@segmentstream-cloud/ontology
v0.68.0
Published
Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.
Maintainers
Readme
@segmentstream-cloud/ontology
The backend of a SegmentStream workspace app, written with Effect.
The ontology/ folder of the workspace repository declares entities, their properties,
the relationships between them, the roles and the actions people and agents may take.
From that one definition, the SDK builds the manifest, the tables in the workspace's
database and the HTTP service under /api/ontology. Every read and every action requires
a permission that the caller's roles grant, and every change to a record goes through an
action and is kept in history.
TypeScript definitions, handler records and SDK calls use camelCase: jobTitle,
assignedOwnerId, createdAt, and changeStatus. The SDK converts ontology names,
select identifiers and declared action input fields to snake_case for the generated
REST API, version 10 manifest and storage. TypeScript helpers and options stay
camelCase. JSON property contents, provider payloads, opaque action results and
historical change names remain data and are preserved. formatManifest serializes
the canonical wire contract shared by REST, storage and future transports.
When calling REST or MCP directly, use the snake_case wire name:
| TypeScript action | REST path | MCP tool |
| ------------------ | --------------------------------------------------------- | ----------------------------------- |
| createDraft | /api/ontology/expense/actions/create_draft | expense_create_draft |
| attributePayroll | /api/ontology/bank_allocation/actions/attribute_payroll | bank_allocation_attribute_payroll |
ontology check lists canonical wire action names. MCP tools/list gives exact tool names;
describe action references use action:expense.create_draft. Incorrect casing and close typos
produce hints with a suggestions array for authenticated callers who may run the suggested
actions (MCP unknown-tool errors put it in error.data). Hints never execute an action or create
an alias; retry explicitly with the suggested name.
Changes
Workspace deployment is automatic on main pushes by default. To accumulate changes for an
explicit release, commit .segmentstream/deployment.json with
{ "schemaVersion": 1, "trigger": "manual" }. Cloud keeps storing and indexing source,
while segmentstream deploy explicitly deploys current main with its existing authorization
and idempotency checks. The configuration takes effect from current main without an ontology
migration. Removing the file or selecting "push" restores automatic deployment; no scheduler
is added, and already-started deployments continue.
For an existing hourly release process, the optional productionWindow setting
in the same file restricts explicit production admission to configured UTC minutes.
Outside that window, create and retry return deployment_window_closed with the
next opening time; no attempt starts. This adds no scheduler or preview restriction.
SDK 0.33.0 requires ActionContext.principal.identities. Code that invokes an action handler
directly, including unit tests, must provide it (use identities: [] when there are none),
plus createdVia and authenticatedVia. Identity facts use provider, subject, optional
username, and verified: true.
The general Principal and prepare context do not include this field.
Install
npm install @segmentstream-cloud/ontology [email protected]effect is a peer dependency. Install the exact version above: each SDK release supports
one Effect version.
ontology/ is a package of its own. The SDK is an ES module, so ontology/package.json
must set "type": "module".
SDK 0.37.0 writes manifest version 9, which adds workflows (see Workflows).
Version 8, since SDK 0.23.0, added the measures of the model. defineOntology({ measures }) declares measures of the
model: arithmetic over measures of entities, m.ref("acmeSales.ordered"), and over other
model measures, m.ref("planAttainment"). align names the properties that stand for one another
across the entities a measure reads, or null for an entity without a counterpart, such as a
current snapshot without a date:
defineOntology({
entities: [AcmeSales, AcmePlans],
measures: {
planAttainment: m.divide(m.ref("acmeSales.ordered"), m.ref("acmePlans.plannedUnits"), {
align: [{ acmeSales: "orderDate", acmePlans: "month" }],
unit: "ratio",
description: "Ordered units over planned units for the same months.",
}),
},
});Aggregate requests name a measure of the model by its bare name, plan_attainment, beside
entity.measure; a filter must reach every entity it reads unless its alignment says the entity has
no counterpart, and reading it requires reading each of those entities. check refuses aggregates
inside a measure of the model, unknown references, cycles and alignments that do not name every
entity once with properties of one type. Entity measures take no align.
SDK 0.17.0 writes manifest version 7. It adds m.avg, m.min and m.max; a where option on
every aggregate, so that a measure reads only the rows its conditions accept (m.sum("amount",
{ where: [{ property: "status", operator: "equals", value: "won" }], description })); and
balanceOver, which names the date a measure's rows are snapshots over, so that requests group by
it or filter it to one day. Aggregate requests
take the notEquals, notIn, gt, gte, lt, lte, contains, startsWith, endsWith and
inPeriod operators, the hour, quarter and year granularities and a timezone. See
ontology aggregates.
SDK 0.14.0 lets the app's own agent call the service as a paired member (see Agents). Its migration adds the agent's runtime tables.
SDK 0.18.0 lets blob properties store any file. accept is optional and can restrict a
property to exact MIME types or ranges. Existing explicit restrictions keep their meaning.
See File storage.
SDK 0.12.0 adds warehouse replication enabled by default for all stored business entities, fields and business relationships. Existing history captures immutable snapshots and the service wakes the publisher after commits. Upgrade and redeploy to activate it; workspace configuration can disable replication or exclude individual entities, fields and relationships. See setup and recovery.
SDK 0.13.0 writes manifest version 6. Relationships may target warehouse-backed entities, from
stored or warehouse-backed entities, matched by value through on, and unique is allowed on
warehouse-backed properties as an unenforced assertion. Upgrading: 0.12 accepted an explicit onDelete, or optional: true, on a
warehouse → stored relationship; 0.13.0 check refuses both, so remove them. See
ontology aggregates.
SDK 0.13.0 also serves aggregates. Upgrade notes:
POST /api/ontology/aggregate, the MCP toolaggregateand, for each entity with measures,POST /api/ontology/<entity>/aggregateanswer measures of the model as the caller. The service checks<entity>:readon every entity a request touches, then forwards it to SegmentStream withSEGMENTSTREAM_APP_CREDENTIAL. Previews and local development have no credential and answer503 aggregate_unavailable.m.countDistinct(property)counts the distinct values of a text, email, url, select, integer, date or timestamp property.- Stored entities may declare
measures, computed over their replicated records; the built-inUsercannot. aggregateis reserved for the route and the MCP tool: rename an entity with that name before upgrading.- Under
signIn: { policy: "open" }, anyone who signs in holdsmember, socheckrefuses amemberwhose wildcard (*:read, the default, or*:*) would let strangers read warehouse-backed entities through aggregates. Grant those tomemberby name (acme_sales:read), or keep theinvitedpolicy.
See ontology aggregates and ontology analytics.
File storage
Use p.blob({ description: "Original source file." }) to retain a file's original bytes.
Without accept, a blob property accepts every concrete MIME type, including custom formats
and application/octet-stream. p.blob() also defaults to all files, though ontology checks
require a description. The manifest publishes this policy as accept: ["*/*"].
Restrict a property when its business rules require a format:
content: p.blob({
description: "Original POS report or bank statement.",
accept: [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
"text/csv",
"text/tab-separated-values",
],
maxBytes: 10 * 1024 * 1024,
}),accept takes MIME types, not extensions. Matching ignores case; image/* accepts image
types and */* accepts all types. Empty lists, duplicate entries, invalid MIME types and
parameters such as ; charset=utf-8 are rejected during ontology checks.
BLOB_CONTENT_TYPES lists common types as a convenience; it is not a global allow-list.
| Source format | MIME type |
| --------------------- | ------------------------------------------------------------------------- |
| XLSX | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| XLS | application/vnd.ms-excel |
| CSV | text/csv |
| TSV | text/tab-separated-values |
| JSON | application/json |
| TXT | text/plain |
| DOCX | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| ZIP | application/zip |
| Unknown binary format | application/octet-stream |
Uploads still require a valid concrete MIME type, a nonempty file within the property's
maxBytes and the 250 MiB service limit, available workspace quota and action permissions.
A refusal for a platform limit is an action_rejected body with limit: storage_quota with
used_bytes, requested_bytes and quota_bytes; pending_uploads with pending and max; or
member_attachments with live and max, so an app or agent can explain it in its own words.
Completion verifies the stored MIME metadata, exact byte count and upload-session binding.
PDF, JPEG and PNG retain their prefix-signature checks; plain text retains its UTF-8 prefix
check. Other formats are stored as opaque bytes, without parsing, conversion, archive
extraction or malware scanning. A MIME declaration does not prove what a file contains.
Downloads use attachment disposition; only JPEG and PNG may request inline display.
Agent attachment promotion honors the same accept and maxBytes restrictions.
Server-side OntologyBackend extensions can use blobs.target(uploadId, principal) to
inspect a session's persisted entity, action, property, record ID or create intent before
continuing an upload. It checks the session owner, workspace branch, deployed manifest
and current member permissions without provider calls or writes. It is not an HTTP
endpoint and returns no storage key or transfer grant. Custom blob adapters may omit
this optional method; extensions requiring target inspection must then deny the operation.
Upgrade the ontology SDK to 0.18.0 or later, update any existing accept list that should
allow more types, then check, migrate and redeploy the ontology. The app SDK storage client
0.13.1 or later uses application/octet-stream when the browser supplies no MIME type.
REST callers should supply that type themselves for unknown formats.
See blob storage for upload tickets, transfer grants and retained revisions.
SDK 0.56.0 adds server-side file transfers through Files. In an action, declare
files: [uploads.target(self, "content")], stage a stream in prepare, then attach the
returned ticket in handler. This uses the same private bucket, upload sessions,
permissions, quotas and completion checks as browser uploads. Files.open streams an
authorized retained revision for sending to another provider. Use files: [] when
preparation only reads files and has no connections. Callers of staging actions provide
an idempotency key; workflow action calls derive a stable key from the run, step and input
when omitted. Streams stay within the step; carry tickets and references between steps.
See server-side file transfers
for the API and recovery rules.
Connections retain buffered get/post behavior. To read a large response, use
connection.stream({ method: "GET", path }) inside Effect.scoped and consume its body
as an Effect Stream before closing the scope. Request bodies remain capped at 2 MiB.
Define the ontology
An action with hints: { connectionsReadOnly: true, destructive: false } may read
providers in prepare and write local records in handler. All its connection
clients enforce GET/HEAD/OPTIONS without bodies or method overrides. MCP treats
these provider reads as closed-world; destructive or irreversible local writes
still require confirmation. This differs from readOnly: true, which requires
a prepare-only action and permits no local writes.
// ontology/lead.ts
import { action, ActionRejected, entity, p, rel, role, User } from "@segmentstream-cloud/ontology";
import { Effect, Schema } from "effect";
export const sales = role("sales", {
description: "Works leads to a pilot.",
permissions: ["lead:read", "lead:win"],
});
export const Lead = entity("lead", {
id: "ent_01J9Q4M8ZK",
label: "Lead",
plural: "Leads",
title: "email",
description: "A person who asked for a pilot.",
properties: {
email: p.email({ unique: true, description: "The address they submitted." }),
status: p.select(
[
{ value: "new", label: "New", description: "Nobody has replied yet." },
{ value: "won", label: "Won", description: "They became a customer. Final." },
],
{ default: "new", managed: true },
),
},
relationships: {
owner: rel.one(User, {
optional: true,
inverse: "ownedLeads",
onDelete: "setNull",
phrase: ["is owned by", "owns"],
description: "The team member responsible for the next step.",
}),
},
}).actions((self) => ({
win: action({
description: "Mark the lead as won.",
input: Schema.Struct({ lead: self.ref }),
// Requires lead:win, the default: <entity>:<action name>.
handler: ({ input }) =>
Effect.gen(function* () {
const lead = yield* self.get(input.lead, { forUpdate: true });
if (lead.status === "won") {
return yield* new ActionRejected({ message: "The lead is already won." });
}
yield* self.update(lead, { status: "won" });
}),
}),
}));// ontology/index.ts
import { defineOntology } from "@segmentstream-cloud/ontology";
import { Lead, sales } from "./lead";
export default defineOntology({
entities: [Lead],
roles: [sales],
// What every active app user may do. Without it, members may only read: ["*:read"].
member: { permissions: ["*:read"] },
// Who may become a member by signing in: only invited people (the default), or anyone ("open").
signIn: { policy: "invited" },
});entity(name, { … })declares an entity.idis its stable identity; do not change it.p.text,p.email,p.url,p.integer,p.number,p.boolean,p.date,p.timestamp,p.selectandp.jsondeclare properties.managed: truekeeps a property out of the generatedupdateaction.rel.oneandrel.manydeclare relationships. Between stored entities,rel.oneadds a key andrel.manya join table. With a warehouse-backed end,rel.onedeclares oneonpair and is matched by value.Useris the built-in entity for app users and service principals;AgentChat(agent_chat) is the built-in entity for the group chats of the app's agent (see Agents). Every ontology includes both. Text feedback defaults on with built-inAppFeedback; files follow the non-feedback-blob policy (or an explicit opt-in) and addAppFeedbackFile(see App feedback).role(name, { description, permissions })declares a role: a named set of permissions.memberis built in: every active app user holds it, with the permissions ofdefineOntology({ member })..actions((self) => ({ … }))adds actions. Each action has a description, an input schema and an Effect handler. It requires the permission<entity>:<action name>, or the one itspermissionnames.signIn: { policy }decides who may become a member by signing in.invited, the default, admits only people whose email a member already has, from an invitation or a role grant; anyone else is answered 403not_invited.openmakes anyone who signs in a member holding onlymember. The manifest carries the policy. Explicit app opt-in (never a platform or starter default): channel admission is independent:signIn: { policy: "invited", channels: { telegram: "open" } }lets private channel senders become email-less members. Missing channels require pairing; emailpolicy: "open"never opens a channel.CHANNEL_PROVIDERSlists supported providers (telegram). Every configured channel requires a matchingchat.<provider>adapter; object values are reserved. Open channels require explicitmember.permissions: entity wildcards and generated create/update/delete grants (including grants through entity:*) are errors. Named shared reads warn for review; public catalogs are valid. The hint is “move staff reads into a named role”. Actions such asupdateMinemust check ownership withprincipal.id. Closing a channel stops new registrations; existing members keep working. Blocking is sticky and independent of activation; deleting a member releases identities.signIn.onNewUser: "person:ensureMine"optionally names an app-owned onboarding action. It must exist, accept{}, be runnable bymember, declarehints.idempotent: true, and have noprepareor external connections. Its handler must have no external effects. The runtime runs it as the new user with only member permissions after admission commits, before the first model turn or Mini App session. Handler writes and its completion marker commit together, under a five-second deadline. Failure logs diagnostics and leaves it pending for retry on the next eligible contact; admission still succeeds. The service shares one action admission gate between onboarding and ordinary actions, reserving database capacity for other requests before either kind of handler acquires a connection. Invited/paired-only members never run this hook. The platform supplies mechanism; the app owns channel policy, grants, hook content, notice text, and lower limits. Nothing defaults to open.check(config, { agentConfig })warns if open channels lackfirstContact.noticeorlimitswhen agent config is supplied. The ontology CLI cannot see that config and says those checks are skipped.defineOntologychecks the definition and builds the manifest. It throwsInvalidOntologywith every diagnostic when the definition has errors. Entities, actions and select options need a description.
Workflows
A workflow is a durable process over time, written with the
Vercel Workflow SDK: it can wait, retry a step, run several actions and
call connections. The destination decides the home, not the data source: work that outputs
tables is a job in pipeline/; records change through actions; work that runs on its own over
time and outputs records, paged provider reads included, is a workflow in ontology/workflows/.
Declare one per
file with workflow and cron from @segmentstream-cloud/ontology/workflows, and list them in
defineOntology({ workflows }):
// ontology/workflows/remind-stale-leads.ts
import { cron, p, run, workflow } from "@segmentstream-cloud/ontology/workflows";
import { Effect, Schema } from "effect";
import { Lead } from "../lead";
async function remindStaleLeads(input: { olderThanDays: number }) {
"use workflow";
const leads = await findStale(input.olderThanDays);
for (const lead of leads) await remind(lead.id);
}
async function findStale(olderThanDays: number) {
"use step";
const before = new Date(Date.now() - olderThanDays * 86_400_000).toISOString();
return run(
Effect.map(
Lead.find({ where: { status: "new", updatedAt: { lt: before } }, limit: 100 }),
(leads) => leads.map((lead) => ({ id: lead.id })),
),
);
}
async function remind(leadId: string) {
"use step";
return run(Lead.actions.addNote({ lead: leadId, body: "No movement for three days." }));
}
export default workflow("remindStaleLeads", {
description: "Write a reminder note on every lead that has not moved.",
role: "reminders",
input: Schema.Struct({ olderThanDays: p.integer({ description: "Days without movement." }) }),
run: remindStaleLeads,
triggers: [
cron("weekdayMorning", "0 8 * * 1-5", {
description: "Every weekday at 08:00 UTC.",
input: { olderThanDays: 3 },
}),
],
});- Import what the file uses at its top level from
@segmentstream-cloud/ontology/workflows, which re-exportspfor the input, andSchemafromeffect. The Workflow SDK bundles a workflow file's top-level imports for a runtime without Node built-ins; this entry, and entity and action declarations, import none, while the package root does. Steps may use anything: the SDK strips their bodies from that bundle. workflow(name, { description, role, input?, run, triggers?, connections? })only records the declaration;checkand the evaluation ofindex.tsnever callrun, and the"use workflow"and"use step"directives are inert strings until the Workflow SDK's compiler builds the service (ontologyWorkflows()inontology/nitro.config.ts, below). Keep workflow files in the directories it compiles (./workflowsby default): arunthe SDK did not compile is logged asontology.workflow.not_compiledwhen the service starts, and starting it answers 503workflow_not_compiled.nameis camelCase and unique; it names the workflow's user.descriptionis required.roleis a declared role other thanmember, and not one that grants*:*. The runs hold what it andmembergrant.inputis aSchema.Struct; omitted means{}.runreceives the decoded input in TypeScript names, and as its second argument{ startedBy }: the trigger, the member, or the platform (with the person it acted for) that started the run.triggerslists what starts the workflow on its own. Each carries akind:cron,actionorwebhook.cron(name, expression, { description, input? })takes five fields in UTC, each*, numbers, ranges, steps after*or a range (*/5,0-30/10; not5/15) or lists, with at most one of day-of-month and day-of-week restricted and no month or day names, as Vercel accepts them. Itsinputis written in TypeScript names and must decode with the workflow'sinput. Trigger names are camelCase,<workflow>.<trigger>on the wire (remind_stale_leads.weekday_morning).after(name, target, { description, when?, input? })starts the workflow after an action commits:targetis an action handle (Lead.actions.changeStatus) or an entity handle (Lead, any of its actions).whenandinputare synchronous functions of the event ({ action, actionId, at, principal, input, result, edits }) and the record ({ record, before, after }, typed as the entity's records);inputomitted means{}.changed(field, { from?, to? })is awhenfor the common case. A workflow reacts to an action, not to a field. The manifest records the kind, name, description, entity and action, never the functions. At most 20 action triggers per ontology;checkreportsinvalid-trigger,trigger_action_unknown,trigger_function_asyncandworkflow_trigger_limit_exceeded.webhook(name, { description, signature?, headers? })starts the workflow for each delivery a provider pushes to/api/webhooks/<name>, whose wire name is unique across the ontology. The workflow'sinputiswebhookDelivery(body,contentType,query,headers,receivedAt) and it has only webhook triggers. Withoutsignature, the URL ends in a token derived from the workspace's webhook secret; with{ scheme: "stripe", secret }, each delivery's signature is checked against that workspace secret through SegmentStream.headerslists up to 10 request headers the delivery carries. The route answers200once the run started,404,401,413or415to refused deliveries and503when the run could not start. Production only.checkreports theworkflow_webhook_*codes; at most 20 webhooks per ontology.connectionslists the connections the steps call directly; each must be indefineOntology({ connections }). Connections declared on an action stay that action's.- At most 50 workflows.
checkreportsworkflow_role_unknown,workflow_role_is_member,workflow_role_grants_nothing(a warning: with member's grants, the role grants no action),workflow_role_wildcard(the role grants*:*),workflow_input_invalid,workflow_cron_invalid,workflow_cron_never_fires(a warning: no listed month has the day),workflow_run_missing(runis not a function),invalid-workflow,invalid-property(a property option its constructor does not take), and the usual name, duplicate and description checks.
Code inside a step
Inside a "use step" function, run(effect) runs an Effect with the ontology's runtime as its
services, bound to the run's workflow user. Entity handles work as in an action handler:
Lead.find and Lead.get read, Lead.create, update, upsertBy, delete, link and
unlink run the generated actions, and Lead.actions.addNote(input) runs the authored action
with its input in TypeScript names; so do the workflow's connections. Every read and action goes
through the same path as the HTTP routes: it is checked against the workflow user's permissions,
validated, and recorded in history under the workflow user. Batch writes (createMany,
updateMany, upsertMany) are refused in steps; run an action that writes the batch. In production,
workflow roles can explicitly grant agent_chat:post_message and agent_chat:preview_message for
attributed chat delivery and its destination preview. Preview deployments refuse both actions;
agent tasks cannot post to chat on either target.
A step calls a model through Vercel AI Gateway with the AI SDK (ai), outside run: the
deployment authenticates with its Vercel project identity, so the workspace holds no key. Give
each model call a step of its own that returns its result, and store the result with an action
in the next step, since a retried step calls the model again. A workspace's spent Gateway budget
refuses the call with 402. The ontology SDK does not wrap these calls.
run resolves with the effect's value, which the step returns, so keep it JSON-serializable.
A failure rejects: a typed failure of the handles (NotFound, Conflict, ActionRejected,
PermissionDenied, ConnectionError) becomes an Error named after it with its message and the
request id; anything else becomes a generic message with the request id, and the service logs
the full cause as ontology.workflow.step.failed. The Workflow SDK records the failed step and
retries it, except a refusal that would repeat (PermissionDenied, ActionRejected, an invalid
input), which is marked fatal. A read the role does not grant fails the step. Calling run in
the "use workflow" body or outside a run throws WorkflowStepRequired. run reads the
step from the Workflow SDK's process-wide step context and never imports the SDK, which a
deployed function need not be able to resolve.
A retried step runs its actions again: the SDK has no idempotency for actions, so write steps
whose actions tolerate a repeat (an upsertBy, a check before a write), as the example's
submitLead does with its submission id.
The binding: when the ontology service builds its routes it installs, on globalThis, the
runtime that serves this process's runs, with the services the routes run with; the step
callback builds the service first when this instance has not. Inside a step, run reads the run
from the Workflow SDK's step context (getWorkflowMetadata()), finds the declared workflow whose
compiled run has the message's workflow id, and checks it against the run's own record in the
world (runs.get(runId).workflowName, read once per run), refusing a step whose workflow is not
its run's; a run the world does not show yet fails the step, which is retried. It then resolves
the workflow's user and provides the runtime bound to it. No token is
involved; connection calls carry the callback request's Vercel OIDC token
(x-vercel-oidc-token), else the deployment's.
Agent tasks
A workflow hands the workspace's agent a prompt with agentTask from
@segmentstream-cloud/ontology/workflows, in its "use workflow" body, and waits for the
agent's final answer without holding compute. It is the agent members reach in chats, with the
same instructions, models, tools and sandbox, acting as the workflow's own user: it sees and does
exactly what the workflow's role and member grant, and ontology actions run without
confirmation, since no person is present. Keep the role narrow: a prompt may carry untrusted text.
import { agentTask, workflow } from "@segmentstream-cloud/ontology/workflows";
async function draftReply(input: { message: string }) {
"use workflow";
const prompt = await describeMessage(input.message); // a step that reads records
const result = await agentTask({ prompt, timeoutMs: 15 * 60_000 });
if (result.status === "completed") {
await saveDraft(input.message, result.text); // a step that runs an action
}
}promptis 1 to 32,768 characters, all of it the workspace's: nothing is added to it.timeoutMsdefaults to 900,000 (15 minutes) and is 60,000 to 3,600,000. Invalid options fail the workflow withAgentTaskOptionsInvalid, which names the option, and a call outside a"use workflow"body (in a step, or outside a workflow) withAgentTaskWorkflowRequired; neither is retried.- It resolves with
{ status: "completed", text, truncated }, the agent's final text;{ status: "needs_input", text, truncated }, its question, when the agent would need a person;{ status: "failed", reason }withagent_unavailable(the deployment has no agent, its agent predates tasks, or the run is outside production),busy(the workspace has 10 live tasks; the agent was not called) oragent_failed(its turn failed, its cost cap included); or{ status: "timed_out" }. Text beyond 65,536 characters is cut and markedtruncated. - A task cannot post to chat in this release:
agent_chat.post_messageis refused for a task whatever the role grants. The answer reaches people through the workflow's later steps. - At least once: a start step retried after the agent accepted the task hands it over again, and the agent ends the earlier session. Write prompts and later steps that tolerate a repeat.
- Tasks run only in production, where the agent's runtime tokens are valid. Locally and in
previews
agentTaskresolves withagent_unavailable.
agentTask creates a Workflow SDK hook and runs its start step, one of two steps the package
ships; the Workflow SDK finds and compiles them through @segmentstream-cloud/ontology/workflows
with the workflow's own. The step records the task in _agent_tasks under the hook's token,
mints its secret (32 random bytes, base64url, of which only the SHA-256 is stored; it is never
returned, logged or written to the run's event log) and posts { task_id, secret } to the
agent's POST /eve/segmentstream/v1/tasks at https://$VERCEL_PROJECT_PRODUCTION_URL, with the
service's own Vercel OIDC token. 202 starts the task and 404 ends it as agent_unavailable;
no answer, 5xx, and a 409 while the task is still pending fail the step, and the retry hands
the pending task over with a new secret. A retry finds a started or ended task and does not hand
it over again; any other answer ends the task as agent_unavailable and fails the step without a
retry. The workflow then races the hook against a sleep until the task's deadline, timeoutMs
after the start created it, so start-step retries count against the timeout. The agent's report through the task routes (see Agents)
ends the task, the first report winning, and resumes the hook; if the sleep wins, the second step
times the task out unless it already ended and returns its outcome, so the workflow's result is
always the task's stored outcome. Each start deletes the tasks whose deadline passed 30 days ago.
Runtime and routes
ontology/nitro.config.ts lists ontologyWorkflows() from @segmentstream-cloud/ontology/nitro
in modules, in place of the SDK's own workflow/nitro:
import { ontologyRoutes, ontologyWorkflows } from "@segmentstream-cloud/ontology/nitro";
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
compatibilityDate: "2026-09-11",
serverDir: false,
routes: ontologyRoutes("./server/handler.ts"),
plugins: ["./server/plugin.ts"],
modules: [ontologyWorkflows({ dirs: ["./workflows"] })],
});It adds the SDK's transform to the server build and, before each build, bundles the workflows of
dirs and their steps into Nitro's build directory, with /api/ontology as the SDK's base path
and ontology as its queue namespace, both written into the generated entrypoint at build time
(no environment variable). It mounts one route in the ontology service's own server bundle,
/api/ontology/.well-known/workflow/v1/flow: since Workflow SDK 5 the flow handler runs the
steps itself, and background steps come back on the workflow topic. The route answers only a
POST queue delivery of the ontology's queue (ce-vqsqueuename, or the local world's
x-vqs-queue-name, starting with __ontology_wkf_workflow_) at its exact path, a trailing slash
refused; anything else is a 404. Before the first step of an instance it builds that instance's
ontology service through Nitro's own fetch of /api/ontology/status; a failure fails the delivery
for the queue to retry. On Vercel the route gets its own function with maxDuration: "max" and
the queue trigger __ontology_wkf_workflow_*; runs start on the same queue (start with the
namespace ontology). The app's own workflows use the SDK's default topics and the agent's use
topics of its own, so none of them receive another's messages. Nitro states that routes with queue
triggers are not reachable from the web; that is still to be confirmed on a deploy. If they are
reachable, the queue-header and run-record checks still refuse a request that is not the
ontology's own delivery.
Webhooks are not supported yet: no webhook route is mounted, since this version of the SDK derives
a webhook's token from the run. Hooks resumed from server code with resumeHook work, as agent
tasks' do.
server/plugin.ts calls installWorkflowRuntime() from @segmentstream-cloud/ontology/server
at startup: until the flow handler loads and sets the base path from the build, it sets it to
/api/ontology, so that runs started first call back there, and it registers the Workflow SDK,
loaded on first use, for the workflow routes. It also sets WORKFLOW_QUEUE_NAMESPACE to
ontology in the service's process, which serves only the ontology's queue: resumeHook takes
no namespace and enqueues the resumed run on the one this variable names. Only that entry imports the Workflow SDK: the package
root does not, so a process that bundles the service on another Workflow SDK major carries none of
it, and neither does @segmentstream-cloud/ontology/workflows, which the Workflow SDK bundles with
every workflow file.
| Route | Who | What |
| --------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
| GET /api/ontology/workflows/:workflow/triggers/:trigger | Vercel cron, Authorization: Bearer $CRON_SECRET | Starts the workflow with the trigger's input; 202 { runId }. |
| POST /api/ontology/workflows/:workflow/start | Holders of workflow:<workflow>:start; the platform | Starts it with { "input": … } in wire names; 202 { runId }. |
| GET /api/ontology/workflows | Any caller; each sees the workflows it may read | The declarations, each trigger with its path and nextAt. |
| GET /api/ontology/workflows/:workflow/runs | Holders of workflow:<workflow>:read, the platform | The Workflow SDK's runs, newest first; ?limit and ?after. |
| GET /api/ontology/workflows/:workflow/runs/:runId | As above | One run with its steps. |
CRON_SECRET is read from the service's environment; without it, the trigger routes refuse every
request. Workflow permissions, workflow:<workflow>:start and workflow:<workflow>:read
(workflow:*:read, workflow:<workflow>:*), are granted by roles like action permissions; only
a permission that starts with workflow: grants them, so *:* and *:read do not. The
platform authenticates with a workload token whose subject is platform:<workspace id>; it is
admitted to these routes only, and its start requests may name startedBy, which the run
receives. The deployment's runtime callers hold what the member they act for holds, and never
start runs. Vercel may deliver a cron invocation more than once, and start does not deduplicate:
a trigger's workflow tolerates a repeated run. A body that is not JSON answers 400.
Every route takes the workflow's TypeScript name or its wire name, remindStaleLeads or
remind_stale_leads, and so does the trigger segment; the start body's input is in wire names, as
an action's input is.
Pages take ?limit (1–100, default 20) and ?after, the previous page's nextCursor, which is
null on the last page. Success bodies use camelCase; errors are the service's usual
{ error, detail, request_id }. tests/fixtures/ontology-workflow-service.json at the repository
root pins every body, for the CLI and backend-next.
Run records come from the Workflow SDK's world (runs.list, runs.get, steps.list) with
status, times and error. The world takes pages of at most 100: runs pages are capped there, and a
run's detail follows the steps' cursor up to 1,000 steps, with stepsTruncated: true beyond. When
the world cannot list a run's steps, the detail answers the run with steps: null and the service
logs ontology.workflow.steps.failed with the cause; inputs and outputs are not returned, since the world stores them
serialized.
Workflow users
Each workflow runs as its own keyless service user: name = the workflow's wire name,
identity_provider segmentstream-ontology-workflow, subject
workflows:<workspace id>:<workflow name>, roles = exactly the declared role, no API key. The
migration that applies the ontology creates it: applyMigration given the workspace's
workspaceId runs reconcileWorkflowUsers in the transaction that records the manifest, under
the migration lock, also on a no-op plan, and segmentstream-ontology migrate does the same when
SEGMENTSTREAM_WORKSPACE_ID is set. It deactivates the users of removed workflows and
reactivates redeclared ones. A run before the deploy creates a missing user
(resolveWorkflowUser), but a run never changes roles: the deployed database is the authority,
and a user whose roles are not exactly the declared role is refused (role_mismatch) until a
deploy reconciles it. Runs that are in flight on an older deployment when a deploy changes the
workflow's role fail fatally at their next step, since their deployment declares the old role. The
user never holds member as a role; every action re-reads its roles in
the action's transaction and refuses any assignment other than the declared role.
Batch writes in actions
Stored entity handles expose createMany(records, options?),
updateMany([{ id, values }], options?), and upsertMany({ on, records, returning? })
inside authored action handlers. The action remains the permission and commit
boundary; a batch does not run other authored actions for its rows.
For example, an action can write sales plan lines by a declared compound unique key:
const PlanLine = entity("planLine", {
id: "ent_plan_line",
label: "Plan line",
plural: "Plan lines",
title: "planCode",
description: "A sales plan decision for a period.",
properties: {
planCode: p.text({ description: "The plan code." }),
period: p.date({ description: "The period start." }),
target: p.number({ description: "The agreed target." }),
},
indexes: [{ properties: ["planCode", "period"], unique: true }],
}).actions((self) => ({
setTargets: action({
description: "Set targets for several plan lines together.",
input: Schema.Struct({
lines: Schema.Array(Schema.Struct(self.properties)),
}),
handler: ({ input }) =>
self.upsertMany({
on: ["planCode", "period"],
records: input.lines,
returning: "summary",
}),
}),
}));Both result modes return { created, updated, unchanged }, whose counts sum to
the input length. The default, returning: "records", also returns items in
input order: { index, status, record }, with status equal to "created",
"updated", or "unchanged". returning: "summary" omits items; the enclosing
action still returns its normal edits. These types are exported as
BatchRecordResult<Record>, BatchSummaryResult, and BatchResult<Record>.
Creates retain normal defaults and validation. Updates patch distinct IDs:
omitted fields remain unchanged, and explicit null follows the property's
nullability. An unchanged patch preserves timestamps and attribution and adds no
history or replication work. Upsert keys must be a nonempty list of ordinary
properties matching a declared unique property or compound unique index. Each
input explicitly supplies non-null key values and enough values to create a
record; existing records receive only supplied non-key fields. Duplicate IDs or
upsert keys fail the batch.
Batch values support ordinary properties and stored rel.one assignments. Blob
and rel.many assignments are rejected; use the existing single-record methods
for those fields. Warehouse-backed and platform-owned built-in entities are not
batch write targets. OntologyWrites exposes the same operations with an
explicit entity and snake_case field names; entity handles convert authored
camelCase names and select values.
Each call accepts at most 200 records and 2 MiB of UTF-8 JSON input, including
record values, update IDs, and upsert key fields. One action may attempt at most
500 rows across nonempty batch calls, including failed calls its handler catches.
Empty input returns zero counts and, in record mode, empty items, without SQL.
Every nonempty batch uses one savepoint inside the action transaction. A failure
rolls back all of that batch's records, history, replication references, and
edits; a handler may catch it and continue. A later uncaught action failure also
rolls back earlier successful batches.
Existing ActionRejected, Conflict, and NotFound errors may include
batch: { indices, fields? }: zero-based input positions and safe wire field
names. Batch calls are never automatically retried. Checkpoint a loader only
after confirmed action success; replay after a lost response is safe only when
the complete action is explicitly idempotent, including its other writes and
side effects. Generic upsert alone does not establish that guarantee.
Fresh aggregates in actions
Stored entity handles expose aggregate({ measures, groupBy?, where }) to
action handlers and prepare. It computes measures the entity declares, with their
warehouse meaning, over the records where matches, in the action's transaction, so
it counts the handler's own writes:
const totalsOf = (invoice: string) =>
Effect.gen(function* () {
const [totals] = yield* InvoiceLine.aggregate({
measures: ["net", "tax"],
where: { invoiceId: invoice },
});
return totals; // { net: "1501", tax: "300" }
});Rows hold the groupBy fields and each measure as a decimal string or null;
without groupBy there is exactly one row. where is find's query language and
must look records up through an index: eq or in on id, a unique property, a
rel.one key or the first field of a declared index. It aggregates every matching
record, with no row limit, so its cost follows the set the indexed condition
selects. Aggregates lock nothing: lock the record the decision depends on with
forUpdate. Balance
measures, model measures and workflow steps stay with the warehouse aggregate.
See fresh aggregates inside an action.
Permissions
me (also GET /api/ontology/me) includes authentication: { kind, client, scope }
and attribution. OAuth provenance carries the registered client's ID/name and the
grant's scope; its permissions remain restricted by that scope. Attribution uses
stored names through workspace-authored chat.attribution.template (default {name}), with
{client} naming the registered OAuth client (empty for runtime/service callers). Client names are
registration metadata, without a claim of brand ownership. Ordinary browser/app
members, member API keys and pipeline workloads receive null attribution and cannot use that
provenance to post. No bearer token or client secret appears in me.
Since 0.27.3, verified agent runtime calls cannot discover or execute agent
invocations, including the legacy agent_chat.send_message relay and reserved
agent_start/get/update/cancel operations. MCP, REST, discovery/catalog exports,
OpenAPI and attachment promotion apply the same policy. OAuth, service and ordinary
member callers retain their usual permission checks when runtime authentication is
configured. This preparation does not advertise or enable exact chat posting.
A permission is <entity>:<verb>. The verbs are read, the generated create, update,
delete, link and unlink, and the names of authored actions. A role may use * for the
entity, the verb or both: lead:*, *:read, *:*. An entity wildcard never covers the
built-in agent_chat, app_feedback or app_feedback_file: reading or changing them needs a
permission that names them, such as agent_chat:read.
- Reads require
<entity>:read: lists, queries, records, history, both entities of a relationship traversal, and every entity an aggregate touches.includeand a filter through a relationship requirereadon the related entity. A missing permission answers 403 with<permission> is required. - Each action requires one permission. The generated
upsert_by_<property>requires both<entity>:createand<entity>:update. - A principal's permissions are the union of its roles' permissions in the ontology.
Members hold
memberand their assigned roles; an API key holds exactly the roles it was created with; a workflow's runs hold the workflow's role and whatmembergrants. A role the ontology no longer declares grants nothing. - The catalog,
describe,graph,openapi.jsonandmeneed only authentication;status,sign-in/checkand thedocspage need none.?as=melists only the actions the caller may run. checkfails on a role without permissions, on a permission whose entity or verb does not exist, and onallow, which version 0.2 replaced. It warns on an action no role may run and on*:*.
Discover the ontology over MCP
/mcp uses stateless Streamable HTTP: POST returns JSON-RPC results, while authenticated
GET requests accepting text/event-stream receive an immediate SSE comment and 15-second
heartbeat comments. The GET stream carries no JSON-RPC messages or session ID. It closes
after 45 seconds with a reconnect hint; clients can reconnect independently to any instance.
Set mcpStreamMaxDurationMs in createOntologyService to change that lifetime, keeping it
below the deployment's function limit with room for startup and authentication. The generated
Vercel service enables Fluid and Nitro response streaming without overriding maxDuration;
the conservative default also fits a 60-second function limit. GET and POST share bearer
authentication and Origin rules. DELETE is unsupported because there are no sessions.
Since 0.27.0, agents use describe without arguments for a compact entity index and discover
for keyword, literal substring, or fuzzy metadata search. Inspect entity:lead,
action:lead.change_status, or measure:sales.revenue refs with describe for complete definitions.
Entity inspection includes all property metadata, relationships, permitted generated and authored
actions with their input contracts, and computable measures. Related entities stay references.
Index and search results carry hash and next_cursor; pass the cursor as after only when more
results are needed. Complete inspection is not paginated. An optional expected_hash detects a
changed deployment. Stored schema visibility does not grant permission to read records or run
operations.
Every MCP client receives the permitted operation tools directly through standard tools/list.
Large external catalogs are paginated: follow nextCursor by sending it as params.cursor until
it is absent. Each page contains at most 128 tools and normally fits within 500,000 UTF-8 bytes;
an individually larger tool gets its own page. Descriptions and input schemas are complete, and
annotations and trusted metadata are preserved. Optional output schemas are omitted.
The server no longer exposes ontology_search_tools, ontology_describe_tool, or
ontology_call_tool. Clients own tool search and call the selected original name with
tools/call. Refresh connector catalogs after upgrading, and remove mcp.toolListing from
workspace configuration. Existing discover and describe inspect domain entities, actions,
and measures; they are not substitutes for MCP tool discovery.
Each page rechecks permissions. Cursors belong to the current catalog and permission scope;
restart listing if either changes. Verified in-app runtimes receive the same direct operations
in one page because Eve 0.85 currently reads only the first listTools response. This preserves
running agents during the upgrade without exposing the retired helpers to either caller type.
The MCP ontology://describe resource is the first index page as JSON. ontology://catalog
remains an explicit full manifest export; REST descriptions and CLI catalog rendering retain
whole-catalog behavior. Custom prompts expecting the old full no-argument MCP description should
switch to progressive inspection or explicitly request the full export.
Serve the ontology
List and query endpoints accept select to avoid fetching unused base fields:
GET /api/ontology/lead?select=email,status or a query body with
{ "select": ["email", "status"] }. An explicit empty list returns ids only.
Selected pages include id and projected: true; omitted fields are absent.
include still adds complete related records under the same read permissions.
Filters, scopes and cursor ordering apply before projection and may use fields
not selected. Without select, the complete record response is unchanged.
Authored action reads (Entity.find, get) remain complete records.
import { configFromEnv, createOntologyService } from "@segmentstream-cloud/ontology";
import ontology from "./index";
const service = createOntologyService(ontology, configFromEnv());
// Give every request under /api/ontology to the service.
export function handleOntologyRequest(request: Request): Promise<Response> {
return service.handler(request);
}GET /api/ontology/docs serves a Scalar API reference over openapi.json. A browser signed
in to the app authenticates with the app's Neon Auth session cookie
(__Secure-neon-auth.session_token), which the service exchanges at Neon Auth for the
session's token and verifies like a bearer token. A bearer token always takes precedence. The
cookie authenticates a POST only when its Origin is the app's own.
handler(request) takes a web Request and returns a Response. dispose() closes the
database pool. configFromEnv() reads these variables (ENVIRONMENT_VARIABLES, and the
optional NEON_AUTH_ENVIRONMENT_VARIABLES). It throws OntologyServiceConfigError when
one is missing or invalid, and never repeats the database URL.
| Variable | Purpose |
| ------------------------------------- | -------------------------------------------------------------------------------------------- |
| SEGMENTSTREAM_ONTOLOGY_DATABASE_URL | Pooled connection string of the ontology_app role. |
| SEGMENTSTREAM_URL | SegmentStream origin. It issues and verifies app tokens, and receives aggregates over https. |
| SEGMENTSTREAM_APPLICATION_ID | App tokens must be issued for this application. |
| SEGMENTSTREAM_WORKSPACE_ID | The workspace of the app. |
| SEGMENTSTREAM_ORGANIZATION_ID | The organization of the workspace. |
| NEON_AUTH_BASE_URL | Optional. The workspace's Neon Auth URL; its origin issues users' tokens. |
| NEON_AUTH_JWKS_URL | Optional, set with the base URL. The keys that verify those tokens. |
| SEGMENTSTREAM_APP_CREDENTIAL | Optional. The workspace app credential; without it, aggregates answer 503. |
| SEGMENTSTREAM_RUNTIME_OIDC_ISSUER | Optional. The team's Vercel OIDC issuer, for the app's agent. |
| SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE | Optional, set with the issuer. The audience of the team's tokens. |
The platform sets these variables in the workspace deployment, the Neon Auth pair once the
workspace has Neon Auth and the runtime pair on deployments with an agent. With the runtime
pair, Vercel's VERCEL_PROJECT_ID must be set too.
App preview deployments
A preview requires the emitted workspace-app-previews-v1 runtime capability and
workspace-app-preview-workflows-v1 for workflow execution. The platform also
probes the installed SDK's fixed capability profile and expiry checks on the locked dependencies
before app code is exposed; preview support is never inferred from an unpublished release number.
configFromEnv() reads SEGMENTSTREAM_DEPLOYMENT_TARGET (production when omitted),
SEGMENTSTREAM_ONTOLOGY_DEPLOYMENT_ID and SEGMENTSTREAM_PREVIEW_EXPIRES_AT. Explicit previews
require a deployment UUID, a valid ISO deadline and child Neon Auth bindings. The returned
deployment observation and /api/ontology/status report the deployment identity, expiry and
capabilities. Preview admission uses the fixed preview profile even if a custom configuration
supplies broader capability values. SDK failures use DeploymentAdmissionError.code; workspace
code owns their presentation.
Preview services accept child Neon tokens and email sessions, checking the exact child issuer
even when keys were copied from production. Legacy app and production workload tokens, opaque
API keys, OAuth/channel sessions and Cloud federation are refused. Ontology CRUD, files and
manual workflow starts retain their normal user permission checks. Action/change triggers and
inbound webhooks can start workflows; ontologyWorkflows() compiles and mounts their queue
handler. Trusted workspace code can make external network requests and cause real external
side effects. Managed connections, after-commit hooks, agents (including workflow chat delivery
and agent tasks), pipelines and replication remain unavailable. Scheduled business cron triggers
are never registered, and direct calls to their trigger routes are refused even with a cron secret.
Workflow execution requires the runtime VERCEL_DEPLOYMENT_ID system variable. New preview runs
carry immutable segmentstream.deploymentTarget and segmentstream.deploymentId attributes;
run reads, steps, hook resumes and decoded queue callbacks require that exact app deployment UUID
and the Workflow SDK's matching provider deployment ID. Missing or copied production provenance
is refused. Production cannot read or resume preview runs. A stable alias is not run ownership.
Every admission checks the deadline, including cached sessions, warm callbacks and workflow steps.
Work already admitted can finish; preview expiry does not isolate or cancel arbitrary workspace code.
Platform workflow starts, reads and recovery use short-lived platform:<workspace id> workload
tokens with environment=preview, both deployment IDs equal to the exact child UUID, the request's
immutable candidate origin, and a token expiry no later than the preview deadline. These tokens
grant no other ontology operations. Recovery calls POST /api/ontology/workflows/sweep with that
token to retry persisted _workflow_starts; preview GET sweeps and cron-secret admission are refused.
The platform must schedule this recovery separately without registering workspace business crons.
Each preview gets a fresh SEGMENTSTREAM_WEBHOOK_SECRET for unsigned URL tokens, never the
production value. Signed webhooks and Meta registration retain their declared validation and use
the backend's existing verification endpoints. Only those calls carry the child app credential as
x-segmentstream-preview-credential plus x-segmentstream-ontology-deployment-id, runtime Vercel
OIDC, organization and request headers. The backend verifies the credential's exact live deployment
and the deployed secret declaration; named signing secret values are never injected into the preview.
Before exposing a verified child database, the provisioner runs:
import { preparePreviewDatabase } from "@segmentstream-cloud/ontology";
yield * preparePreviewDatabase({ sql });This transactional, repeatable preparation clears SDK-owned API keys, OAuth/federation
sessions and tokens, agent pairings/identities/confirmations, workflow starts/tasks, upload
sessions, file action replay results and replication delivery state. It disables copied
replication while preserving users, roles, persistent member blocks, channel policy, business records, histories and retained file
revisions. Older databases with fewer SDK tables are supported. It never changes neon_auth
or any provider-owned schema: child Auth invalidation, origins, email delivery and secrets
remain the provisioner's responsibility. Call it only on a verified, unexposed child, before new
preview workflows can start; never repeat preparation on a serving child or recovery would lose
that child's pending starts. Copied production starts and tasks must be cleared before exposure.
The preview SEGMENTSTREAM_ONTOLOGY_STORAGE binding additionally requires deploymentId
matching this deployment and the provisioner's verified productionBranchId, which must differ
from branchId. Endpoint validation still binds storage to that child branch. The provisioner
must verify that DB, Auth and storage all belong to the same child and issue child-only
credentials; SDK self-consistency checks cannot prove provider ownership. The runtime rebinds
copied file revision metadata to that child's provider and bounds signed transfer grants by
the preview deadline. Production storage bindings need neither new field.
Authentication
Every route but status and sign-in/check takes Authorization: Bearer …:
- Neon Auth token. The app's users sign in with the workspace's Neon Auth (Managed
Better Auth). The service verifies their JWT: EdDSA, issued by the origin of
NEON_AUTH_BASE_URL, against the keys atNEON_AUTH_JWKS_URL. The token names the user (sub,email,name) and carries no roles. The email counts only when Neon Auth verified it. - SegmentStream app token. Still accepted while workspaces move to Neon Auth. The service picks the verifier by the token's issuer; a token from any other issuer is a 401. A verified app-token email also resolves an existing Neon Auth member, without replacing the member's Neon identity. The service reads that member's current roles and active state.
- API key.
ssk_…, held by a service user. - Workload token. Issued by SegmentStream for production operations and the exact preview
workflow operations described above. Its subject
decides who it is:
pipeline-workers:<workspace id>is the pipeline worker;platform:<workspace id>is SegmentStream acting for the console and the CLI, admitted only to the workflow routes and refused everywhere else. Any other subject is a 401; a workload token never resolves as a member or the agent.
Sign-in finds the user linked to the token's provider and subject, else links the member
invited with the same email, else follows the ontology's signIn policy: invited answers
403 { "error": "not_invited", "detail": "Ask your administrator for an invitation." },
open creates a member. Deactivated members are refused. Each sign-in sets the user's
last_sign_in_at, at most once per 15 minutes, so the user's history records it.
Members without an email can be managed by their user UUID in the console and CLI:
ontology member grant|revoke|deactivate|reactivate MEMBER. Find the UUID with
ontology member list. The platform's POST …/ontology/members/roles accepts
either email (including new invitations) or memberId (existing members only).
The SDK's updateMemberRoles(ontology, { id, grant, revoke }) uses the same role
writer as email updates. Deactivation preserves the member's roles and identities.
GET /api/ontology/me answers who the caller is, for the app to render what they may do:
{ user: { id, name, email, kind, active }, roles, permissions, authentication, attribution }. It needs only
authentication; services get email: null.
POST /api/ontology/sign-in/check with { "email": "…" } answers, before the app emails
anyone, whether the address may sign in and how. Under invited, an active member who has
signed in with Neon Auth gets { "allowed": true, "method": "code" }, an active member who
has not gets method: "link" (Neon Auth sends codes only to existing accounts; a sign-in
link creates the account), and anyone else, deactivated members included, gets
{ "allowed": false }. Under open, every address gets link. It reads nothing else and
changes nothing. A malformed address answers 400. Each service instance allows a client
address (x-real-ip, else the first x-forwarded-for hop) ten checks a minute, then
answers 429 rate_limited with retry-after. checkSignIn(ontology, email) is the same
lookup for code that holds a SQL client.
App feedback
Text feedback is enabled when feedback is omitted. Every authenticated human member can
submit, read
