Reporting protocols: reports, rendering, scheduled actions, alerts, notifications

Status: draft for review · revised 2026-10-06 (second review: simplified)

Seven optional, separately versioned vgi-rpc protocols that any VGI worker or standalone service may host:

Protocol Job Methods Spec
vgi.reports.v1 Report store: revisions, publishing, redaction 9 reports.md
vgi.report_render.v1 Render a report body into PDF, HTML or PNG 4 render.md
vgi.schedules.v1 Run an action (render a report, run a query, a vendor kind) on a trigger, as its owner, with an optional condition and deliveries 12 schedules.md
vgi.alerts.v1 Rules whose query returns the rows meeting a condition; one stateful instance per key; acknowledge, snooze, subscribe 16 alerts.md
vgi.notify.v1 Deliver a message to email, Slack, Teams, Discord or a webhook 4 notify.md
vgi.sql_tasks.v1 Run SQL that changes data on a trigger: load a query into a table (replace, append, merge, snapshot), incrementally by watermark, or a script of statements and CALLs in one transaction 13 sql_tasks.md
vgi.delegations.v1 Hold an owner's delegations (grant + attach ticket per catalog) for unattended work 3 credentials.md

Cupola's localStorage reports become the first client. The groundwork shipped in every vgi-rpc port and VGI SDK (prerequisites.md): multi-protocol hosting, the gRPC error model, Identity.v1 everywhere, and grants accepted as bearer credentials.

Design rules

  1. The protocol is the unit of optionality. A host serves a protocol whole or not at all; every method is required. What varies between hosts is data in get_*_info (e.g. writable), never the method set.
  2. Composable across hosts. Nothing assumes co-location. A report can read catalogs on several workers, live on a third, be scheduled on a fourth and rendered on a fifth. What makes that work is two shared types in credentials.md: a portable session description (DataSource list) and delegations: a grant plus a worker-sealed attach ticket per catalog, from vgi_export_session().
  3. Wire contract only. Each spec says what crosses the wire and what a client can rely on. How the reference implementation builds its engine (leases, ticks, warm sessions, retries, retention) is in clearly marked, non-normative notes.
  4. Delegated grants are the only unattended data credential. No service account ever reads data. Service principals exist only to call infrastructure (a scheduler calling notify's send).

Decisions

Topic Decision
Where reports live Any host may implement vgi.reports.v1; a dedicated report service is the expected enterprise shape. A report names any number of catalogs in data_sources.
Report body Standard envelope (title, path, tags, data sources, parameters); the body is opaque bytes tagged with a format such as cupola.evidence/1.
History Append-only numbered revisions, one published pointer, tombstone redaction. Drafts and restore are client-side.
Access control Each implementation's. The protocols carry advisory allowed_actions and standard refusals, not ACL management.
Data access Any host may run a session: the scheduler, the renderer, an alert evaluator, any worker. A session is rebuilt anywhere from its DataSource list plus the owner's grants, with one ATTACH … bearer_token per source.
Unattended sessions The client serializes the user's attached session with vgi_export_session(): per catalog, a grant (who) and an attach ticket (what, sealed by the worker, secrets included). Held as delegations, per owner per catalog (vgi.delegations.v1) by each host that runs unattended work; any runner reattaches with ATTACH … (bearer_token, attach_ticket). Schedules and rules carry no credentials. Lifetime is each data worker's policy; the framework default becomes unlimited unless a worker sets a maximum.
Scheduling Generic actions: render_report, run_query, and <vendor>.<name> kinds. Optional condition_sql returns one boolean. Triggers are cron or once on the wire; presets are UI.
Report versions in schedules The owner pins a revision, or follows the published revision or head and accepts that later edits run with their grants.
SQL tasks Their own protocol on the shared engine, for SQL that changes data; schedules produce and deliver outputs. Bodies are a declarative load or a script; a CALL is a statement. Targets are any writable catalog (under the owner's delegation) or the host's own store. Watermarks are derived from the target (exactly once) or stored (at least once). One written database per transaction.
Alerts Their own protocol. Rows are instances; notify on transitions; subscriptions with link-only details unless the owner shares them; acknowledge per instance, snooze per instance or rule, always with an end.
Delivery vgi.notify.v1, channel-agnostic. Each service owns its destination policy and unsubscribe list.
Serverless The reference scheduler is driven by an operator tick that a platform cron can call over HTTP; this is reference implementation, not protocol (schedules.md).
SQL access Normative. Each protocol is also a schema in the host's VGI catalog: every method is a table function, annotated lists are tables, annotated create/update/delete back INSERT/UPDATE/DELETE, other verbs are procedures. Derived from the RPC definition by a generic adapter, never hand-written, so the two surfaces can't drift.
Naming Report-only protocols are scoped (vgi.report_render.v1, later vgi.report_sharing.v1); the rest are general.
Discovery Reflection for what an endpoint hosts; one catalog tag per protocol for where services live.

Context

Cupola reports today (vgi-web-frontend/src/lib/evidence/): one zod-validated JSON document per report (Evidence Markdown source, setupSql, typed parameters, appearance and a single serviceUrl) in localStorage, keyed per worker and per browser. History is a list of revisions with no author; sharing is exporting a file; PDF export is built from the live DOM. Other catalogs a report queries are referenced by alias in SQL and never declared.

What vgi-rpc and the SDKs give us (shipped 2026-10-06):

Goals

  1. A report has one durable identity that any authorized person can open from any browser or client.
  2. Every saved change is an immutable, numbered revision with an author; publishing gives readers a stable version while editors keep working.
  3. A report can query any number of VGI workers and declares which.
  4. Reports, queries and other actions run on a schedule without a browser, as their owner, optionally only when a condition holds, and deliver to email, chat or webhooks.
  5. People get alerted when data meets a condition, once per affected entity, with the values that triggered it.
  6. Each protocol is optional and easy to implement; a worker can ship read-only reports from files in one line.

Non-goals for v1

Architecture

flowchart TB
    cupola["Cupola"]
    reports["vgi.reports.v1
report service"] sched["vgi.schedules.v1 + vgi.delegations.v1
scheduler"] alerts["vgi.alerts.v1 + vgi.delegations.v1
alert evaluator"] render["vgi.report_render.v1
renderer"] notify["vgi.notify.v1"] w1["sales worker
vgi.v2 + Identity.v1"] w2["hr worker
vgi.v2 + Identity.v1"] cupola -- "save, publish" --> reports cupola -- "issue_grant, as the user" --> w1 & w2 & reports cupola -- "vgi_export_session, put_delegations,
schedules, rules" --> sched & alerts sched -- "read revision (owner's grant)" --> reports sched -- "body + sources + grants" --> render render & sched & alerts -- "ATTACH … bearer_token (owner's grant)" --> w1 & w2 sched & alerts -- send --> notify

The boxes are roles, not deployments: one process may host several of them (the reference vgi-report-serve hosts reports, schedules, SQL tasks, alerts and delegations), or each may be its own service. Every arrow carrying data access uses the owner's grant for the location it goes to, and nothing else.

Shared conventions

Naming and versioning. vgi.<name>.v1, protocol_version = "1.0.0". Minor versions only add methods or defaulted fields; breaking changes ship as .v2, hosted side by side. No unions on the wire: every choice is an explicit kind field plus plain values.

Errors

Every refusal is a vgi-rpc error with a canonical code, a reason (error_kind) and catalog details. Kinds are shared across the protocols.

error_kind error_code Details Client behaviour
not_found NOT_FOUND ResourceInfo; optional access hint Show "Ask {contact} for access" when a hint is present
action_denied PERMISSION_DENIED ErrorInfo.metadata {action} Explain; don't retry
read_only_service PERMISSION_DENIED ResourceInfo Hide editing; writable should already have said so
conflict ABORTED ResourceInfo, ErrorInfo.metadata with the current version or head (head_revision_id, updated_by, updated_at) Reload, then review, reapply or save as a copy
invalid_request INVALID_ARGUMENT BadRequest Show field-level detail
grant_required FAILED_PRECONDITION PreconditionFailure, one {type: "GRANT", subject: <location>} per location Connect or reauthenticate those sources
quota_exceeded RESOURCE_EXHAUSTED QuotaFailure; RetryInfo for rate limits Show the limit
service_unavailable UNAVAILABLE RetryInfo (required) Retry with backoff; never cache

The access hint on not_found is LocalizedMessage, Help.links (request access) and ErrorInfo.metadata.access_contact; no custom detail type.

Visibility. Return not_found for objects the caller can't read, so ids can't be probed; list methods filter by read permission.

Permission hints. Returned objects carry allowed_actions for the caller: read, edit, publish, redact, delete, schedule, run, manage, plus alert actions. Advisory; the server enforces on every call.

Principals. Authorship is PrincipalRef {id, display_name, email}, id being AuthContext.principal.

Concurrency and idempotency. Mutations of existing objects take expected_revision_id or expected_version (conflict on mismatch). Creates, sends and runs take a client-chosen request_id; a repeat within 24 hours returns the original result.

Lists are producer streams with a fixed Arrow schema, paged by continuation tokens. Times are timestamp[us, UTC] with IANA zone names. Parameter values are tagged ParamValue records whose literal payload is JSON text. body_sha256 is over the body bytes as sent.

Transports. Hosted on every transport through hosted_protocols(). On HTTP callers have identities; on stdio and unix the caller is the operator, which suits tests and single-user tools. Grants are HTTP only.

Unattended sessions across workers

The full model is in credentials.md. In short:

Known limits:

Discovery

  1. Reflection. list_protocols on an endpoint says which protocols it hosts. Clients never probe by calling and catching.
  2. Catalog tags. A worker that relies on services elsewhere returns them in CatalogAttachResult.tags, one per protocol, following vgi_secret_service_url: vgi_reports_url, vgi_report_render_url, vgi_schedules_url, vgi_alerts_url, vgi_notify_url. Values must be https (localhost excepted) and same-site or on an operator allowlist; the client confirms each with reflection.

When catalogs in one session point at different report services, each report lives in exactly one, and the library groups by service.

SQL binding

Every protocol here except the scheduler driver is also usable from SQL. A host that serves a protocol over vgi-rpc and also serves vgi.v2 exposes the same protocol as a schema in its VGI catalog, so after

ATTACH 'ops' (TYPE vgi, LOCATION 'https://reports.example.com');

ops.sql_tasks.tasks lists tasks, INSERT INTO ops.schedules.schedules … creates a schedule, and CALL ops.sql_tasks.run_now(task_id := '…') runs one. The binding is normative: SQL written against one implementation runs against another.

It is derived, never hand-written. The SQL binding is a function of the RPC definition plus a small per-protocol annotation (which list is a table, which methods back its INSERT, UPDATE and DELETE). Implementations write the RPC service only; a generic adapter in each SDK serves the catalog from it, so the two surfaces cannot drift. Each spec's "SQL binding" section is that annotation, and nothing else.

Derivation rules

  1. Schema. vgi.<name>.v1 is the schema <name>; a .v2 hosted beside it is <name>_v2. A host that serves several protocols has one schema each.
  2. Every method is a table function, named as the method, with the method's parameters as named arguments and its result as rows: a unary method returns one row (or one row per list element it returns), a stream returns its batches. CALL invokes them. This rule is total: no method is left out, so nothing reachable over RPC is unreachable from SQL.
  3. get_*_info() is also the one-row table info.
  4. A list method may be annotated as a table, named for its resource. Columns are the list's row schema exactly. Filters on columns that match the method's arguments are pushed down to them; every other filter is applied by DuckDB after the scan. A list with a required argument is never a table; it stays a table function.
  5. Fetched columns. A table may name a get method. Columns only that method returns (a report's body) appear in the table but are fetched per row only when a query selects them.
  6. DML is annotated, per table. INSERT calls the create method (one call per row, or one call per statement when the method takes a list); UPDATE calls the update method with the row as changed, passing the row's old version (or revision id) as the expected value; DELETE calls the delete method the same way. Arguments the method takes beyond the row (request_id, a commit message) are extra columns: writable, NULL on read. DELETE can't set columns, so it uses those arguments' defaults; a delete that needs one (drop_target) is a CALL. A table without an annotation is read-only. A verb that isn't create, update or delete (publish, run_now, acknowledge) is only ever a procedure, so SELECT never has side effects.
  7. Types are the RPC's. The RPC already speaks Arrow: records are STRUCTs, lists are LISTs, kind fields are VARCHAR. Field names are column names verbatim, so an RPC field name must be one DuckDB accepts unquoted (grant is fine; check new names against DuckDB's keyword list).
  8. Write-only fields (grants, tickets) are accepted by INSERT and read back as NULL.
  9. Identity and access are the ATTACH's: the same caller, the same AccessPolicy, the same not_found for objects the caller can't read.

Semantics

Drift guards

Developer experience

from pathlib import Path

from vgi.reports import AccessPolicy, ReadOnlyReportStore, ReportsProtocol
from vgi.reports.storage import SharedStorageReportStore
from vgi.worker import Worker


class SalesWorker(Worker):
    functions = [...]

    @classmethod
    def hosted_protocols(cls):
        # Ship .cupola-reports.json exports as read-only reports.
        return [(ReportsProtocol, ReadOnlyReportStore.from_files(Path(__file__).parent / "reports"))]


class TeamWorker(Worker):
    functions = [...]

    @classmethod
    def hosted_protocols(cls):
        return [(ReportsProtocol, SharedStorageReportStore.from_env(policy=OwnerPolicy()))]


class OwnerPolicy(AccessPolicy):
    def allowed_actions(self, auth, report) -> set[str]:
        if report.created_by.id == auth.principal:
            return {"read", "edit", "publish", "redact", "delete", "schedule"}
        return {"read", "schedule"} if report.published_revision_id else set()

AccessPolicy is the one function an implementor writes to own access, and it decides from auth.principal, so it works for unattended (grant) callers too. Reference stores sit on FunctionStorage, so sqlite, Azure SQL and Cloudflare Durable Objects come free. Conformance is per protocol: every method, the error mapping, concurrency, idempotency and authorship; pytest parameterized by URL so the TypeScript implementation runs it too.

Implementation plan

The build plan, its status and the decisions made along the way are in IMPLEMENTATION.md. It is the single source for what to build next.

Review history

First review (principal engineer, BI UX lead, DX lead): grants accepted as bearers (shipped), credentials bound to locations, run uniqueness, no unions, the gRPC error model, test_run, plain-English trigger previews, structured run errors, labels on data sources, writable services.

Second review (senior engineer, simplification and composability). Taken:

Finding Change
Three shapes for one credential, keyed by alias One Delegation per owner per catalog; DataSource is the shareable description
Grants per schedule: renewal toil, and purpose named a schedule id before it existed Per owner per catalog in vgi.delegations.v1, minted by vgi_export_session()
Minting at author-controlled locations leaks the user's session Mint only at attached or confirmed locations
"Same grant" for the secret service can't verify there Replaced by worker-sealed attach tickets, which carry secret options without exposing them
Report sources copied at schedule creation go stale Taken from the revision rendered, every run
Claim-based policies on the report store fail for grant callers Documented; reference AccessPolicy decides by principal
Notify assumed co-location (unsubscribe, attachments) Notify owns the suppression list; attachments use signed URLs
Server drafts, publications log, undelete, copied_from, labels Removed; reports 15 → 9 methods
Trigger presets and misfire policy on the wire Cron or once; presets are UI
Engine rules (driver, leases, retries, retention, session hardening, grouping) as protocol Moved to non-normative reference notes
Merges publish unpublishes; set_enabled, reauthorize, retry_run folded in; one conflict kind; set_acknowledged; no get_notification or partial_ok

Not taken, by decision: custom action kinds stay; alert subscriptions stay; schedules may follow a report's latest revision as an accepted risk; the unlimited default grant lifetime stands.

Resolved questions

Question Answer
Features in protocol_hash? Moot: features is always empty
Publish channels? One pointer
History removal? Tombstone redaction
Grant lifetime? The worker's; framework default unlimited unless set
DuckDB tables? Yes, on the reference service, read-only
Artifact retention? Operator policy (reference: 90 days)
Tags? One flat tag per protocol
Notify host? New vgi-notify repo
Scheduling others' reports? Anyone with schedule; runs as its owner; pin or follow
Where data access runs? Anywhere; sessions are portable
Grant storage? Per owner per location

Open questions

None outstanding.

Unattended sessions

Reports, schedules and alerts read data from catalogs on other workers, and the work may run on another host while nobody is logged in. This file defines how a user's attached DuckDB session is serialized so that any runner, anywhere, can reattach it later as that user, without ever seeing the user's secrets:

Shared conventions are in README.md. The new worker method and extension pieces are planned in attach-tickets-plan.md.

Serializing a session

While the user is attached and logged in, the client runs:

SELECT * FROM vgi_export_session();
-- alias | location                  | catalog_name | grant      | ticket     | expires_at | status
-- sales | https://sales.example.com | main         | vgig1.…    | vgia1.…    | …          | ok
-- hr    | https://hr.example.com    | people       | vgig1.…    | vgia1.…    | …          | ok
-- tmp   | :memory:                  |              |            |            |            | not_vgi

One row per attached catalog. For each VGI attachment the extension asks that worker for two things, using the session's own credentials:

Rows the worker can't or won't serialize say why in status (not_vgi, not_supported, stale_login, refused) instead of failing the whole export.

A runner on any host reattaches with both, and never sees an option or a secret:

ATTACH 'sales' (TYPE vgi, LOCATION 'https://sales.example.com',
                bearer_token 'vgig1.…', attach_ticket 'vgia1.…');

The grant authenticates the runner as the user. The worker opens the ticket, checks its principal matches the grant's, and attaches with the sealed options exactly as the user did.

Two parts, deliberately

The grant and the ticket stay separate:

To the runner they are one row, held and presented together.

The session description in a report

A report envelope carries only what's safe to share and needed to show a "connect these sources" checklist:

@dataclass
class DataSource:
    alias: str               # catalog name in the session, e.g. "sales"
    location: str            # worker URL
    catalog_name: str = ""   # remote catalog, if not the worker's default
    label: str = ""          # "Sales warehouse (prod)", for people
    required: bool = True

Cupola fills it from the non-secret columns of vgi_export_session() when the author saves, with a checklist so the author can untick catalogs the report doesn't use. No grants or tickets ever go into a report: they belong to the person whose credentials they carry.

Delegations

@dataclass
class Delegation:
    location: str            # normalized worker URL
    catalog_name: str
    grant: str               # write-only
    ticket: str              # write-only
    expires_at: float        # the earlier of the two; +inf when neither expires

Normalized location: scheme, lowercased host, explicit port, and path without a trailing slash, so two workers behind one host on different paths stay distinct.

vgi.delegations.v1

Hosted by any service that runs work unattended (in practice beside vgi.schedules.v1 and vgi.alerts.v1). The caller is always the owner.

Method Kind Purpose
put_delegations(delegations) U Store or replace the caller's delegations, keyed by (location, catalog_name)
list_delegations() S The caller's delegations with expires_at and updated_at. Never grants or tickets
revoke_delegation(location, catalog_name) U Destroy one delegation

To run a report, query or rule, the runner takes each DataSource, looks up the owner's delegation for its (location, catalog_name), and attaches it under the source's alias. A missing delegation is grant_required, naming the source. Schedules and rules carry no credentials of their own.

SQL binding

Schema delegations. Derived by the rules in README.md; this is the annotation.

Table List / get INSERT UPDATE DELETE
delegations list_delegations put_delegations, one call per statement (replaces by key) revoke_delegation

grant and ticket are write-only. The columns match vgi_export_session(), so delegating a whole session is one statement and no credential appears in the SQL text:

INSERT INTO ops.delegations.delegations BY NAME
SELECT * EXCLUDE (alias, status, message) FROM vgi_export_session()
WHERE status = 'ok';

Who does what

Step Who Rule
Issue Each attached worker A grant needs a login no older than 900 s. A ticket needs only an authenticated caller. Either can be refused
Export The client, as the user vgi_export_session(), only for catalogs the user attached themselves. A report author can't make a reader export at a location the reader never attached
Store Each host that runs unattended work Encrypted under a key outside its database; never returned; each host keeps its own copy
Reattach Whatever host runs the session Presents a delegation only to its own location, only for that job

Nothing assumes co-location: report store, scheduler, renderer, alert evaluator and data workers can all be different hosts. When a job moves between them (a scheduler asking a renderer), the delegations for that job travel with it and are dropped when it ends.

Scheduling someone else's report. The schedule's owner reattaches with their own delegations, exported from their own session, never the author's. If the owner hasn't attached a source the report needs, they're asked to before saving the schedule.

Renewal. Whenever the owner logs in, Cupola re-exports the sessions behind their expiring delegations and calls put_delegations. Delegations from workers with no maximum lifetime never need it. A rotated secret needs a fresh export from a session attached with the new value.

Revocation. revoke_delegation destroys the only copy on that host. Rotating a worker's grant key invalidates its grants; rotating its VGI_SIGNING_KEY invalidates its tickets.

What still can't be done unattended

vgi.reports.v1

A report is a standard envelope plus an opaque body, with a linear chain of immutable numbered revisions, one published pointer, and tombstone redaction. Every method is required; a host that can't accept writes says writable = false and refuses them. Shared conventions are in README.md.

Model

Envelope

@dataclass
class ReportEnvelope:
    title: str
    description: str = ""
    path: str = ""                       # library folder, "/"-separated
    tags: list[str] = []
    body_format: str                     # "cupola.evidence/1"
    data_sources: list[DataSource] = []  # credentials.md
    parameters: list[ParameterSpec] = []

@dataclass
class ParameterSpec:
    key: str                             # $key in SQL
    label: str
    type: str                            # text | number | date | boolean | select | multi_select | date_range
    required: bool = False
    default_json: str = "null"

data_sources says which catalogs the report needs, so a renderer, a scheduler or another browser can rebuild its session. parameters is the report's public interface, so a schedule editor can show inputs without parsing the body.

Records add report_id, head_revision_id, published_revision_id, created_at/by, updated_at/by, body_sha256 and allowed_actions. Revision rows add published_at/by and, once redacted, redacted_at/by and redaction_reason.

Methods

U = unary, S = producer stream. All are required.

Method Kind Purpose
get_report_service_info() U display_name, writable, body formats, size limits
list_reports(query, path_prefix, tags, owned_by_me, published_only) S Library rows without bodies, filtered by read permission
get_report(report_id, revision_id="") U Envelope and body. With no revision id, editors get the head and everyone else the published revision; revision_served says which
list_revisions(report_id) S History, newest first, tombstones included
create_report(request_id, envelope, body, message) U New report with revision 1
commit_revision(report_id, expected_revision_id, envelope, body, kind, message) U New head; conflict if the head moved
publish(report_id, revision_id, expected_published_revision_id) U Move the published pointer; empty revision_id unpublishes
delete_report(report_id, expected_revision_id) U
redact_revision(report_id, revision_id, reason) U Requires redact

Errors: not_found (with the optional access hint), action_denied, read_only_service, conflict, invalid_request, quota_exceeded, service_unavailable (README.md).

Unattended reads. A scheduler or renderer reads reports with the owner's grant on the report service. Grants carry no identity-provider claims, so an AccessPolicy that authorizes by claims refuses them; it must decide from the principal (credentials.md).

SQL binding

Schema reports. Derived by the rules in README.md; this is the annotation.

Table List / get INSERT UPDATE DELETE
reports list_reports / get_report (fetches body) create_report commit_revision, expected = the row's head_revision_id delete_report, same

list_revisions has a required report_id, so it stays a table function. publish and redact_revision are procedures. Extra columns: request_id and message on INSERT; message and kind on UPDATE.

SELECT title, path, published_revision_id FROM ops.reports.reports
WHERE path LIKE '/finance/%';

SELECT revision_number, author, kind, message
FROM ops.reports.list_revisions(report_id := 'r_1');

CALL ops.reports.publish(report_id := 'r_1', revision_id := 'rev_7',
                         expected_published_revision_id := 'rev_5');

Read-only publisher

ReadOnlyReportStore.from_files(path) serves .cupola-reports.json exports from a package directory and implements the whole protocol: reads serve the files, writes are refused with read_only_service, writable = false. The revision id is the content hash and the published revision is the head.

Import

An import of a .cupola-reports.json file is create_report followed by commit_revision for each historical revision, kind import, with the original kinds kept in the message. Cupola's "remove from history" maps to redact_revision.

vgi.report_render.v1

Rendering a report is a job: start it, poll it, collect artifacts. The first implementation is headless Cupola, because Cupola reports render only in a browser; other renderers can register for other body formats. The scheduler uses it for render_report actions and Cupola for large exports. Shared conventions are in README.md.

Why jobs, not one call. A render runs every query, waits for charts to settle and builds a PDF. That takes longer than HTTP intermediaries tolerate, and the caller needs to cancel and retry it.

@dataclass
class RenderRequest:
    body_format: str
    body: bytes                           # the revision to render, passed inline
    data_sources: list[DataSource]        # the session to rebuild (credentials.md)
    delegations: list[Delegation] = []  # owner's grant + ticket per source, this job only
    parameter_values: list[ParamValue] = []  # literals; the caller resolves relative tokens
    outputs: list[str] = ["application/pdf"]  # also text/html, image/png (page-1 preview)
    locale: str = "en-US"
    time_zone: str = "UTC"
    timeout_seconds: int = 600

The caller passes the body inline. A scheduler has already read the revision (it needs the envelope's data_sources to check grants), so the renderer never needs credentials for the report store and is a function of body, sources, parameters and grants.

Method Kind Purpose
get_renderer_info() U Body formats, output media types, timeout ceiling, allowed source locations
start_render(request_id, request) U Returns job_id, status queued
get_render(job_id) U Status (queued, running, succeeded, failed, cancelled), poll_after_seconds, artifacts, and on failure the cause's code, kind and data source
cancel_render(job_id) U Best effort

Artifacts are {name, media_type, size_bytes, sha256, url, expires_at}, with a short-lived download URL. Callers that keep artifacts copy them.

Credentials. The renderer follows the presenting rule in credentials.md: a grant goes only to the location it was minted at, and is dropped when the job ends. It also enforces an operator allowlist of source locations, published in get_renderer_info, so a report can't point a session at an arbitrary host.

SQL binding

Schema report_render. Derived by the rules in README.md; this is the annotation.

No tables beyond info: render jobs can't be listed. start_render, get_render and cancel_render are table functions, so a script can start a render and poll it; Cupola and schedulers use RPC.

Reference renderer (non-normative)

vgi.schedules.v1

A schedule runs an action on a trigger, as its owner, and delivers what the action produced. Rendering a report is one action; running a query is another; implementations may add their own. Each firing is a run that records what ran, with which values, whether its condition held, and where the outputs went. Shared conventions are in README.md; sessions and grants are in credentials.md.

A host of vgi.schedules.v1 also hosts vgi.delegations.v1: a schedule carries no credentials, and runs reattach each source with the owner's delegation for it.

Model

@dataclass
class Schedule:
    title: str
    trigger: Trigger
    action: Action
    condition_sql: str = ""              # empty = always; see Conditions
    parameter_values: list[ParamValue] = []
    deliveries: list[Delivery] = []
    enabled: bool = True

@dataclass
class Trigger:
    kind: str                            # cron | once
    cron: str = ""                       # 5-field cron (croner dialect, vgi-crontimes)
    run_at: str = ""                     # kind=once: ISO 8601
    time_zone: str = "UTC"               # IANA
    start_at: str = ""
    end_at: str = ""

@dataclass
class Action:
    kind: str                            # render_report | run_query | <vendor>.<name>
    render_report: RenderReportAction = ...   # kind = render_report
    run_query: RunQueryAction = ...           # kind = run_query
    data_sources: list[DataSource] = []       # run_query and vendor kinds; credentials.md
    custom_json: str = ""                     # kind = <vendor>.<name>, validated by that implementation

@dataclass
class RenderReportAction:
    report: ReportRef                    # {service_url, report_id, revision_id}
    track: str = "published"             # pinned | published | head
    outputs: list[str] = ["application/pdf"]

@dataclass
class RunQueryAction:
    setup_sql: str = ""
    sql: str                             # its result is the action's output
    output_format: str = "parquet"       # none | arrow | parquet | csv

@dataclass
class ParamValue:
    key: str
    kind: str                            # literal | relative
    json: str = ""                       # kind=literal
    relative: str = ""                   # kind=relative, e.g. previous_month, last_n_days:7

@dataclass
class Delivery:
    destinations: list[Destination]      # vgi.notify.v1 {kind, address}
    inline: str = "summary"              # none | summary | image (report) | table (query)
    attach: list[str] = []               # output media types to attach; empty = link only

Schedule editors offer presets ("weekdays at 8:00"); they compile to cron, and preview_trigger describes the cron back in plain English. A missed firing after downtime runs once.

Records add schedule_id, owner, version, next_fire_at, last_run, disabled_reason, credentials (per required location: ok, expiring with a time, expired, missing) and allowed_actions.

Actions

Kind Session Outputs
render_report The report's data_sources, rendered by vgi.report_render.v1 The rendered media types
run_query The action's data_sources The result in output_format, plus row_count
<vendor>.<name> The action's data_sources Defined by the implementation

Tracking report versions. pinned runs exactly one revision. published and head follow the report as it changes, which means later edits by the report's editors run with the schedule owner's grants and go to the owner's destinations. That is an accepted risk the owner chooses; schedule editors warn when someone follows a report they can't edit, and runs record the revision they rendered.

Conditions

condition_sql decides whether a run proceeds. It is one query that must return exactly one row with one BOOLEAN column, run in the action's session with the same parameters.

Result Run outcome
true Proceed and deliver
false / NULL skipped (condition_false / condition_null)
Any other shape failed, condition_invalid

For run_query it runs after the query and can read the result as the view action_result ("send only if there are late orders" is SELECT count(*) > 0 FROM action_result). For render_report and custom kinds it runs before the action, so a quiet day costs one small query instead of a render.

Methods

U = unary, S = producer stream. All are required.

Method Kind Purpose
get_scheduler_info() U Action kinds, output formats, minimum interval, relative tokens, limits
preview_trigger(trigger, count) U Next N fire times, a plain-English description, DST warnings
list_schedules(action_kind, report_id, owned_by_me) / get_schedule(schedule_id) S / U Read
create_schedule(request_id, schedule) U Owner is the caller; grant_required if the owner lacks a grant for a required location
update_schedule(schedule_id, expected_version, schedule) U Includes pausing and resuming via enabled
delete_schedule(schedule_id, expected_version) U
test_run(schedule) U Run an unsaved schedule once and return its outputs, row count, condition value and the messages it would send. Delivers nothing
run_now(schedule_id, request_id, ignore_condition) U Manual run, also the way to retry
list_runs(schedule_id, status, since) / get_run(run_id) S / U Run history
cancel_run(run_id) U

Errors: the shared kinds in README.md, notably grant_required (FAILED_PRECONDITION, one PreconditionFailure violation per location without a usable grant) and conflict.

Runs

A run records run_id, trigger_kind (schedule, manual), scheduled_for, started_at, finished_at, status (pending, running, succeeded, skipped, failed, cancelled), skip_reason, the revision_id rendered, the resolved parameter values, outputs (media_type, row_count), one result per destination, and on failure error:

@dataclass
class RunError:
    code: str                  # canonical vgi_rpc code
    kind: str                  # grant_expired | grant_required | source_unavailable | query_failed
                               # | condition_invalid | result_too_large | policy | render_timeout | internal
    location: str = ""         # the data source involved, if any
    message: str = ""          # developer-facing

The protocol guarantees two things about runs, and leaves the rest to the implementation:

Relative values

Tokens resolve in the trigger's time zone: today, yesterday, last_n_days:N, previous_week (ISO weeks, Monday start), previous_month, previous_quarter, month_to_date, year_to_date. Pinned with test vectors. A date_range parameter resolves to {start, end}. Resolved values are stored on the run and shown in deliveries ("Period: Sep 1–30, 2026").

SQL binding

Schema schedules. Derived by the rules in README.md; this is the annotation.

Table List / get INSERT UPDATE DELETE
schedules list_schedules / get_schedule create_schedule update_schedule, expected = the row's version delete_schedule, same
runs list_runs / get_run

list_runs with no schedule_id lists runs across every schedule the caller can read. run_now, cancel_run, test_run and preview_trigger are procedures.

UPDATE ops.schedules.schedules SET enabled = false WHERE title LIKE 'Weekly%';

SELECT schedule_id, scheduled_for, error.kind, error.message
FROM ops.schedules.runs WHERE status = 'failed' AND started_at > now() - INTERVAL 1 DAY;

Reference scheduler (non-normative)

How the reference scheduler is built. None of this is required of another implementation.

Driven by tick, so it runs long-running or serverless. The engine keeps no clock. Two calls on durable state do all the work: tick(budget_seconds) claims due schedules, due alert rules and runs with a step due; advance_run(run_id, budget_seconds) moves one run through its persisted, idempotent steps (condition, action, poll the render, store outputs, deliver to each destination) until it finishes, waits, or runs out of budget. A long-running vgi-report-serve calls tick on a loop. Serverless deployments are ticked by a platform cron's HTTP request (a Cloudflare Cron Trigger, EventBridge, any cron), and tick fans out one advance_run request per run to its own URL. Both are methods of vgi.scheduler_driver.v1, an operator protocol callable only by allowlisted principals (VGI_SCHEDULER_DRIVER_PRINCIPALS); tick takes no time argument, so a caller can wake the scheduler but not move its clock. Duplicate ticks are no-ops. Platform cron's one-minute floor is the scheduler's minimum interval. Queries need native DuckDB with the VGI extension, so on Cloudflare the Worker is only the clock.

Sessions are fresh per run, built from data_sources plus the owner's delegations, with enable_external_access = false and lock_configuration = true after the attaches, and memory, row and time limits. They are dropped with their grants when the run ends.

Reliability. Per-schedule leases with fencing tokens on FunctionStorage counters. Retryable failures (by is_retryable()) retry within the run, honouring RetryInfo; five consecutive failed runs disable the schedule and notify the owner. Sends record their intent before calling send, with request_id = hash(run_id, delivery index), so a resumed run never delivers twice. An expired or rejected grant fails the run with grant_expired.

Outputs are copied into the scheduler's store and kept for the operator's retention (90 days by default). Delivery links point at an authenticated run page; attachments use short-lived signed URLs the notify service can fetch.

State lives in FunctionStorage: sqlite for long-running and development, Azure SQL or Cloudflare Durable Objects for serverless. The grant encryption key comes from the platform's secret store.

Deferred

vgi.sql_tasks.v1

A SQL task runs SQL that changes data on a trigger, as its owner, in a session rebuilt from its data sources and the owner's delegations. A task can load a query's result into a table (replace it, append to it, merge into it, or keep dated snapshots), fetch only what's new since the last run, or run a script of statements in one transaction, CALLs included. Each firing is a run that records what it changed and where the watermark moved. Shared conventions are in README.md; sessions and delegations are in credentials.md. A host of vgi.sql_tasks.v1 also hosts vgi.delegations.v1.

Why not a schedule. A schedule produces outputs and delivers them; it has no destination table and no memory between runs. A task writes, keeps a watermark across runs, and is managed as the dataset it maintains: how fresh it is, what the last run loaded, reset and backfill. Either protocol can be hosted without the other; the reference service runs both on one engine.

Model

@dataclass
class SqlTask:
    title: str
    description: str = ""
    trigger: Trigger                     # as vgi.schedules.v1: cron | once
    data_sources: list[DataSource]       # the session; credentials.md
    body: TaskBody
    incremental: Incremental = ...       # default kind = none
    condition_sql: str = ""              # empty = always; see Conditions
    parameter_values: list[ParamValue] = []
    notify: TaskNotify = ...
    stale_after_seconds: int = 0         # health is `stale` with no success this long; 0 = never
    timeout_seconds: int = 3600
    enabled: bool = True

@dataclass
class TaskBody:
    kind: str                            # load | script | <vendor>.<name>
    load: LoadBody = ...                 # kind = load
    script: ScriptBody = ...             # kind = script
    custom_json: str = ""                # kind = <vendor>.<name>

@dataclass
class LoadBody:
    setup_sql: str = ""
    query: str                           # the rows to load; may use $watermark
    target: Target
    mode: str                            # replace | append | merge | snapshot
    key_columns: list[str] = []          # mode = merge: match on these
    delete_missing: bool = False         # mode = merge: delete target rows the query no longer returns
    snapshot_column: str = "_snapshot_at"  # mode = snapshot
    retain_snapshots: int = 0            # mode = snapshot: keep the newest N; 0 = all
    on_schema_change: str = "fail"       # fail | add_columns

@dataclass
class ScriptBody:
    statements: list[str]                # run in order; may use $watermark and parameters
    transaction: str = "single"          # single | per_statement | none
    watermark_sql: str = ""              # incremental.kind = stored: returns the new watermark

@dataclass
class Target:
    kind: str                            # catalog | host
    alias: str = ""                      # kind = catalog: one of data_sources, written under the owner's delegation
    schema: str = "main"
    table: str
    create_if_missing: bool = True       # created from the query's result schema

@dataclass
class Incremental:
    kind: str = "none"                   # none | derived | stored
    cursor_column: str = ""              # load: the column that only grows (updated_at, id)
    initial_json: str = ""               # the watermark for the first run; empty = NULL
    lookback: str = ""                   # e.g. "PT2H": re-read late-arriving rows; needs mode = merge

@dataclass
class TaskNotify:
    destinations: list[Destination] = [] # vgi.notify.v1
    on: list[str] = ["failing", "recovered", "disabled"]
                                         # failing | recovered | stale | disabled | every_failure | success

Records add task_id, owner, version, next_fire_at, last_run, watermark_json, target_row_count where the target reports it, disabled_reason, credentials (as schedules), allowed_actions, and the task's health (below).

Bodies

load

Runs setup_sql, then writes query's result to the target in one transaction:

Mode Effect
replace The target holds exactly the result. Readers see the old rows or the new, never neither
append Inserts the result
merge Upserts on key_columns; with delete_missing, deletes target rows whose key the result no longer returns
snapshot Appends the result with snapshot_column set to the run's scheduled_for, then deletes snapshots beyond retain_snapshots

A missing target is created from the result's schema when create_if_missing. A result whose schema no longer matches fails the run (schema_mismatch) unless on_schema_change = add_columns, which adds new columns as nullable and fills missing ones with NULL; a changed type always fails.

script

Runs statements in order, with parameters bound as $key:

transaction Meaning
single One transaction around all statements: all commit or none do
per_statement Each statement commits on its own; a failure stops the script and earlier statements stay committed
none No transaction is opened; for statements that can't run inside one

A CALL is a statement like any other. CALL sales.main.refresh_extract(...) invokes a table function on the sales worker under the owner's delegation, which is how a task asks a worker to do work that isn't SQL. Work outside any worker is a vendor kind.

Vendor kinds

<vendor>.<name> carries its arguments in custom_json, validated at create time, as in schedules. It declares data_sources so delegations are checked the same way. A client that doesn't know a kind can still list, pause, run and delete the task and read its runs.

Transactions and atomicity

One written database per transaction. DuckDB reads from any number of attached databases in a transaction but writes to only one. A single script or a load may read every source; its writes must all go to one database. A task that writes two databases uses per_statement and accepts that a failure can leave the first written and the second not. Implementations check this at create time where they can and otherwise fail the run with transaction_failed.

A remote target's commit is that worker's. Writing a catalog target commits through the worker's VGI catalog transaction. Atomicity, isolation and what readers see during the write are that catalog's guarantees.

Incremental loads

The watermark is the high-water mark of what has been loaded. It's bound as $watermark in query, statements and condition_sql (NULL on the first run unless initial_json is set).

kind Where the watermark comes from Guarantee
none No watermark
derived SELECT max(cursor_column) FROM target, read inside the run's transaction just before writing Exactly once. The read and the write commit together, and at most one run is active, so a crashed run loads nothing and the next run starts where the target ends
stored Saved by the service after a successful run: max(cursor_column) of the loaded rows (load), or the single value watermark_sql returns, run last inside the transaction (script) At least once. The service saves the watermark after the target commits, so a crash between the two re-reads the last window. Make the write idempotent (merge) and that is harmless

derived needs a load with a target that has cursor_column. stored works for any body, including a script whose watermark isn't a column, a target the runner can't query, and a source-side cursor such as an API page token. On a host target an implementation may store the watermark in the same database as the data, which makes stored exactly once too; get_tasks_info says whether it does.

lookback subtracts an ISO 8601 duration from a timestamp watermark to re-read rows that arrived late, and requires mode = merge so the overlap doesn't duplicate. A watermark never moves backwards on its own: a run whose new watermark is lower than the old one fails with watermark_invalid. Moving it back is a backfill, done explicitly with set_watermark.

Destinations

Conditions

condition_sql runs before the body in the task's session and returns one BOOLEAN, with the results as in schedules (false/NULL skip the run). It can read $watermark, so "only if anything is newer than what we have" is SELECT max(updated_at) > $watermark FROM sales.main.orders.

Tracking health

Every task carries a health summary, kept current by the service as runs finish and as time passes:

@dataclass
class TaskHealth:
    state: str                    # healthy | failing | stale | paused | disabled | new
    since: str                    # when the task entered this state
    last_success_at: str = ""
    last_failure_at: str = ""
    last_error: RunError = ...    # the most recent failure's error
    consecutive_failures: int = 0
State Meaning
new Never run
healthy The last finished run succeeded or was skipped by its condition, and the task isn't stale
failing The last finished run failed; consecutive_failures counts the streak
stale No success within stale_after_seconds, even though nothing failed (runs skipped, cancelled, or the trigger stopped firing)
paused enabled = false, set by a person
disabled Disabled by the service, with disabled_reason (for example, repeated failures or an expired delegation)

Notifications follow state changes, not runs. failing notifies once when a streak starts, recovered once when it ends, stale and disabled once on entry. every_failure and success notify per run, for those who want it. Each message carries the task, the state, the run and its RunError.

History is queryable in SQL. The tasks and runs tables (see SQL binding) carry health and run history, so an alert rule can watch tasks too ("any task in the finance folder failing for over an hour").

Methods

U = unary, S = producer stream. All are required.

Method Kind Purpose
get_tasks_info() U Body kinds, modes, host store catalog and location, whether host watermarks are atomic, minimum interval, limits
preview_trigger(trigger, count) U As in schedules
list_tasks(owned_by_me, target, health_state) / get_task(task_id) S / U Read, with health; health_state filters ("everything failing")
create_task(request_id, task) U Owner is the caller; grant_required for any source without a usable delegation
update_task(task_id, expected_version, task) U Includes pausing via enabled. Changing the target, mode or cursor column clears a stored watermark
delete_task(task_id, expected_version, drop_target) U drop_target drops a host table; a catalog target is never dropped
test_run(task) U Runs an unsaved task inside a transaction that is always rolled back. Returns row counts per statement, the old and new watermark, the target schema it would create and the condition value. A CALL whose function acts outside DuckDB still has that effect
run_now(task_id, request_id, ignore_condition, full_refresh) U Manual run, also the way to retry. full_refresh runs with $watermark = NULL and, for load, replace semantics
set_watermark(task_id, expected_version, watermark_json) U Backfill or reset a stored watermark; empty clears it. Refused for derived, whose watermark is the target's data
list_runs(task_id, status, since) / get_run(run_id) S / U Run history. task_id empty lists runs across every task the caller can read, so "what failed overnight" is one call
cancel_run(run_id) U Rolls back an open transaction; per_statement keeps what already committed

Errors: the shared kinds in README.md.

Runs

As in schedules (unique on (task_id, scheduled_for), at most one active run, an overlapping firing is skipped with reason overlap), recording in addition: watermark_before, watermark_after, per statement its index, kind and rows_affected, and for load the rows inserted, updated and deleted and the target's row count afterwards, and attempt when the implementation retried within the run. RunError.kind adds transaction_failed, schema_mismatch, watermark_invalid and target_unwritable to the schedule kinds.

SQL binding

Schema sql_tasks. Derived by the rules in README.md; this is the annotation.

Table List / get INSERT UPDATE DELETE
tasks list_tasks / get_task create_task update_task, expected = the row's version delete_task, same (drop_target false; use CALL to drop)
runs list_runs / get_run

run_now, cancel_run, test_run, set_watermark and preview_trigger are procedures. A task's own script can call them, and any other protocol's procedures (CALL ops.notify.send(…)).

SELECT title, health.state, health.consecutive_failures, health.last_error.message
FROM ops.sql_tasks.tasks WHERE health.state IN ('failing', 'stale');

CALL ops.sql_tasks.run_now(task_id := 't_orders', request_id := 'manual-2026-10-08',
                           ignore_condition := false, full_refresh := true);

Reference implementation (non-normative)

Deferred

vgi.alerts.v1

An alert rule is a query that returns the rows meeting a condition. Each row is an alert instance, identified by the rule's key columns, and its other columns are the instance's details. Instances move through states, people are notified on transitions rather than on every evaluation, and anyone with access can acknowledge, snooze or subscribe. Shared conventions are in README.md; sessions and grants are in credentials.md. A host of vgi.alerts.v1 also hosts vgi.delegations.v1: rules carry no credentials and are evaluated by reattaching each source with the owner's delegation for it.

Why not a schedule with a condition. A schedule delivers every time its condition holds and has no memory between runs. An alert needs state (notify when West starts falling short and when it recovers, not every five minutes in between), one instance per entity, the triggering values in the message, and people acting on it. Those are this protocol. Either protocol can be hosted without the other; the reference service runs both on one engine.

Rules

@dataclass
class AlertRule:
    title: str
    description: str = ""
    severity: str = "warning"            # info | warning | critical
    data_sources: list[DataSource]       # the session to evaluate in (credentials.md)
    setup_sql: str = ""
    sql: str                             # returns the rows meeting the condition
    key_columns: list[str] = []          # identify an instance; empty = one instance at most
    parameters: list[ParameterSpec] = []
    parameter_values: list[ParamValue] = []
    evaluate_every_seconds: int = 300    # at least the evaluator's minimum
    pending_for_seconds: int = 0         # must keep matching this long before firing
    resolve_after_seconds: int = 0       # must stay absent this long before resolving
    repeat_every_seconds: int = 0        # re-notify while firing and unacknowledged; 0 = never
    max_instances: int = 100
    messages: AlertMessages = ...
    owner_destinations: list[Destination] = []
    share_details_with_subscribers: bool = False
    enabled: bool = True

@dataclass
class AlertMessages:
    title_template: str = "{{rule.title}}: {{instance.key}}"
    summary_template: str = ""           # e.g. "{{region}} is {{shortfall | percent}} below target"
    resolved_template: str = ""          # default: "{{instance.key}} recovered"
    field_columns: list[str] = []        # detail columns shown as headline fields

The query is the condition. "Any region below target today" is:

SELECT region, revenue, target, revenue / target - 1 AS shortfall
FROM sales.main.daily_revenue
WHERE day = current_date AND revenue < target

with key_columns = ["region"]. A row for West means West matches; no row for West means West doesn't. A yes/no alert is a rule with no key columns whose query returns one row or none, which is the schedule condition's case made stateful.

Evaluation runs setup_sql and sql in a session rebuilt from data_sources and the owner's delegations, wherever the implementation runs it. Parameters bind as in schedules, relative values included, so last_n_days:1 works in a rule.

Result rules. Key columns must exist and be non-null, and keys must be unique within one result; a violation is an evaluation error. More than max_instances rows is an evaluation error (too_many_instances), never a silent truncation. Detail values are kept as their Arrow types and rendered by the templates.

Instances and states

An instance's id is a hash of (rule_id, canonical key), where the canonical key is the RFC 8785 JSON of the key columns in key_columns order. The same region therefore maps to the same instance across evaluations and restarts.

stateDiagram-v2
    [*] --> pending: row appears
    pending --> firing: still present after pending_for_seconds
    pending --> [*]: row gone before then
    firing --> resolving: row gone
    resolving --> firing: row back
    resolving --> resolved: still gone after resolve_after_seconds
    resolved --> pending: row appears again

Notifications

Notifications go out on transitions, never on every evaluation:

Event Sent when Message
firing pending → firing, or resolved → … → firing title_template / summary_template, with the detail values
resolved resolving → resolved resolved_template
repeat Still firing, unacknowledged, not snoozed, every repeat_every_seconds As firing, marked as a reminder
rule_error / rule_recovered The rule enters or leaves error To the owner only

Templates are logic-less, with {{column}}, {{rule.title}}, {{instance.key}}, {{instance.fired_at}} and a fixed set of formatters: number, percent, currency:<ISO 4217>, date, datetime, duration, pinned with test vectors. Channel formatting and escaping are the notify service's. create_rule and test_rule reject a template that names a column the query doesn't return.

Who sees the details

A rule is evaluated as its owner, so its rows are the owner's data. The protocol holds no grants for anyone else and can't check what they may see.

Acknowledge, snooze, subscribe

Every action records actor, time and note, and appears in the instance or rule timeline.

Methods

U = unary, S = producer stream. All are required.

Method Kind Purpose
get_alerts_info() U Minimum evaluation interval, max_instances ceiling, maximum snooze, limits
list_rules(query, owned_by_me, state) / get_rule(rule_id) S / U Rules with their state (ok, firing, error, disabled) and instance counts
create_rule(request_id, rule) U Owner is the caller; grant_required if the owner lacks a grant for a required location
update_rule(rule_id, expected_version, rule) U Includes pausing via enabled. Changing the key columns starts new instances; the old ones close as superseded
delete_rule(rule_id, expected_version) U Instances close as superseded
test_rule(rule) U Evaluate an unsaved rule once: the instances it would create, rendered messages, template errors. Sends nothing
list_instances(rule_id, state, since) / get_instance(instance_id) S / U Instances with state and times; detail values only for callers who may see them
set_acknowledged(instance_id, acknowledged, note) U
snooze(rule_id, instance_id, until, until_resolved, reason) / unsnooze(snooze_id) U Empty instance_id snoozes the rule
subscribe(rule_id, destinations) / unsubscribe(subscription_id) / list_subscriptions(rule_id) U / U / S
list_events(rule_id, instance_id, since) S Timeline: transitions, notifications, acknowledgements, snoozes, errors

Permission hints. Rules and instances carry allowed_actions from the shared set plus acknowledge, snooze, subscribe, view_details.

Errors use the shared kinds in README.md, plus:

invalid_request covers a bad rule (a template column the query doesn't return, a missing key column, an interval below the minimum) and a snooze past the maximum, with a BadRequest naming the field.

SQL binding

Schema alerts. Derived by the rules in README.md; this is the annotation.

Table List / get INSERT UPDATE DELETE
rules list_rules / get_rule create_rule update_rule, expected = the row's version delete_rule, same
instances list_instances / get_instance
events list_events

list_instances and list_events with no rule_id cover every rule the caller can read. list_subscriptions has a required rule_id, so it stays a table function. set_acknowledged, snooze, unsnooze, subscribe, unsubscribe and test_rule are procedures. Detail columns are NULL where the caller may not see them, as over RPC.

SELECT rule_id, instance_id, state FROM ops.alerts.instances
WHERE state = 'firing';

CALL ops.alerts.set_acknowledged(instance_id := 'i_west', acknowledged := true,
                                 note := 'Investigating');

Conformance

The state machine is pinned with vectors. Each vector is a sequence of (time, rows returned or error) evaluations, plus acknowledgements and snoozes, with the expected transitions and notifications. The fake clock and recording notifier in vgi.testing run them, so every implementation agrees on when West fires, reminds and resolves.

Reference evaluator (non-normative)

Deferred

vgi.notify.v1

One channel-agnostic protocol for delivering a message to email, Slack, Microsoft Teams, Discord or a webhook. The caller says what to say in a portable content model; the service decides how each channel shows it, and owns which destinations are allowed. Shared conventions are in README.md.

Destinations

Destinations are {kind, address} pairs. kind is an open set; v1 names these:

Kind address
email ana@example.com
slack Channel or user id, e.g. C024BE91L
teams Name of a registered channel
discord Name of a registered channel
webhook Name of a registered endpoint, e.g. ops-pagerduty; never a raw URL

Everything URL-shaped is a registered destination the operator configured, so a schedule can't be pointed at an attacker's URL.

Message

@dataclass
class Notification:
    title: str                            # subject line / headline
    summary: str                          # plain text, always delivered
    body_markdown: str = ""               # CommonMark subset; converted per channel
    link_url: str = ""                    # primary action, e.g. the run or alert page
    link_label: str = ""
    fields: list[Field] = []              # headline values: {label, value}
    image: Attachment = ...               # inline preview; empty url = none
    attachments: list[Attachment] = []    # {filename, media_type, url, size_bytes, sha256}
    severity: str = "info"                # info | warning | critical
    thread_key: str = ""                  # groups follow-ups (Slack thread, email In-Reply-To)
    on_behalf_of: PrincipalRef = ...
    correlation: dict[str, str] = {}      # schedule_id, run_id, rule_id, ...

Each service maps the model to the channel's native form, and parts a channel can't carry degrade to text, so callers never branch on channel.

Attachment URLs must be fetchable by the notify service: the caller issues short-lived signed URLs for them. Links (link_url) are different: they point at pages that check the reader's own access.

Methods

U = unary, S = producer stream. All are required.

Method Kind Purpose
get_notify_info() U Per kind: enabled, limits (text length, attachment bytes), images, threads; destination policy summary
list_destinations(kind, query) S Registered and discoverable destinations with display_name; also address autocomplete
check_destinations(destinations) U Per destination: allowed, display_name, or why it is refused
send(request_id, destinations, notification) U notification_id and one result per destination: accepted, refused (with reason, e.g. policy, unsubscribed) or failed

send delivers to the destinations it can and reports the rest. A caller that wants all-or-nothing calls check_destinations first. A caller not on the sender allowlist gets action_denied; a provider outage is service_unavailable with RetryInfo.

SQL binding

Schema notify. Derived by the rules in README.md; this is the annotation.

Table List / get INSERT UPDATE DELETE
destinations list_destinations

send and check_destinations are procedures, so a SQL task can notify as a step of its script.

Policy

Reference notify service (non-normative)

The reference host is a new vgi-notify repo hosting every channel, with a stdout channel for development.

Plan: attach tickets and vgi_export_session()

Status: plan for review · 2026-10-06

Goal: serialize a user's attached DuckDB session so any runner can reattach it later as that user, without seeing the user's secrets. Design context is in credentials.md. This plan covers the worker side (every SDK) and the extension. vgi-rpc needs no change: tickets are a VGI artifact, sealed the way VGI already seals attach_opaque_data.

Decisions to confirm

# Decision Recommendation Why
T1 A vgi.v2 method, or a separate protocol? Separate: vgi.attach_tickets.v1 vgi.v2 is version-gated on exact major+minor. A new vgi.v2 method means 2.2.0, and every SDK plus the extension must release in lockstep, as catalog_contents did. A separate protocol is optional per worker (D1), discovered by reflection, and needs no vgi.v2 change.
T2 How the ticket reaches catalog_attach A reserved ATTACH option, vgi_attach_ticket, opened by the SDK framework before the catalog's own catalog_attach runs CatalogAttachRequest.options is already an open record, so the vgi.v2 wire is unchanged. The extension sends it only to workers whose reflection lists vgi.attach_tickets.v1, so an old worker never silently ignores it.
T3 Where sealing lives Each SDK's existing attach-envelope sealer (Python _seal_attach, Java OpaqueDataSealer, C++ seal_attachment, …), keyed by VGI_SIGNING_KEY, with a ticket-specific AAD Every SDK already seals attach_opaque_data with an AEAD bound to the caller, using vgi-rpc's crypto primitive where the port exposes one. Tickets are the same kind of VGI artifact. No vgi-rpc change, no new key, no new cipher.
T4 What gets sealed The options the extension attached with, sent by the extension; validated against the catalog's declared AttachOptionSpecs, not by a trial attach The extension holds the original values, secrets included. Stateless workers don't keep them. Validation without attaching has no side effects.
T5 Ticket and grant separate Yes See credentials.md: a ticket carries no authority, so it needs no fresh login, never enters AuthContext, and adds no authentication path.

Token format

ticket   = "vgia1." base64url_nopad( envelope )
envelope = the SDK's attach envelope: version || nonce || ciphertext || tag, under VGI_SIGNING_KEY
aad      = "vgi.attach_ticket.v1" 0x00 || UTF-8(principal)

Same sealer, different AAD. attach_opaque_data binds (auth domain, principal). A ticket must bind the principal only, because it's sealed while the user is logged in (domain jwt, say) and opened when a runner presents their grant (domain grant). The principal is the same in both: a grant is minted for its caller's principal. Binding the principal in the AAD also makes the principal check free: a ticket opened under any other principal fails authentication, so there is no separate comparison to forget.

The prefix keeps tickets distinguishable from other strings and versioned. The distinct AAD domain means an attach envelope can never be opened as a ticket, or the reverse.

Payload (integers little-endian; strings are u16 length plus UTF-8):

Field Encoding
issued_at, expires_at int64 seconds (expires_at = 0 means no expiry)
ticket_id string (32 hex)
catalog_name string
data_version_spec, implementation_version string ("" = none)
options u32 length plus Arrow IPC bytes of the options record, exactly as in CatalogAttachRequest.options

Capped at 16 KiB of options. Verification: prefix, canonical base64url, open under the caller's principal (failure is attach_ticket_invalid), strict parse, then lifetime with 60 s skew.

Key and lifetime.

Principal namespaces must be consistent per worker. A ticket sealed for alice opens for any caller whose principal is alice, whatever domain authenticated them. That's already true of grants, and a deployment whose authenticators map the same string to different people has that problem today. The spec says so.

vgi.attach_tickets.v1

One method. Hosted on HTTP when VGI_SIGNING_KEY is set explicitly and the worker can issue grants (grant keys, or its own mint_grant), because a ticket is useless without a grant.

@dataclass
class SealAttachRequest:
    catalog_name: str
    options: bytes                    # Arrow IPC record, as CatalogAttachRequest.options
    data_version_spec: str = ""
    implementation_version: str = ""
    ttl_seconds: int = 0              # 0 = as long as the worker allows

@dataclass
class AttachTicket:
    ticket: str
    expires_at: float                 # +inf when the worker sets no maximum
Method Rules
seal_attach(request) -> AttachTicket Caller must be authenticated (anonymous is action_denied). The principal sealed is the caller's. Options are validated against the catalog's declared options (invalid_request with BadRequest). No freshness requirement. A worker may refuse by policy (action_denied).

Reattach: what every SDK's catalog_attach does

When options contains vgi_attach_ticket, the framework, before any catalog code:

  1. Rejects any other catalog option alongside it (invalid_request). The sealed options are authoritative, so there is nothing to merge.
  2. Verifies the ticket: an invalid one is invalid_request (attach_ticket_invalid); an expired one is FAILED_PRECONDITION (attach_ticket_expired).
  3. The principal check is the open itself: the AAD carries the caller's principal, so a ticket presented by anyone else fails as attach_ticket_invalid. A stolen ticket is useless without that user's grant.
  4. Calls the catalog's catalog_attach with the sealed catalog_name, options and version specs, as if the user had typed them. Catalog code needs no change.

vgi_attach_ticket becomes a reserved option name: declaring an AttachOption with it fails at startup. Tickets and restored secret options are never logged, and loggable_attach_options sees the restored options as usual.

Extension

vgi_export_session(aliases := NULL, ttl_seconds := NULL), a table function. For each attached catalog (or each alias given):

  1. Not TYPE vgi: emit the row with status = 'not_vgi'.
  2. Reflection on the worker: if vgi.attach_tickets.v1, or issue_grant on vgi_rpc.Identity.v1, is missing, emit not_supported.
  3. Call issue_grant(purpose = "vgi.unattended", scopes = [], ttl_seconds) using the catalog's existing auth. A stale_auth refusal is stale_login, so Cupola can step up and retry.
  4. Call seal_attach with the catalog name, the original option values (secrets included) and the version specs.
  5. Emit alias, location, catalog_name, grant, ticket, expires_at, status, message.

Per-row failures never abort the export.

attach_ticket ATTACH option.

Prerequisites in the extension:

Work and order

Step Repo Work
0 vgi (extension), vgi-python Reflection in the clients. Every worker hosts vgi_rpc.Reflection.v1, but no VGI client calls it. Extension: a vgi_protocols(...) table function (by location like vgi_catalogs(), or by attached catalog alias) returning protocol_name, protocol_version, protocol_hash, plus a cached per-catalog "hosts protocol X?" check for the extension's own features. vgi-python: Client.list_protocols(). The TypeScript client already has it (vgi-rpc-typescript introspect). Cross-SDK sqllogictests in test/sql/integration/reflection/.
1 vgi-python (reference SDK) vgi.attach_tickets.v1, ticket payload, the catalog_attach interception, reserved option name, attach_ticket_vectors.json, fixture worker with a secret attach option. Spec in vgi/docs.
2 vgi (extension) vgi_export_session(), the attach_ticket option, retained options, the Identity and tickets clients, wasm. sqllogictests (below). In parallel with step 1, against the Python fixture.
3 6 other VGI SDKs Port step 1 on each SDK's existing attach sealer and pass the same sqllogictests. No vgi-rpc releases are needed first.
4 Cupola Export on save and on login renewal; put_delegations to the scheduler and alert hosts. Not part of this plan's release.

No vgi.v2 version bump and no vgi-rpc release, so the SDKs and the extension release independently.

Tests

Cross-SDK sqllogictests, in ~/Development/vgi/test/sql/integration/attach_ticket/, over HTTP, against every SDK's fixture worker:

Unit tests: in every port and SDK, attach_ticket_vectors.json with exact mint, accept and reject cases: fixed key, nonce and clock, including a principal mismatch and an attach envelope presented as a ticket.

Mutation checks: drop the principal from the AAD (or use the (domain, principal) attach AAD), skip expiry, drop the "no other options" rule, and skip the reflection check before sending. Each must turn a test red.

Risks

Prerequisites: multi-protocol hosting across vgi-rpc and the VGI SDKs

Status: done · planned 2026-10-05, released 2026-10-06 (see Outcome)

Before any reporting protocol can ship, every vgi-rpc port and every VGI SDK must let a worker host additional protocols, classify transient errors with a retry hint, and host vgi_rpc.Identity.v1. This plan covers 1 spec, 7 vgi-rpc ports and 7 VGI SDKs. Rowfence is deferred.

Decisions

# Decision Consequence
D1 The protocol is the unit of optionality. No method subsets and no feature tokens for application protocols. A capability that is optional becomes its own protocol. No narrowing API. Reflection features stays emitted-empty; the spec says so explicitly. Identity keeps its existing hook-based narrowing, untouched.
D2 A worker supplies a list of (protocol, implementation) pairs when its server is built. The list may be computed from configuration or environment, and is fixed for the life of the process. Reflection output and protocol hashes stay stable per process. The same list is hosted on every transport.
D3 Identity stays framework-owned, and lands in all seven SDKs now. It keeps the reserved vgi_rpc. name and its guards, is opted into through resolve_token / mint_grant hooks rather than the worker's list, and is hosted on transports that authenticate callers (HTTP). Every SDK gets the same opt-in and the same refuse-to-start rule when introspection is enabled without an allowlist.
D4 Errors take gRPC's shape. A closed set of canonical codes (vgi_rpc.error_code), the existing open error_kind as the fine-grained reason, and a list of typed details from a fixed catalog (vgi_rpc.error_details). Rules below. Clients get a uniform retry and handling policy from the code alone. Transient errors carry RetryInfo. Later protocols reuse the catalog (e.g. a revision conflict as ABORTED + ResourceInfo).
D5 Rowfence routing is out of scope. Rowfence still forwards only Identity.v1/issue_grant.
D6 The cross-SDK checks live in vgi-rpc-python's conformance suite. Every SDK fixture worker hosts the same conformance.Secondary.v1 the ports use. One fixture protocol and one test group serve both the ports and the SDKs.

Error model (adopted from gRPC)

gRPC's google.rpc.Status model (canonical codes, a reason, and typed details from error_details.proto, with AIP-193 as the usage guide) has held up across many languages for years. We adopt its shape, and fix the parts that caused trouble in practice.

Three layers on every EXCEPTION batch:

Layer Wire Set Purpose
Code vgi_rpc.error_code, the code's name, e.g. UNAVAILABLE Closed: gRPC's 16 codes minus OK Generic handling: retry or not, how to show it, and later the HTTP status a proxy maps it to
Reason vgi_rpc.error_kind, unchanged, e.g. identity_unavailable Open; unique within the protocol that raised it What a client branches on. The pair (protocol, kind) is gRPC's (domain, reason)
Details vgi_rpc.error_details, a JSON array of typed objects Fixed catalog below Machine-readable specifics: retry delay, which field, which resource

error_message stays developer-facing English, as in gRPC.

Detail catalog. Each object names its type in @type, mirroring protobuf's JSON form for Any. Field names follow gRPC's, in snake_case:

@type Fields Use
vgi_rpc.ErrorInfo metadata: {string: string} Extra context for the reason. Reason and domain are already error_kind and the protocol, so they aren't repeated
vgi_rpc.RetryInfo retry_delay_seconds: number How long to wait before retrying
vgi_rpc.BadRequest field_violations: [{field, description}] Which inputs were wrong
vgi_rpc.PreconditionFailure violations: [{type, subject, description}] What state must change first
vgi_rpc.QuotaFailure violations: [{subject, description}] Which limit was hit
vgi_rpc.ResourceInfo resource_type, resource_name, owner, description Which object the error concerns
vgi_rpc.Help links: [{description, url}] Where to read more
vgi_rpc.LocalizedMessage locale, message Text safe to show an end user

DebugInfo is deliberately absent (see Tracebacks below).

Rules:

What we fix from gRPC's experience:

Codes for the existing kinds:

error_kind Code Details
method_not_implemented UNIMPLEMENTED
protocol_not_supported UNIMPLEMENTED
protocol_not_specified INVALID_ARGUMENT
protocol_version_mismatch FAILED_PRECONDITION PreconditionFailure
session_lost ABORTED
server_draining UNAVAILABLE RetryInfo
identity_unavailable UNAVAILABLE RetryInfo (required)
stale_auth UNAUTHENTICATED
introspection_refused PERMISSION_DENIED
grant_refused PERMISSION_DENIED
token_unresolved NOT_FOUND

What the audit found

Spec rules ports already break. These need no spec change, only fixes:

The spec doesn't say these. All seven ports get them wrong, which is a sign the spec is the cause:

SDKs:

Out of scope:

Phase 0 — Spec (vgi-rpc repo)

  1. docs/WIRE_PROTOCOL.md §3.1, "Hosting several application protocols." - A server MUST let an application register any number of application protocols at construction, each (name, version, implementation). - The registered set is fixed for the server's lifetime and is the same on every transport the server is offered on. - The reserved-prefix rule applies to every registered protocol, however its name was derived. - list_protocols lists application protocols in registration order, the primary first. The client driver's describe op already relies on "first non-reserved protocol".
  2. §8, "Error model." - New keys vgi_rpc.error_code and vgi_rpc.error_details, EXCEPTION batches only, also mirrored in log_extra. - Normative text for the three layers, the detail catalog, the rules and the retry policy above. - The error-kinds table gains code and details columns, as above. - The traceback switch, on by default on every transport.
  3. §16, "Identity." - identity_unavailable MUST carry RetryInfo. - A resolve_token or mint_grant hook that raises the port's transport-auth "unavailable" error MUST be emitted as identity_unavailable with that error's retry hint, never unclassified. That is the TypeScript and Rust behaviour today.
  4. §14, "Reflection." features is reserved and MUST be emitted as an empty list in this version; clients MUST ignore its contents.
  5. tools/cross-port/specs/CLIENT_DRIVER_PROTOCOL.md. Add error_code (string, "" when absent), error_kind (string, "" when absent) and error_details (array, [] when absent) to the structured error object.
  6. tools/cross-port/specs/IDENTITY_CONFORMANCE_FIXTURE.md. Add a reserved test token that makes the fixture's resolver raise the port's auth-unavailable error with a retry hint of 7 seconds.
  7. New decision record, tools/cross-port/specs/MULTI_PROTOCOL_HOSTING.md, in the IDENTITY_V1_SPEC.md style: - the decisions above, with reasons - the conformance fixture protocol (Phase 1) - per-port deliverables - the pinned protocol hash for the fixture protocol
  8. docs/porting-guide.md. A checklist entry for each item above.

Commit titles name the finding, e.g. "§8 never said how a retry hint reaches the client, and all seven ports dropped it".

Phase 1 — Python reference (vgi-rpc)

  1. Error model. - A Code enum and the detail catalog as dataclasses in vgi_rpc, with to_json / from_json. - Every error class with an error_kind also declares its error_code. Classes may supply error_details. - Message.from_exception (vgi_rpc/log.py:207) writes code, kind and details to the top-level keys and log_extra, enforcing the cap. - IdentityUnavailableError and AuthUnavailableError supply RetryInfo. - One server switch for tracebacks, on by default on every transport. - Access-log records gain error_code.
  2. Client. - RpcError (vgi_rpc/rpc/_common.py:688) gains error_code, error_kind and error_details, plus typed accessors (retry_info(), bad_request(), …), filled on every decode path: pipe, HTTP, stream, and external. - is_retryable() on RpcError, following the rule above. No automatic retry. - client_driver.py reports all three.
  3. Identity translation. IdentityImpl catches AuthUnavailableError from either hook and raises IdentityUnavailableError(detail, retry_after=...). This also makes vgi-python's documented advice correct.
  4. Reflection. No code change; features=[] is now what the spec requires.
  5. Conformance fixture protocol. The conformance worker hosts a second application protocol, conformance.Secondary.v1, through extra_protocols. - echo(value: str) -> str. The primary also has a method of this name, so routing by (protocol, method) is exercised. - fail(code: str, kind: str, retry_delay_seconds: float): raises an error with that code and kind, plus RetryInfo when the delay is positive. - fail_oversized(): raises an error whose details exceed 4 KiB.
  6. Conformance tests (vgi_rpc/conformance/, run against every port on every transport): - The secondary is listed second by list_protocols, and describe works. - echo routes by pair and the same-named primary method stays distinct. - fail round-trips error_code, error_kind and error_details. - The Identity fixture's auth-unavailable token yields UNAVAILABLE / identity_unavailable with RetryInfo of 7 seconds. - Every existing kind carries the code in the mapping table. - Tracebacks are present by default on every transport and absent when the switch is off. - Detail rules: unknown detail types are ignored by clients; an array over 4 KiB is dropped whole. - Client role: through client_driver, each port's client reports error_code, error_kind and error_details from the reference server. - Hosted-protocols group, for SDK workers. Takes a worker command or URL plus the expected protocol list. It asserts that reflection lists every expected protocol in order, that conformance.Secondary.v1 passes the group above, and, over HTTP when the fixture opts in, that vgi_rpc.Identity.v1 is listed and passes the Identity fixture tests. It runs on stdio, unix and HTTP.
  7. Tooling. tools/cross-port/describe_diff.py compares the secondary's hash across ports.
  8. Release. vgi-rpc to PyPI (remote upstream), before anything consumes it.

Phase 2 — The six other vgi-rpc ports

Same deliverables in each, verified by the Phase 1 suite in both directions: the port's server against the reference client, and the port's client against the reference server.

Port Public hosting API to add Reserved prefix Emit code + details; traceback setting Translate auth-unavailable Client exposes code / kind / details
TypeScript Launchers (serveTcp, serveUnix, serveStream) accept a protocol host with extra protocols, closing the gap noted at server.ts:177 ok add ok add both (log-batch.ts:33)
Go Exported binding constructor, so AddProtocol works outside the package ok add add (identity_v1.go:587) detail; use errors.As for kind (wire.go:251)
Rust Builder method to add a protocol with its dispatcher add on the application name (server.rs:561) add (server.rs:1989) ok add both (envelope.rs:57)
C# Constructor or builder accepting additional (interface, implementation) pairs extend to derived names add add (IdentityImpl.cs:134) detail
Java addProtocol(Class<?>, Object) beside setIdentity ok add (Wire.java:232) add (IdentityImpl.java:198) detail
C++ ServerBuilder::add_protocol(...) add (server.cpp:129) add (result.cpp:80) add (token_identity.cpp:481) detail

Each port's conformance worker hosts conformance.Secondary.v1, and its client driver reports error_code, error_kind and error_details. Releases follow the per-repo mechanics (crates.io and Go on tag push, npm and Maven on release). The VGI SDKs that depend on these ports wait for each release.

Phase 3 — The seven VGI SDKs

Common contract.

SDK Work
vgi-python build_rpc_server(worker, transport) replaces the 5 RpcServer(...) calls (vgi/serve.py:321, :612; vgi/worker.py:1727, :5565, :5644). Add Worker.hosted_protocols(cls) -> Sequence[tuple[type, object]]. Iroh gets the same set. Docs: AuthUnavailableError is now correct advice; say why.
vgi-typescript Consolidate its 5 sites (worker.ts:198, :223, :248, :289; http/fetch.ts:200) into one builder; add the hook; add Identity opt-in with the allowlist rule.
vgi-go Hook and Identity in buildServer (vgi/worker.go:1235).
vgi-rust Hook and Identity in build_parts (vgi/src/worker.rs:297).
vgi-csharp Hook and Identity in NewRpcServer (Worker.cs:381).
vgi-java Hook and Identity in buildServer (Worker.java:1138); bump the vgirpc dependency and the VGI_RPC_JAVA_REF CI pins.
vgi-c++ Hook and Identity in Worker::run (worker.cpp:231).

Fixture and test.

Order and parallelism

flowchart LR
    S[Phase 0: spec] --> P[Phase 1: Python vgi-rpc + suite] --> R1[release vgi-rpc]
    R1 --> TS[vgi-rpc-typescript] & GO[vgi-rpc-go] & RS[vgi-rpc-rust] & CS[vgi-rpc-csharp] & JV[vgi-rpc-java] & CP[vgi-rpc-c++]
    R1 --> VP[vgi-python]
    TS --> VT[vgi-typescript]
    GO --> VG[vgi-go]
    RS --> VR[vgi-rust]
    CS --> VC[vgi-csharp]
    JV --> VJ[vgi-java]
    CP --> VX[vgi-c++]

Definition of done

Outcome

Everything above shipped on 2026-10-06, followed the same day by grants accepted as bearer credentials (vgi-rpc GRANT_AUTHENTICATION.md), which the reporting design needs for unattended runs.

Language vgi-rpc port VGI SDK
Python vgi-rpc 0.49.0 vgi-python 0.42.0
TypeScript vgi-rpc-typescript 0.27.0 vgi-typescript 0.40.0
Go vgi-rpc-go 0.32.0 vgi-go 0.31.0
C# vgi-rpc-csharp 0.14.0 vgi-csharp 0.15.0
Java vgi-rpc-java 0.29.0 vgi-java 0.38.0
Rust vgi-rpc-rust 0.30.0 vgi-rust 0.40.0
C++ vgi-rpc-cpp 0.7.0 vgi-cpp 0.7.0

Versions are the grant-acceptance releases; the hosting and error-model work landed in earlier releases.

Differences from this plan:

Still open from this round:

Implementing the reporting protocols

The build plan for the protocols in this directory, written so an agent with no other context can pick up where the last one stopped. Read this file, then the README, credentials.md and the spec of the phase you're on. Update the status table when a phase lands, in the same commit.

Status

Phase Status Landed in
P0 Specs committed, HTML builder in the repo done this commit
P1 Unlimited default grant lifetime not started
P2 SQL-binding framework (vgi-python) not started
P3 vgi.reports.v1 not started
P4 vgi.delegations.v1 not started
P5 Run engine and vgi.schedules.v1 (run_query) not started
P6 vgi.sql_tasks.v1 not started
P7 vgi.notify.v1 reference and deliveries not started
P8 vgi.alerts.v1 not started
P9 vgi.report_render.v1 and render_report not started
P10 Cupola client not started (can start after P3)

Already shipped and not part of this plan: multi-protocol hosting, the gRPC error model, Identity.v1, grants as bearers, attach tickets (vgi.attach_tickets.v1, vgi_export_session(), the attach_ticket ATTACH option), full vgi.v2 parity across the seven SDKs.

Decisions

Settled; don't reopen them without the user. The README's Decisions table has the protocol-level ones; these are the build-level ones and late changes.

# Decision
D1 The protocol is the unit of optionality; every method is required.
D2 vgi.delegations.v1 (not "held attachments"): Delegation, put_delegations, list_delegations, revoke_delegation.
D3 vgi.sql_tasks.v1 is its own protocol on the shared run engine, for SQL that changes data. Both destination kinds (catalog, host) and both watermark kinds (derived, stored). Scripts run statements in one transaction by default; CALL is a statement.
D4 vgi.schedules.v1's run_query stays as specified (it may write). The overlap with SQL tasks is accepted.
D5 The delegation field stays grant. DuckDB accepts it unquoted (the extension's attach_ticket/round_trip.test does SELECT grant FROM …).
D6 The SQL binding is normative and derived from the RPC definition by a generic adapter plus a per-protocol annotation; never hand-written (README).
D7 Protocol-table writes are refused inside an explicit multi-statement transaction (each row is an RPC that ROLLBACK can't undo). Revisit only if a real use needs it.
D8 Specs stay in docs/design/reporting-protocols/ until each protocol ships; a shipped protocol's spec then moves to docs/protocol/.
D9 These are service protocols. The reference implementation is Python; other SDKs implement one only when someone wants to host it natively. The extension tests the SQL bindings cross-language.
D10 Build order is by dependency, P1 to P9. Each phase ends usable, with conformance over both surfaces.

Working rules

Where work happens

Testing

Commits and releases

Phases

P0 Specs committed (done)

docs/design/reporting-protocols/ committed, with scripts/build_reporting_protocols_html.py, which regenerates reporting-protocols.html from the markdown. Rerun it after editing a spec.

P1 Unlimited default grant lifetime

The design says a grant's lifetime is the issuing worker's policy and unlimited unless set. vgi-rpc still defaults to 7 days (DEFAULT_MAX_TTL_SECONDS in vgi_rpc/grants.py), so every unattended job would stop a week after its owner's last login.

P1 doesn't block P2 to P4; development can set an explicit TTL meanwhile.

P2 SQL-binding framework (vgi-python)

The generic adapter that serves a hosted RPC protocol as a VGI catalog schema, built before any protocol so each ships both surfaces.

P3 vgi.reports.v1

P4 vgi.delegations.v1

P5 Run engine and vgi.schedules.v1

P6 vgi.sql_tasks.v1

P7 vgi.notify.v1 reference and deliveries

P8 vgi.alerts.v1

P9 vgi.report_render.v1 and render_report

P10 Cupola client

Known gaps to close while building