openvibers/openvibe.watch README.md

README.md

main 54.1 KB · 666 lines · markdown

1# OpenVibe.Watch
2
3> Persistent, user-defined observations and conditions over pages, feeds, APIs, webhooks and OpenVibe events: define what to
4> watch, get told when it changes or fires — cheaply, because a Watch never polls when an event or
5> the site's own ETag will do.
6
7**Live site:** https://openvibe.watch · **API docs:** https://openvibe.services · **For AI:** https://openvibe.watch/llms.txt
8**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
9
10**Status:** alpha (plan T18). The registry, the check engine, the pull carriers (`http`, `feed`, `api`), the push
11sources (OpenVibe events and signed webhooks), Link probes from the owner's own machine, the actions (a notification,
12an event, a webhook, an Actor task or agent run, a Run job, a Codes task) and the public site (sign-in, seven starting
13points, forms) exist. The Run rung (browser or code polling through OpenVibe.Run) and the `ai` extraction do not
14yet.
15**Domain:** `openvibe.watch` (a public, server-rendered site at [Public site](#public-site); the API
16is loopback-only). Install steps: [Public host](#public-host).
17**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — T18 (Watch complete),
18the carrier preference order, "a Watch has source, cadence or event source, extraction, comparison,
19condition, action, budget, retention".
20**License:** AGPL-3.0.
21
22## Purpose
23
24Sources ingests the sources a product chose; Watch is the place a **person or an agent** defines
25their own observation: "this page's price", "this feed's latest release", "this API's stock", and
26the condition on it. Sources performs permitted structured ingestion; **Watch owns user-defined
27persistent observations and conditions**; Run executes expensive browser/code checks; Link observes
28local/LAN things; Actor interprets and acts; Events transports the trigger.
29
30A Watch is a first-class OpenVibe resource (`watch.watch@1`), so products compose it instead of
31each growing a watcher: Rent, Homes, Work, Deals, Reviews, price trackers, uptime, releases.
32
33## Running it
34
35```bash
36npm install
37cp .env.example .env
38npm run dev # http://127.0.0.1:4730
39npm test # every test/*.test.js against local stub sites; no internet
40npm run test:pg # the same through PostgreSQL + PgBouncer (the CI path)
41```
42
43Node 22 in production. Production: `/opt/openvibe.watch`, env `/etc/openvibe/watch.env`, unit
44[deploy/systemd/openvibe-watch.service](deploy/systemd/openvibe-watch.service), database `ov_watch`
45on the host's data role (ADR-035; `sudo /opt/openvibe.host/roles/data/add-service.sh watch`), nginx
46[deploy/nginx/openvibe.watch.conf](deploy/nginx/openvibe.watch.conf).
47
48`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the
49database fails; a Network key that has not loaded and a check worker that is off, stopped or behind
50(a watch due for more than 15 minutes) degrade it. It reports watch counts by health status, checks
51in flight, observations by state and the outbox backlog. `GET /metrics` (openvibe-shared/metrics)
52answers direct loopback callers only: golden signals by route template, `watch_watches{status}`,
53`watch_observations{state}`, the check queue (`watch_checks_due`,
54`watch_checks_oldest_wait_seconds`, `watch_checks_in_flight`),
55`watch_last_check_timestamp_seconds`, `watch_last_success_timestamp_seconds`.
56
57Request telemetry: every HTTP request is aggregated by route template, method and status class, never
58by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and
59event-loop-lag gauges, goes to this service's own `telemetry_samples` table (openvibe-sdk 0.46,
60`platform.telemetry-sample@1`, migration 0011); rows older than 14 days are deleted daily. `/shared`
61files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` mounts
62openvibe-sdk's `telemetryMiddleware` (a no-op until init); `server/index.js` calls `telemetry.init()`
63at production start, runs the retention and flushes on stop.
64
65## Public site
66
67`openvibe.watch` is also the place a person manages their own watches in a browser. Every page is
68server-rendered and complete without JavaScript: the create and edit forms POST, pause/resume/
69delete/check-now are plain forms, and the fields a form shows are chosen by the query
70(`/watches/new?template=price`), not by script. The visual layer is plain semantic HTML on the
71classes openvibe-shared's frame and this repository's `public/css/app.css` already provide.
72
73**Sign-in** is OpenVibe.Network's OAuth 2 authorization code with PKCE (S256), as the OAuth client
74`watch` (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`), redirecting to `BASE_URL/auth/callback`.
75The Network token is verified once (issuer, audience `openvibe.network`, a `usr_…`/`agt_…` subject)
76and then discarded; the browser holds an opaque random session token in an httpOnly, SameSite=Lax
77cookie, and Watch's database stores only its SHA-256 hash (`web_sessions`, migration 0002). The
78signed-in person's `usr_…` subject is the watch owner: the site calls the **registry directly,
79in-process**, with exactly the ownership rules the API applies — another person's watch is 404 on
80every page and every form, never 403. A signed-in write must also come from this site itself
81(same-origin), so another site cannot make a visitor act here.
82
83| Page | What it shows |
84|---|---|
85| `/` | what Watch is in one paragraph; the seven starting points as cards; channels active on OpenVibe.Live lately (live first) with a "tell me when they go live" watch each; ready-made watches (Host plan prices, release and job feeds, each blog's new posts); how it checks in plain words; the API samples; the questions (FAQPage JSON-LD); your watches summary (counts by status) when signed in. The channels, plans and blogs are OpenVibe.Search's public documents, read anonymously and kept 10 minutes ([server/http/network-reads.js](server/http/network-reads.js), `WATCH_SEARCH_URL`, `https://search.openvibe.network` in production, off elsewhere); a section with nothing to show is left out |
86| `/watches` | your watches: name, source host, condition in words, status, last check state and time, last value, next due — paged with `before` |
87| `/watches/new?template=page\|price\|feed\|json\|event\|webhook\|lan` | the one create form, showing only the fields the chosen kind needs; a link may fill it in (`url`, `name`, `value_path`, `op`, `value`, `event`, `event_filter`, `agent_id`), and the person still reviews and saves |
88| `/watches/:id` | the watch in words, status with pause/resume/delete/check-now, the observation history (time, value, changed, condition met) and the check runs with their states in plain words, both paged |
89| `/watches/:id/edit` | the same form, PATCH semantics |
90| `/how-it-works`, `/updates` | the carrier order, the manners and every state, in plain words; Watch's own changelog |
91| `/robots.txt`, `/sitemap.xml`, `/llms.txt(/-full)` | discovery: the home page and how-it-works are listed; `/watches` and `/auth` are disallowed |
92
93**Seven starting points**, each a page of the same form ([server/http/pages.js](server/http/pages.js) `TEMPLATES`):
94
95| Template | Source | Extraction | Condition |
96|---|---|---|---|
97| `page` | http | html, an optional CSS selector | `changed` |
98| `price` | http | css selector or regex | `lt`/`lte`/`gt`/`gte` |
99| `feed` | feed | the newest item (`latest`) | `changed` |
100| `json` | api | json with an optional `value_path` | any of the ops |
101| `event` | event (a channel goes live, a blog post, a wiki page published …) | the event | `changed` |
102| `webhook` | webhook (the watch's own signed address) | json with an optional `value_path` | any of the ops |
103| `lan` | node (a host:port on the owner's own network, checked by their own computer) | `status` | `eq` `down` |
104
105Besides **Notify me**, the form can run one of the person's OpenVibe.Actor agents (`actor-agent`, by its `agt_` id,
106with an optional note) or start a coding task on OpenVibe.Codes in a repository they name (`codes-run`) when the watch
107fires.
108
109The cadence is a select (15 minutes, 1 hour, 6 hours, 1 day); 15 minutes is the shortest a person may
110choose here (the contract's floor is 30 seconds, for a service). A validation error re-renders the
111form with the person's own input and the registry's own message next to the field it names. The
112kind whose carrier does not exist yet (`run`) and the `ai` extraction are **not offered** — and a watch of such a kind
113created through the API still answers `watch.carrier_unavailable` when checked from the site, fetching nothing. An
114event watch is checked when its event arrives and a webhook watch when a signed delivery reaches its `/hooks/` address,
115so Check now answers `watch.event_awaited` or `watch.webhook_awaited`, fetching nothing.
116
117Check-now from the page respects the API's limits and its refusals: a second check while one runs or
118a check of a paused watch is the API's 409, said in words; a kind without a carrier is the API's 422;
119and the manual-check and write budgets (10/120 and 30/600 per person, a minute/hour) are enforced
120with a friendly page. "Notify me" stores the notification action: when the condition fires the owner gets one
121notification on OpenVibe (through OpenVibe.Network), and the watch page lists what each firing did under "What it did
122when it fired".
123
124## The watch model
125
126A Watch is: `source`, `cadence` (or an event pattern), `extraction`, `comparison`, `condition`,
127`action`, `budget`, `retention`, plus `owner`, `status`, `labels`. It is validated against
128`watch.watch-request@1` on the way in and served as `watch.watch@1` on the way out — both with
129openvibe-contracts' own validator, never a hand copy of the schema. Rules a schema cannot carry
130([server/registry.js](server/registry.js)): a create requires source, extraction, condition and
131action; a source that polls (`http`, `feed`, `api`, `node`, `run`) needs `cadence.every_sec`; an
132`event` or `webhook` source is pushed to and never polls (`cadence: null`); the headers the fetcher
133owns (`Host`, `Cookie`, `User-Agent`, `If-None-Match`, …) cannot be overridden.
134
135**Ownership is the authority.** Every read and write is scoped to the acting subject — a person's
136`usr_…`/`agt_…` bearer token, or a first-party service acting for a person with `X-OV-Subject` —
137and another owner's watch is **404, never 403**: an id must not confirm that someone else's watch
138exists. A service principal that does not name a person owns what it creates.
139
140## Carrier preference (binding, plan T18)
141
142 webhook/event → ETag/Last-Modified → feed → API → and only then browser polling through Run
143
144The rungs run from cheapest to most expensive, and `source.kind` fixes which one a watch uses.
145This release carries the pull rungs and event sources:
146
147| Kind | Carrier | State |
148|---|---|---|
149| `http` | one URL, conditional | implemented |
150| `feed` | RSS 2.0 / RSS 1.0 (RDF) / Atom 1.0 → `{ items, latest, count }` | implemented |
151| `api` | JSON/XML with `items_path` + `fields`, or the body itself | implemented |
152| `event` | delivered by OpenVibe.Events (see [Event sources](#event-sources)) | implemented |
153| `webhook` | a signed POST to `/hooks/<watch id>` (see [Webhook sources](#webhook-sources)) | implemented |
154| `run` | browser/code polling through OpenVibe.Run | step 6 |
155| `node` | a probe from the owner's own machine through OpenVibe.Bot and OpenVibe Link (see below) | implemented (step 7) |
156
157**Link sources** (plan T18 step 7, contracts 0.167.0, [server/carriers/node.js](server/carriers/node.js)): `{ kind: 'node', node_id: 'dev_…', probe: { target, timeout_s } }`. The check runs from the owner's own machine:
158- **The call**: Watch asks OpenVibe.Bot (`WATCH_BOT_URL`, default `http://127.0.0.1:4630`) with its own token (`bot.computer.control`) and the owner in `X-OV-Subject`. Bot answers only for that person's machine, only where they turned on "Let my watches check things from this computer", and Watch may send nothing but the read-only `probe`.
159- **Targets**: the machine's OpenVibe Link reaches only its own network (private, loopback or link-local addresses).
160- **http(s) targets**: the answer's body is extracted like a page, and a status other than 200/203 is an `http_error`.
161- **tcp targets**: the document is `{ open, status: 'up' | 'down', ms }`.
162- **Owners**: an agent-owned watch is never probed (`node_person_only`).
163- **The form**: its "Something on your network goes down" template makes one: a machine id, a host:port, and the condition `status` eq `down`.
164
165A kind whose carrier does not exist yet is **accepted by the registry but never fetched**: the
166check is recorded `skipped` with `watch.carrier_unavailable` and the reason names the step. That is
167what keeps a half-built rung from silently turning into polling.
168
169## Event sources
170
171A watch whose source is `{ "kind": "event", "pattern": "host.release.published", "filter": { "repo": "OpenVibe.Bot" } }`
172(and `cadence: null`) is checked when a matching OpenVibe.Events event arrives, and never on a cadence, so it costs
173nothing while nothing happens (`server/event-sources.js`, `server/carriers/event.js`).
174
175- **Matching.** The pattern matches the event type as Events matches topics: `*` stands for one or more whole
176 segments (`media.*.ready`). Every `filter` key must equal the event's value: `subject.id`, `subject.type`, or a
177 top-level `payload` field.
178- **What the extraction reads.** The event is the check's document: `{ event_type, event_id, timestamp, subject,
179 payload }`, so `{ "kind": "json", "value_path": "payload.version" }` takes the released version. Comparison,
180 condition, observations and actions work exactly as for a fetched page.
181- **Only public events.** Anyone may own a watch, and an internal or private event is not theirs to read, so only
182 events with `visibility: "public"` wake a watch.
183- **Once per event.** Events delivers at least once; each `event_id` is handled once (`event_source_receipts`, kept
184 seven days), so a redelivery is answered `duplicate`.
185- **A budget.** At most `WATCH_EVENT_MAX_PER_HOUR` (60) event checks per watch an hour. More are dropped and counted,
186 so a busy pattern cannot turn one watch into a write storm.
187- **Subscriptions.** Watch keeps one Events subscription per distinct pattern of an active event watch, delivered to
188 the same signed loopback route as the account events (`POST /internal/events`, `WATCH_EVENTS_SECRET`). A sync at
189 boot, every `WATCH_EVENT_SYNC_MS` (10 minutes) and two seconds after an event watch changes creates the missing ones,
190 re-enables one a watch needs again, and disables the ones no watch needs. It never touches the account
191 subscriptions or another endpoint's. It is off without `EVENTS_URL`, the secret or the OAuth client secret.
192- **Check now** on an event watch answers 422 `watch.event_awaited`: there is no event to read.
193
194## Webhook sources
195
196A watch whose source is `{ "kind": "webhook" }` (and `cadence: null`) is woken by a POST to
197`https://openvibe.watch/hooks/<watch id>` from anything that can call a webhook: GitHub, a CI, a store, another OpenVibe
198product (`server/webhook-sources.js`, `server/carriers/webhook.js`).
199
200- **The address is the watch's own.** Its stored endpoint is `/internal/hooks/<watch id>` (the form `watch.watch@1`
201 names), whatever a caller sent, so a watch can never point at another watch's address.
202- **The secret is shown once.** Creating the watch answers `{ watch, webhook: { url, secret } }`. The secret
203 (`whk_…`) is never shown again; `POST /api/v1/watches/:id/webhook-secret` replaces it (the old one stops working at
204 once). It is kept in full because checking a signature needs it (`webhook_secrets`), and it goes with its watch:
205 deleting the watch or the account removes it.
206- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form),
207 `Authorization: Bearer <secret>` for a sender that cannot sign, or Telegram's `X-Telegram-Bot-Api-Secret-Token`
208 (give the secret as `setWebhook`'s `secret_token`). Compared in constant time.
209- **A sender's own secret (agent plan A3).** Slack, Stripe and Discord sign with a secret or key they make. Paste it on the watch's
210 page ("Signed by Slack or Stripe?") or send `POST /api/v1/watches/:id/webhook-secret` with
211 `{ "provider": "slack" | "stripe" | "discord", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept
212 (never shown again) instead of Watch's own. A bad provider or secret answers 422 and changes nothing; a new secret of
213 Watch's own (the same call with no body) replaces it.
214 - `slack`: `X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<X-Slack-Request-Timestamp>:<body>">`, the timestamp within
215 five minutes. Slack's `url_verification` is answered with its `challenge`, and a retry (`X-Slack-Retry-Num`) is
216 acknowledged without checking twice.
217 - `stripe`: `Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (any of several `v1`), the time
218 within five minutes.
219 - `discord`: `X-Signature-Ed25519` over `X-Signature-Timestamp` + body, checked with the application's public key
220 (64 hex characters from the Developer Portal). Paste the address as the Interactions Endpoint URL. Discord wants
221 its reply within three seconds: its ping gets `{ "type": 1 }`, and any other interaction a quiet `{ "type": 4 }`
222 ("Got it") at once, with the check run right after on the interaction (`value_path: "data.name"` reads a slash
223 command's name). A bad signature answers 401, as Discord checks.
224 - Cases: `test/webhook-providers.test.js`.
225- **Email in (agent plan A3).** A webhook watch can have an email address of its own instead of a secret: "Wake it by
226 email?" on its page, or `POST /api/v1/watches/:id/webhook-secret` with `{ "provider": "email" }`, answers
227 `{ address: "in-<26 characters>@openvibe.watch" }`. The address is the key (anyone who has it can wake the watch);
228 asking again makes a new one and retires the old, and a webhook secret replaces it.
229 - Cloudflare Email Routing receives the domain's mail (SPF, DKIM, DMARC checked there) and its catch-all rule hands
230 every message to the mail Worker ([edge/mail-worker.mjs](edge/mail-worker.mjs)). The Worker posts
231 `{ to, from, raw (base64, at most 180 KB), truncated, size }` to `POST /hooks/email` with
232 `X-OpenVibe-Mail-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (`WATCH_MAIL_INGRESS_SECRET`, within
233 five minutes).
234 - Watch finds the watch by the address, reads the message ([server/mail-parse.js](server/mail-parse.js): headers,
235 encoded words, multipart, base64 and quoted-printable, HTML-only mail as text) and runs the check on
236 `{ from, from_name, to, subject, date, message_id, text, text_truncated, attachments }` (`value_path: "subject"`).
237 `from` is the envelope sender Email Routing checked; `header_from` is what the message says.
238 - An unknown address answers 404 and a paused watch 409, and the Worker refuses the mail, so its sender gets a bounce.
239 Over the hour's budget the mail is taken and not checked; a busy watch is tried once more after 1.5 s.
240 - Deploy: `ssh host "sudo sed -n 's/^WATCH_MAIL_INGRESS_SECRET=//p' /etc/openvibe/watch.env" | node deploy/mail-worker.js --secret-stdin`
241 uploads the Worker, turns Email Routing on for the zone and points the catch-all at the Worker.
242 - Cases: `test/email-in.test.js`.
243- **What the extraction reads.** A JSON delivery is parsed and becomes the document (`value_path: "order.total"`); any
244 other is read as text (`text`, `regex`). A delivery that says JSON but is not records `parse_error`.
245- **Answers.** 200 `{ watch_id, state, triggered }` after the check. 401 for a missing or wrong signature. 404 for an
246 unknown address or a watch that is not a webhook watch. 409 for a paused watch. 413 over 256 KB. 429 with
247 `Retry-After` over `WATCH_WEBHOOK_MAX_PER_HOUR` (60) deliveries an hour. 503 with `Retry-After: 1` while the watch's
248 previous check still runs.
249- **Check now** on a webhook watch answers 422 `watch.webhook_awaited`: there is no delivery to read.
250
251## Provider-rate evidence (`WATCH_PROVIDER_RATES`, off)
252
253PLAN P6 gives Watch/Sources the permitted, provenance-backed market data and observed rate changes; the supply
254rate card itself stays with OpenVibe.AI. This is that observation feed, behind `WATCH_PROVIDER_RATES=1` (off by
255default): two official, machine-readable provider price documents become supply rate observations, a difference
256raises a change, and a person approves it. **The approval is evidence; nothing here is billable and no rate card is
257written.** AI's catalog operator applies an approved change to the supply rate card through AI's own path.
258
259**Why Watch and not Sources.** Sources' own README scopes it to the material the publication products ingest and
260publish ("Products read items with provenance and decide — under their own review and indexability rules — what, if
261anything, to publish"), and `sources.source@1`'s `type` is a released enum. A provider price that may become a
262billable supply rate is not publication content; the observation, the change, the condition and the human decision
263are Watch's model, and PLAN B assigns observed rate/model changes to Watch/Sources jointly. So the source kind
264(`provider-rate`) lives here, and the one ingestion registry stays Sources for product content.
265
266**The two providers** (both in OpenVibe.AI `docs/provider-matrix.md`, both keyless and JSON — no page is scraped):
267
268| Source kind | Provider | Official document | Access in the matrix |
269|---|---|---|---|
270| `provider-rate` | DeepInfra | `https://api.deepinfra.com/models/list` (documented in its OpenAPI) | `RESTRICTED` for resale; evidence only |
271| `provider-rate` | Featherless AI | `https://api.featherless.ai/v1/models` (OpenAI-compatible) | `AUTHORIZED_WHOLESALE` (Scale only); terms §3 permit resale, subject to an agreement |
272
273They are the first two because they publish a price per model in a document a program can read without an account
274or an HTML parse, and they bracket the matrix: Featherless is the one provider whose terms permit resale, so its
275supply price is the one the exchange will most want to know; DeepInfra is an existing OpenAI-compatible adapter
276target with an exact keyless list. Each source's row records its terms and robots notes; **it starts disabled**, and a
277person enables it after reading them even when the flag is on.
278
279**What an observation is** ([server/provider-rates.js](server/provider-rates.js), migration
280`0018_provider_rates.sql`): `provider`, `model`, `token_class` (`input`, `output`, and `cached_read` where the
281document states it), `nano_usd_per_m` as an **integer string** (D1: never a float), `currency`, `region`, the
282`effective_date` (the first day the value was observed; an unchanged value keeps its date, so the rows show effective
283periods), `source_url` (the URL actually fetched) and `fetched_at`.
284
285- **Units.** DeepInfra states cents per token: `× 1e13` is nano-USD per million. Its `rate_per_input_token_cached`
286 is a multiplier (the pricing page renders input × it), not a price, and `discount` is applied while active
287 (`price × (1 − discount)`). Cache-write is not recorded: the document states it only as a retention-dependent
288 multiplier (5m/1h), so no single cache_write rate is invented.
289 Featherless states USD per token as decimal strings: `× 1e15` is nano-USD per million; its per-million display
290 numbers are a fallback only. `region` is `global`: neither document scopes its price by region, so no region is invented.
291- **Changes.** A successful parse writes one immutable observation per `(model, token_class)` and updates the
292 source's validators. When the value (or currency/region) differs from the last observation, the same transaction
293 writes a `provider_rate_changes` row in state `pending` and a `watch.rate.changed` event with
294 `approval_required: true`. An unchanged value is not written again (its first observation already records it and
295 keeps the effective date); the fetch row's `stated` and `observations` counts say what the document said versus what
296 was new, and no change is raised. A new model is recorded but raises no change (there is no previous value to differ
297 from).
298- **Failures.** A malformed document (invalid JSON, a price that is not a finite non-negative decimal, no priced
299 model) records a `parse_error` fetch and **no observation at all** — a missing price is never zero. A 304 is
300 `not_modified`; an HTTP error is `http_error`/`rate_limited`; stale means the last success is older than
301 `stale_after_sec`. The health (`healthy | stale | failing | never_fetched | disabled`) is computed, never guessed,
302 and the last good observations stay visible with their labels.
303- **Approval.** `POST /api/v1/provider-rates/changes/:id/approve` (or `/reject`) requires a **person** (`usr_…`),
304 not a service or an agent, and records who and when; the answer says `applied: false` and the change's `effect` is
305 `evidence`. A second decision is `409`. A `watch.rate.approved`/`watch.rate.rejected` event carries the decision,
306 so AI's operator can apply it, but Watch itself has no rate-card table and no writer for one.
307
308API (mounted only with the flag on; `watch.watch.read` to read, `watch.watch.manage` to enable/approve,
309`watch.check.run` to refresh): `GET /api/v1/provider-rates`, `GET .../sources[/:key]`,
310`GET .../observations?latest=1&source=&model=&token_class=`, `GET .../changes[?state=pending]`,
311`GET .../changes/:id`, `PATCH .../sources/:key` `{enabled}`, `POST .../sources/:key/refresh`,
312`POST .../changes/:id/approve|reject` `{note?}`. The worker fetches enabled sources on
313`WATCH_PROVIDER_RATES_TICK_MS` (60 s) every `poll_interval_sec` (6 h); with the flag off the module is not created,
314no route exists and nothing is seeded.
315
316## A check
317
318An in-process scheduler ([server/scheduler.js](server/scheduler.js)) starts checks for active
319watches whose `next_due_at` and `not_before` have passed, oldest first, at most
320`WATCH_MAX_CONCURRENT` at a time; `check.run()` refuses a second check of the same watch, so a
321manual `POST …/check` never overlaps a scheduled one. One check
322([server/check.js](server/check.js)):
323
3241. **Carrier** chosen by `source.kind` (never for event/webhook/node/run — see above).
3252. **Fetch** through the one way out: the SSRF guard, per-host spacing, conditional GET.
326 - `If-None-Match` / `If-Modified-Since` from the stored validators; a **304 → `not_modified`**
327 (no body, no observation).
328 - When the watch only needs to know *whether* it changed (`condition.op: changed`), a **HEAD**
329 is tried first while a validator is stored: two headers instead of a body.
330 - A 200 whose body hashes to the last body → **`no_change`** (no observation).
331 - 429/503 `Retry-After` → `not_before`, so Watch waits as long as the site asked.
3323. **Extract** the value (table below) from the body, or from the records a feed/api carrier mapped.
3334. **Compare and evaluate**: `changed` (with `tolerance`/`percent`), the predicate, and the
334 `for_sec` debounce measured from the observation trail — an unchanged body still evaluates the
335 condition, so "this has been true for five minutes" can fire without the page changing.
3365. **Record**, in ONE transaction: the `check_runs` row, the `observations` row (value, hashes,
337 changed, condition_met, capped snapshot, `retained_until`) and the events.
3386. **Arm** `next_due_at` (the cadence plus jitter, or an exponential backoff after failures, capped
339 at `WATCH_MAX_BACKOFF_MS`).
340
341A condition that fires is recorded on the check run (`state: condition_met`, `triggers: 1`) and
342emits `watch.watch.triggered`; it fires again only for a value that is new or for a new run of
343holding, never once per check while nothing moves (the last firing is compared by when it finished: the holding trail
344starts at the observation the firing run recorded).
345
346## Actions
347
348A fired condition queues the watch's actions in the same transaction as the check run that fired
349(`server/actions.js`, table `action_runs`): each action once per trigger. The action worker (every
350`WATCH_ACTIONS_TICK_MS`, up to `WATCH_ACTIONS_BATCH` at a time, off with `WATCH_ACTIONS=off`) delivers them:
351
352| kind | what happens |
353|---|---|
354| `notification` | the owner gets one OpenVibe notification through OpenVibe.Network (`openvibe-sdk/notifications`, type `WATCH_TRIGGERED`): the watch's name, the value (and the value before), and a link to the watch page. A refusal retries after 1, 5 and 30 minutes, 2 and 6 hours, then `failed`; an owner Network does not know (a deleted account) is `skipped`; a person who switched the category off is Network's decision and counts as `sent`. A watch owned by an agent or a service has no one to notify (`skipped`). Needs the Network OAuth client secret and the grants `identity.subject.resolve` + `network.notifications.push` |
355| `event` | `watch.watch.triggered` is emitted with every firing in the run's own commit, so that action is recorded `sent` as it is queued (the event names a person: an agent- or service-owned watch is `skipped`). Any other event type is not declared by Watch: `unavailable` |
356| `webhook` | a POST of the firing (`type`, `watch {id, name, url}`, `check_run_id`, `condition`, `observation {observed_at, value, previous_value}`) to the action's https URL, through the same SSRF guard as every fetch, never following a redirect ([server/net/post.js](server/net/post.js)). It is signed with the key `secret_env` names exactly as OpenVibe.Events signs a delivery (`X-OpenVibe-Timestamp` + `X-OpenVibe-Signature-V2`; check it with openvibe-sdk/events `verifyDeliveryV2`). `X-OpenVibe-Delivery-Id` (`<check run>.<action index>`) is the same on every attempt, so a receiver can drop a repeat. 2xx is `sent`. 410 or a URL the guard refuses is `failed` at once. Anything else, or a lost connection, is retried on the backoff, then `failed`. The key must be granted to the owner (`WATCH_SECRET_GRANTS`, below) and set, 32 characters or more: otherwise `unavailable` |
357| `actor-task` | a task for the owner on OpenVibe.Actor ([server/actor.js](server/actor.js)): the action's `brief`, followed by the firing as context (the watch, the value and what it was, its page), at most `budget_usd`. Watch names the owner in `X-OV-Subject`, so the task runs under **the owner's** allowance and appears in their task list, and Actor records Watch as `via`. The idempotency key `watch.<check run>.<action index>` makes a retried delivery the same task. Created is `sent`, with the task id recorded. A request Actor refuses as invalid (over the tier's budget, an unknown agent) is `failed` at once. Anything else, including a day's allowance spent, is retried on the backoff. An agent- or service-owned watch is `skipped`. Needs `actor.task.create` for `openvibe.actor` (Network grants it) and `WATCH_ACTOR_URL` |
358| `actor-agent` | runs one of the person's OpenVibe.Actor agents by `agent_id`. Its optional `note` precedes the firing context (watch name and link, value and previous value, observation time); input is capped at 8000 characters without cutting the note. Watch uses `X-OV-Subject` for the owner. A created run is `sent` with its task id; a missing agent, limit or free-tier refusal is `failed` for that firing; service errors retry on the usual backoff. Only person-owned watches may use it. Needs the `actor.agent.run` grant for `openvibe.actor` and `WATCH_ACTOR_URL` |
359| `run-job` | one OpenVibe.Run job for the owner in the watch's project ([server/run-jobs.js](server/run-jobs.js)): the action's `run.job-create-request@1` fields (`class`, `artifact`, `args`, `limits`, `egress`, `ttl_ms`), with the firing (watch, check run, value, previous value, time) under `args[firing_arg]` when the action names one. Watch submits as itself with the owner in `X-OV-Subject` and the project in `X-OV-Project` (project delegation, ADR-003 amendment 2026-10-12): Run bills the project, checks the owner is still an admin or owner of it, makes the owner the requester and records Watch as `via`. Key `watch.<check run>.<action index>`, so a retry is the same job. Created is `sent` with the job id; Run's final refusals (400, 403, 404, 409, 413, 422) are `failed` at once; anything else retries on the backoff. `WATCH_RUN_URL` is Run's internal address (default `http://127.0.0.1:4920`). |
360| `codes-run` | a coding task on OpenVibe.Codes for the owner, run on the owner's own machine ([server/codes-tasks.js](server/codes-tasks.js), Contracts 0.162 `codes.task.start`): the action's `brief` with the firing as context (at most 8000 characters), in the `repo` it names. Watch starts it as itself with the owner in `X-OV-Subject`; Codes runs it in the project of the owner's latest openvibe.codes/run task and lists it on their /run page. Key `watch.<check run>.<action index>`, so a retry is the same task. Created is `sent` with the task's page; a refusal a retry cannot change (400, 403, 404, 409 — `codes.no_project` when the owner has never started a task on /run — 413, 422) is `failed` at once; anything else retries on the backoff. A person must own the watch: otherwise `skipped`. The create and edit forms offer it ("Start a coding task on my own machine"). `WATCH_CODES_URL` is Codes' internal address (default `http://127.0.0.1:4900`). |
361
362**Into a team room.** An OpenVibe.Chat workspace room's hook (room settings → "Alerts into this room") takes a
363`webhook` action as it is: its address is the action's `url`, and its `whsec_` secret, saved as a project secret granted
364to `watch`, is the action's `secret_ref`. Each firing then arrives in the room as a message from Vibe, with the watch's
365link, and can queue a task for the hook's maker.
366
367A watch with a `run-job` action names a **project** (`project_id` on `watch.watch-request@1`, required for it:
368`422 watch.project_required`). Only a person names one, and only a project OpenVibe.Network says they are an admin or
369owner of and that is not archived (`GET /internal/projects/:project/members/:subject`, `network.project.member.read`),
370checked when the project is named or changed: `403 project.not_delegated` otherwise, `503
371watch.project_check_unavailable` when Network cannot answer. Run checks again before every job. The run-job action
372needs `class` and `limits` (and `artifact` for `function` and `code`); the contract draft's `runtime`, `command` and
373`workload` are refused.
374
375Rows are claimed with a short lease (FOR UPDATE SKIP LOCKED), so two processes never deliver one row at once.
376Network keeps no idempotency record for a push, so a lost answer can repeat a notification: delivery is at least once.
377`/api/ready` reports the queue (`actions: { enabled, queued, failed, sent }`).
378
379Nothing is invented: a value the source did not state is `null`, a failed check is a recorded state,
380and the validators/body hash are stored only after a body was read successfully, so an unreadable
381body is fetched and parsed again next time.
382
383`check_runs.state` is one of `ok | not_modified | no_change | changed | condition_met | http_error |
384timeout | parse_error | rate_limited | budget_exceeded | skipped | disabled`; every check re-arms the
385watch on every terminal path.
386
387### Extraction (`watch.watch@1` `$defs/extraction`)
388
389| Kind | Reads |
390|---|---|
391| `text` | the whole body as text, trimmed (capped at 64 KiB) |
392| `html` | the body's text, or the text of the first element `selector` matches |
393| `css` | the same, with the small selector subset: `tag`, `.class`, `#id`, `[attr]`, `[attr=value]`, descendants and `>` |
394| `json` / `jsonpath` | the parsed body, or a carrier's mapped `{ items, latest, count }`; `value_path` picks a part (`latest.price`, `items.0.title`), `fields` several at once |
395| `regex` | the first match of `selector` (its first group when it has one), e.g. `(\d+\.\d+) EUR` |
396| `ai` | **not a check**: an AI reading is an action (step 10). The watch is accepted, the check is recorded `skipped` with `watch.extraction_unavailable`, and nothing is fetched |
397
398A path that matches nothing and a pattern that does not match extract `null` — which `absent` then
399answers truthfully.
400
401### Conditions (`watch.watch@1` `$defs/condition`)
402
403`changed` (the default), `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `contains`, `matches`, `exists`,
404`absent`; `for_sec` debounces a condition that must hold that long. Comparison refuses to guess: a
405value that is not a number (or a plain decimal string) is never "greater than" anything, and an
406unknown value is never low — the same rule Deals' watches use. `comparison.tolerance` (absolute)
407and `comparison.percent` decide whether a move counts as a change at all.
408
409### Observations
410
411Immutable rows (`watch.observation@1`): the extracted `value`, its SHA-256 `value_hash` over the
412canonical JSON, `previous_hash`, `changed`, `condition_met`, a capped `snapshot` of the body it came
413from, and `retained_until` (the watch's `retention.observations_days`, `WATCH_RETENTION_DEFAULT_DAYS`
414= 90 by default; `keep_snapshots: false` stores no snapshot). An hourly prune drops what is past its
415retention.
416
417## API (bearer tokens, one capability per route)
418
419Callers present an OpenVibe.Network token: a service's client-credentials token for audience
420`openvibe.watch` (judged by its `cap` claim with the contracts grant rule), or a signed-in person's
421or agent's token — either a token minted for `openvibe.watch` or the Network session token the
422signed-in site holds (`aud: openvibe.network`); a watch is theirs, owned by the canonical
423`usr_…`/`agt_…` `subject_id`, and ownership scoping is the authority. A first-party service
424(`svc:…`) may present `X-OV-Subject: usr_…` to act for a person; an app or module may not.
425
426| Capability | Routes |
427|---|---|
428| `watch.watch.read` | `GET /api/v1/watches?status=&project_id=&before=&limit=`, `GET /api/v1/watches/:id` |
429| `watch.watch.manage` | `POST /api/v1/watches`, `PATCH /api/v1/watches/:id`, `POST /api/v1/watches/:id/pause`, `POST /api/v1/watches/:id/resume`, `POST /api/v1/watches/:id/webhook-secret`, `DELETE /api/v1/watches/:id` |
430| `watch.observation.read` | `GET /api/v1/watches/:id/observations?before=&limit=`, `GET /api/v1/watches/:id/checks?before=&limit=` |
431| `watch.check.run` | `POST /api/v1/watches/:id/check` (404 unknown, 409 `watch.busy` while one runs, 409 `watch.disabled` for a paused watch, 422 `watch.carrier_unavailable`, or 422 `watch.event_awaited` for an event source) |
432| `watch.resource.read` | `GET /api/v1/resources?project=&kind=&cursor=&limit=`, `GET /api/v1/resources/:ovrn` (first-party service token only) |
433| `watch.resource.control` | `POST /api/v1/resources/control` (first-party service token only; ADR-048, [server/api/resource-control.js](server/api/resource-control.js)): a `common.resource-control-request@1` for a watch from OpenVibe.Services acting for its owner (`on_behalf_of`) — `suspend` (pause), `resume`, `delete`; other actions refused `control.unsupported_action`; only the owner's watch in the OVRN's project (otherwise `resources.unknown_resource`); `dry_run` applies nothing; the first answer per (caller, project, `idempotency_key`) is kept (`watch_control_log`, migration 0009) and a key reused for another request is 409. Loopback only: the public vhost 404s `/api/v1/resources*`. |
434
435Provider-rate evidence (`WATCH_PROVIDER_RATES=1` only; off by default) adds
436`GET /api/v1/provider-rates`, `.../sources[/:key]`, `.../observations`,
437`.../changes[?state=]` and `.../changes/:id` on `watch.watch.read`;
438`PATCH .../sources/:key` and `POST .../changes/:id/approve|reject` on `watch.watch.manage`; and
439`POST .../sources/:key/refresh` on `watch.check.run`. Approve and reject require a person (`usr_…`)
440and answer `applied: false`; no route writes a rate card. With the flag off none of these paths exists.
441
442The loopback-only authority index lists non-deleted `watch.watch` resources as
443`common.resource-list-result@1`. It orders watches by id, pages with an opaque cursor, and
444includes an OVRN only for a watch with a valid project id.
445
446The list answer is exactly `watch.watch-result@1` (`{ watches }`): page with
447`before=<the last watch's id>`, since a `wch_` id is a ULID, i.e. creation order. Every watch view,
448observation view and check-run view is validated against its released contract before it is sent.
449Errors are problem+json (`watch.not_found` 404, `watch.invalid` 422 with the validator's detail).
450
451### Per-actor limits
452
453Every route also limits the principal that passed its capability guard, before any work:
454[server/api/actor-limits.js](server/api/actor-limits.js), openvibe-sdk/limits. Past a limit: `429`
455problem+json `rate_limited` with `Retry-After`, one `[limits]` log line and
456`watch_rate_limited_total{limit,window}`.
457
458| Routes | Per principal, a minute / an hour |
459|---|---|
460| Reads by an app or module (`app:…`, `mod:…`) | `WATCH_LIMITS_MINUTE` / `WATCH_LIMITS_HOUR` (120 / 3000) |
461| `POST`/`PATCH`/`DELETE /api/v1/watches[/:id]`, pause, resume | 30 / 600 |
462| `POST /api/v1/watches/:id/check` | 10 / 120 |
463
464A first-party service (`svc:…`) is not counted on reads: the public site and agent runtimes read for
465many people, and one budget for the whole service would refuse real people's work. Watch's own
466worker (the scheduler, the carriers, the outbox relay) runs in-process and never passes through
467HTTP. Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics` and the home page.
468
469## Events (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set)
470
471- `watch.observation.recorded` (low) — a check recorded an observation, with the value as the source
472 stated it and its hash, never the raw body.
473- `watch.watch.triggered` (important) — a condition fired, with the observation that fired it. This
474 is what OpenVibe.Network turns into a `WATCH_TRIGGERED` notification for `payload.recipient`.
475- `watch.check.failed` (important) — a check ended in a failure state, with `consecutive_failures`
476 so a consumer can tell a blip from a watch that is down.
477
478All `visibility: internal`, subject `{ type: watch, id: <watch_id> }`, actor `service:watch`,
479envelopes validated as `events.event-envelope@1` and payloads against their released contracts. The
480watch-lifecycle events (`watch.watch.created`, `watch.watch.updated`, `watch.watch.paused`,
481`watch.watch.removed`) are emitted on the registry's writes; they have no payload contract yet.
482
483`openvibe-sdk/events` owns the transactional outbox and relay. Pending rows live in `service_outbox`
484(migration 0004); the migration copies pending rows from `event_outbox`, which remains through the
485rollback window. When `EVENTS_URL` or the OAuth client secret is unset, rows wait for the relay.
486
487A watch owned by a service (`svc:…`) still records every observation and trigger in its own tables,
488but the person-facing payloads name a `usr_…`/`agt_…` owner (and a trigger a `usr_…` recipient), so
489those two events are logged as not emitted rather than sent with a payload the contract refuses.
490
491## Account export and deletion (ADR-033)
492
493`network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. It is
494loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with
495`WATCH_EVENTS_SECRET`. They are answered by `server/account-data.js` over `openvibe-sdk/account-data`.
496
497- **Export:** the person's watch definitions, with how many check runs and observations each has.
498- **Deletion:** their watches go with every check run, observation and conditional-GET state under them, and so do
499 their sign-in sessions (never exported). A staff edit's `updated_by` on someone else's watch becomes NULL.
500- **Receipts:** `account_data_events` (migration 0003) keeps one per export and deletion, so a redelivery changes
501 nothing.
502- **Subscriptions:** the two are created at boot when missing.
503
504## Security
505
506Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
507
508- **Auth.** Every `/api/v1` route needs a token for audience `openvibe.watch` with that route's ONE
509 capability (`svc:…` by capability grant, `usr_…`/`agt_…` by identity), then passes the per-actor
510 limits. `X-OV-Subject` is honoured only from a first-party service. The vhost keeps `/api/v1/*`
511 loopback-only.
512- **Ownership.** Reads and writes are scoped to the acting subject; another owner's watch answers
513 404 on every route — and on every page and form of the public site, which acts on the same
514 registry in-process.
515- **Site sessions.** The browser holds an opaque random token in an httpOnly, SameSite=Lax cookie;
516 the database keeps only its SHA-256 hash, and the Network access token is verified once and
517 discarded. A signed-in write must be same-origin, and the page forms escape every value they show
518 (the `html` tagged template, `server/render/html.js`).
519- **Egress.** A watch fetches outside URLs by design, only through the guard:
520 [server/net/guard.js](server/net/guard.js), http(s) on `WATCH_ALLOWED_PORTS` (80/443), every
521 resolved address public at connect time (no DNS rebinding), each redirect hop re-checked, one
522 deadline and a byte cap per response. Exactly one path reaches the network — the carriers, through
523 the fetcher — and only for a kind whose carrier exists. `test/carriers.test.js`.
524- **Secrets.** A source's credential is the **name** of a `WATCH_CRED_*` variable, and a webhook
525 action's signing key the name of a `WATCH_HOOK_*` one. Both are read at use time, never stored or
526 logged. A name is **not a capability**: the operator grants each one in `WATCH_SECRET_GRANTS`
527 ([server/secret-grants.js](server/secret-grants.js)). A credential may be used only by the owners
528 it is granted to, and sent only to the origins it is granted for. A webhook key may be used only by
529 its owners. Otherwise anyone could name an operator's credential with a URL of their own, or have
530 deliveries signed with another receiver's key. A name without a grant is usable by nobody. A save
531 that names one is refused with 422 `watch.secret_not_granted` (the form shows why), and the carrier
532 and the action worker check again at use. `OV_OAUTH_CLIENT_SECRET`, the credentials and the grants
533 live in `/etc/openvibe/watch.env` (0600).
534- **Project secrets (ADR-056).** A watch that names a project may use that project's own secrets instead:
535 `source.auth.secret_ref` for its credential, and `secret_ref` on a webhook action for its signing key,
536 looked up in the watch's `project_env` (production by default). OpenVibe.Network keeps them and the project
537 grants each to `watch` (on openvibe.services, the project's Secrets page). A credential is **revealed** by
538 Network for the source URL's origin only, at fetch time, and used for that one request. A webhook is
539 **signed** by Network over `"<t>.<raw body>"` in the usual headers, so Watch never holds that key. A secret
540 Network refuses leaves the credential missing (the check says so), or makes the action unavailable.
541 Network unreachable is retried. Watch holds `network.secret.use` (Network's default grant), and naming a
542 project secret needs `project_id` (422 `watch.project_required`).
543- **Data.** An observation records what the source stated, never a guess; snapshots are capped and
544 pruned by retention; a failed check never records an observation.
545
546## Owns
547
548- the watch registry (`watches`, `watch_endpoint_state`) and the `watch.watch@1` /
549 `watch.watch-request@1` / `watch.watch-result@1` shapes it serves
550- checks: `check_runs`, `observations`, the scheduler, conditional-fetch state, backoff
551- the public site's sign-in sessions (`web_sessions`) and its pages
552- `watch.*` events
553- provider-rate evidence (plan P6, `WATCH_PROVIDER_RATES` off): the DeepInfra and Featherless official price
554 documents as immutable observations, the pending change when a rate differs, and a person's dated approval —
555 never a rate card
556
557## Does not own
558
559- structured source ingestion for publication (OpenVibe.Sources' registry and adapters): Watch
560 observes what one person or agent asked for, not what a product publishes
561- expensive browser/code execution (OpenVibe.Run, step 6) and local/LAN probes (OpenVibe Link,
562 step 7) — Watch asks them, they run
563- interpreting a trigger and acting on it (OpenVibe.Actor, OpenVibe.Codes, actions = step 4)
564- delivery of a notification (OpenVibe.Network consumes `watch.watch.triggered`)
565- the supply rate card (OpenVibe.AI's catalog): an approved provider-rate change is evidence AI's operator applies
566 through AI's own path, so Watch is never a second authority for a billable price
567
568## Depends on
569
570- PostgreSQL 18 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
571 `openvibe-sdk/db`
572- OpenVibe.Contracts (the watch contracts, service tokens, capability checks, problem details, ids,
573 the event envelope; `wch_`/`wco_`/`ckr_` in `lib/ids.js`)
574- OpenVibe.Network (signing key; service principal `watch`; grants `network.account.export.contribute` and
575 `network.account.deletion.confirm` for account export and deletion, given last, once this release is live)
576- OpenVibe.Events (outbound events; the two account subscriptions, created at boot with `events.subscription.manage`)
577- OpenVibe.Billing (one `check-operation` usage reading per completed check, `POST /api/v1/usage` with `billing.usage.record`; off by default, `WATCH_BILLING_INTERVAL_MS`)
578
579## Capabilities
580
581Implemented here (the service manifest's `capabilities`; routes in [API](#api-bearer-tokens-one-capability-per-route)):
582`watch.watch.read`, `watch.watch.manage`, `watch.observation.read` and `watch.check.run`, each
583checked by [server/auth.js](server/auth.js) with the contracts grant rule for audience
584`openvibe.watch`.
585
586Called elsewhere, as the service principal `watch` (client credentials from OpenVibe.Network):
587
588| Service | Grant | Why |
589|---|---|---|
590| OpenVibe.Events | `events.event.publish` (audience `openvibe.events`) | the outbox relays `watch.observation.recorded`, `watch.watch.triggered`, `watch.check.failed` and the lifecycle events |
591| OpenVibe.Run | `run.*` (plan T18 step 6) | not yet: a `run` source answers `watch.carrier_unavailable` today, and `WATCH_RUN_URL` (`http://127.0.0.1:4920`) is where it will call |
592
593## Acceptance (tests)
594
595- the release contract on both sides: bodies validated against `watch.watch-request@1`, every watch,
596 observation and check-run view against `watch.watch@1` / `watch.observation@1` /
597 `watch.check-run@1`, and every emitted payload against its released event contract
598 (`test/api.test.js`, `test/registry.test.js`, `test/check.test.js`)
599- conditional GET: validators stored, 304 → `not_modified`, identical body → `no_change`, a HEAD
600 answers `changed` without a body (`test/carriers.test.js`)
601- the guard refuses a private host before any socket, a redirect into a private address, a port
602 outside `WATCH_ALLOWED_PORTS`, URL credentials (`test/carriers.test.js`)
603- credentials by variable name, read at fetch time, never stored, missing → `disabled` with no
604 request (`test/carriers.test.js`)
605- feeds and mapped APIs, `items_path`/`fields`, and the parse errors they can raise
606 (`test/carriers.test.js`)
607- every condition op, tolerance and percent, `for_sec` debounce (`test/condition.test.js`); every
608 extraction kind, the selector subset, and `ai` refusing (`test/extract.test.js`)
609- a check writes its run and its observation and emits `watch.observation.recorded`; a fired
610 condition emits `watch.watch.triggered`; failures emit `watch.check.failed` and back off;
611 event/webhook/node/run sources and an `ai` extraction are never fetched; retention prune
612 (`test/check.test.js`)
613- due order, the concurrency cap, `not_before`, backoff, and the worker checking on its own tick
614 (`test/scheduler.test.js`)
615- the API: one capability per route, owner scoping 404, 422 from the validator, pause/resume/delete,
616 X-OV-Subject delegation, the check and observation reads, per-actor 429 (`test/api.test.js`)
617- the registry: required fields, cadence rules, forbidden headers, due selection, re-arm, soft
618 delete, the keyset page (`test/registry.test.js`)
619- the public site (`test/site.test.js`): sign-in through the stand-in Network (PKCE, a session,
620 state mismatch refused, `/auth/me`, sign-out), the signed-out pages, each create template against
621 what the API would store, validation errors that keep the input, another person's watch 404 on
622 every page and form, pause/resume/delete/check-now against a stub site (with the API's 409s and
623 422), cross-site writes refused, and hostile names and values escaped on every page
624
625Demonstrated on production (2026-10-15): a check against a real site from the deployed service.
626- A feed watch on openvibe.work's `/jobs.atom` was made from Work's Get alerts link and submitted through the
627 prefilled form.
628- One check answered HTTP 200 with one observation; the condition was met and the notification fired.
629- The proof watch was deleted.
630
631Not yet demonstrated: the Run rung (step 6), which answers `watch.carrier_unavailable` and fetches nothing
632today (node sources are covered by `test/node-carrier.test.js` against a stand-in Bot) — and the action kinds beyond notifications. Event sources are covered end to end by
633`test/event-sources.test.js` (matching, visibility, once per event, the budget, the signed route and the subscription
634sync against a stand-in Events), and webhook sources by `test/webhook-sources.test.js` (the secret shown once, both
635signing forms, every refusal, the budget, rotation, text and JSON bodies, and removal).
636
637## Deploy
638
639`sudo ovhost deploy watch` on the host: git checkout at `/opt/openvibe.watch`, unit
640[deploy/systemd/openvibe-watch.service](deploy/systemd/openvibe-watch.service) as
641`openvibe-watch.service` on `127.0.0.1:4730`, env `/etc/openvibe/watch.env` (0600) from
642[.env.example](.env.example), database `ov_watch` on the host's data role (ADR-035,
643`sudo /opt/openvibe.host/roles/data/add-service.sh watch`). nginx serves `openvibe.watch` from
644[deploy/nginx/openvibe.watch.conf](deploy/nginx/openvibe.watch.conf); the pages, `/auth/*` and the
645discovery files are public, `/api/v1/*` and `/internal/*` stay loopback-only. Rollback: ovhost puts
646the previous sha back by itself when `/api/ready` does not answer 2xx after the restart; afterwards
647`sudo ovhost rollback watch --to <sha>`. Nothing is deployed yet: there is no host install, and
648openvibe.watch has no DNS or vhost.
649
650## Public host
651
652`openvibe.watch` serves the site at the edge: the pages, `/auth/*`, `/css/*` and `/shared/*`, the
653discovery files, and `/api/health`, `/api/ready`, `/release.json`. It keeps `/api/v1/*`
654loopback-only, exactly as before, so the service API is reached only from the host. Sign-in and form
655POSTs have their own per-address limit zones (nginx refuses `limit_req` inside `limit_except`, so a
656`map` on `$request_method` gives GET/HEAD an empty key). Install: copy the unit and the vhost, write
657`/etc/openvibe/watch.env` (0600) from `.env.example`, run
658`sudo /opt/openvibe.host/roles/data/add-service.sh watch`, then
659`sudo ovhost deploy watch`.
660
661<!-- versions:start -->
662- openvibe-contracts: v0.419.0
663- openvibe-sdk: v0.161.0
664- openvibe-shared: v4.241.0
665<!-- versions:end -->
666