One report row. A user files it. So does the agent.
Two feature requests describe the same row from opposite ends. A human wants to report a problem and quote a reference; an agent that hit a defect mid-run wants to file it and not file the same one twice. Rather than two attribution systems that drift, there is one record, one public projection, and one trust boundary: everything a caller could forge — who reported it, whether it is readable, what run it belongs to — is derived from an identity the request body cannot reach. Wire nothing and your swarm is byte-identical: the form, the routes, and the capture plane are all opt-in.
Signed-in only, on purpose. The intake surface ships for authenticated callers. Public/anonymous submission — and its single-org opt-in — is deferred: an anonymous submitter has no org to belong to (every table is org_id NOT NULL, every handler authenticates), and it carries a genuinely different threat model. The full record shape below keeps the fields the public path will need (source, visibility, a reserved anonId) so adopting it later is not a migration of live rows.
The form a user touches
<IssueIntake> is an end-user surface, not a studio section — the studio is the operator console, and a support form must never be a tab inside the shell that also holds the agent editor. It submits a report, then reads the caller's own reports back with the operator's visible replies threaded under each one.
"use client";
import { IssueIntake } from "@nightowlsdev/react";
// Mount it anywhere a user already is: a settings page, a help modal, a footer link. It assumes NO
// <SwarmProvider>, no chat stylesheet, no studio shell — it themes off the --owl-* custom properties
// when they exist and renders correctly when they do not.
export function SupportPanel({ token }: { token: string }) {
return (
<IssueIntake
base="/api/swarm/issues"
// Best-effort: the routes are authorized server-side regardless, so a mint hiccup falls back to cookie auth.
getToken={() => token}
// ⚠ REQUIRED, host-authored, rendered VERBATIM beside the submit button. Say who reads these reports,
// how long they are kept, and whether they leave this deployment. A BLANK string WITHHOLDS the form —
// the framework cannot write this sentence for you, so it refuses to collect free text without it.
collectionNotice="Reports are read by our team, kept for 90 days, and never leave this workspace."
onFiled={(reference) => console.log("filed as", reference)}
/>
);
}The collection notice is required, and the form refuses to render without it. collectionNotice is a host-authored string with no default. A blank one withholds the submit form (the "your reports" list still renders — reading your own rows collects nothing). The framework cannot know what this deployment does with a free-text report — who reads it, how long it is kept, whether it leaves the tenant — so it cannot write that sentence, and a form that collects free text while promising nothing is exactly the failure this requirement exists to prevent.
It never truncates the description to the cap. The server rejects an over-cap draft with a sentence that says by how much; the counter turns into a warning and the button stays live. Silently trimming in the browser would file a report missing the half that said what happened.
What a report is
The submitter-facing shape (PublicReport) is built by one function, toPublicReport, and nothing else. It is not an Omit of the internal row: the wire shape genuinely differs, and every field a submitter can see is derived from an actor union they never see.
| Field | What it is | Set by |
|---|---|---|
| reference | the quotable handle, NO-XXXXXXXXXX. Derived from a UUID so it is not enumerable, uppercased so it survives a phone call | server-minted |
| source | user · agent · operator — who reported it | derived from the actor, never a body field |
| submitter | { userId?, displayName? } — a human's own id, or an agent's slug as the display name | derived from the actor |
| visibility | submitter vs internal. Only a human's own report is readable back; an agent-filed row is internal even when it names onBehalfOfUserId | derived: user ⇒ submitter |
| status | the wire vocabulary: new · acknowledged · investigating · resolved · wont-fix · duplicate-of. A draft has no public rendering and never crosses | mapped from the internal lifecycle |
| duplicateOf | when set, overrides the wire status to duplicate-of whatever the stored status says — the only honest answer to a submitter | triage-set |
| occurrences | how many times this same failure has been recorded, from the grouping fingerprint | store-maintained, atomically |
Several internal fields must never reach a submitter — the fingerprint (invertible, so a probe oracle), the confidence (reads as an accusation), the trusted origin (run and thread ids that identify other people's conversations). The repo has no field-visibility mechanism, so the control is a closed key set plus a per-route runtime belt, assertPublicReportShape, that every route walks over anything it is about to serialize.
The routes, and the trust boundary they own
Three route files, one accessor. POST /issues files for the authenticated caller and nobody else, GET /issues/mine is owner-scoped, and GET /issues/[ref] serves the owner a public projection and an operator holding the issues admin scope the internal row.
// app/api/swarm/issues/route.ts — the submit handler. `mine` and `[ref]/detail` are the same shape.
import { runner } from "@/lib/runner";
export const { POST } = runner.issueIntakeRoutes().submit;
// app/api/swarm/issues/mine/route.ts -> export const { GET } = runner.issueIntakeRoutes().mine;
// app/api/swarm/issues/[ref]/route.ts -> export const { GET } = runner.issueIntakeRoutes().detail;The plane is host-assembled — the report table is host-owned, so there is no shipped storage adapter to reach for. Two fields carry the whole safety story: sink (an IssueSink) and a rateLimitStore with no default.
// lib/runner.ts — the issue plane is HOST-assembled, like skillPlane/connectionsPlane. The report row is
// host-owned (there is no shipped storage adapter for it), so you bring the sink.
import { createInMemoryIssueSink, createInMemoryRateLimitStore } from "@nightowlsdev/core";
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
export const runner = createNextjsRunner({
// ...engine, auth, and the other planes you already pass...
issuePlane: {
// Wire your OWN IssueSink in production; createInMemoryIssueSink() implements every facet and is the
// shape a conformance-tested adapter (verifyIssueSink) mirrors.
sink: createInMemoryIssueSink(),
// ⚠ REQUIRED, no default. On a serverless deployment an in-memory Map is one window PER WARM LAMBDA, so
// the cap you configured is not the cap you get. Pass a Redis/Postgres-backed store; pass the in-memory
// one EXPLICITLY only if this deployment really is one process.
rateLimitStore: createInMemoryRateLimitStore(),
// Stamped onto every intake submission as its first-seen release. null => this host does not track releases.
release: process.env.RELEASE ?? null,
},
});Nothing on the row is read from the body. The only fields the route reads out of a submit are title, description, severity and attachments. reporter is hard-coded to { kind: "user", userId: authCtx.userId }, so source, visibility and status all derive from an identity the body cannot reach; release, origin (always null — a route submission has no run) and confidence are server-stamped. A body carrying source: "operator" or status: "resolved" changes nothing.
Every miss on the detail route returns the same 404 — an unknown reference, someone else's, a draft, a frozen row, all one body. A route that said "that reference is not yours" would confirm the reference exists, and references are the handle a submitter quotes over the phone; an oracle over them enumerates the tenant's whole issue list one guess at a time. All three routes are rate-limited per (tenantId, userId), and the whole family throws at construction when the sink lacks a store or the shared limiter is absent.
The other end: an agent files its own bugs
The same row is written automatically. Wire a bugCapture plane and defineSwarm registers two agent tools — report_issue and check_issue — and a per-run reducer that turns the event stream into reports without ever stalling it.
// lib/swarm.ts — the automatic capture plane. Thread ONE handle everywhere it is needed.
import { createBugCapture, createInMemoryIssueSink, defineSwarm } from "@nightowlsdev/core";
import { agents, cost, modelFactory, models, storage } from "@/lib/swarm-config";
// Call createBugCapture ONCE and pass the SAME handle to defineSwarm, any background runner and the reaper.
// Two planes in one deployment split every report across unrelated sinks — dedupe never joins them.
export const bugCapture = createBugCapture({
sink: createInMemoryIssueSink(),
// Optional pepper (D8-8): default OFF. Enabling it FORFEITS cross-deployment grouping. Validated eagerly here.
// pepper: process.env.BUG_CAPTURE_PEPPER,
});
export const { engine } = defineSwarm({
storage, agents, models, modelFactory, cost,
// Registers the two agent tools (report_issue / check_issue) AND the per-run capture reducer.
bugCapture,
});A model supplies a title and a description and nothing else: the tenant, the user, the run, the agent slug and the whole origin bag are read from the run context and stamped server-side. An agent's report is filed as a draft (confidence: "likely"), because an agent reporting a bug is making an inference through a channel it controls the text of; the framework auto-files only what it observed itself with a complete evidence set. A per-run cap — enforced in the store from trusted { tenantId, runId } metadata, not from anything the model can invent — stops an agent in a hot failure loop from out-spamming every human on the intake surface.
Grouping, and redaction on the way in
"Have we seen this before" is answered by a fingerprint: bugFingerprint scrubs the error, normalizes away the parts that vary between two occurrences of the same defect (ids, timestamps, numbers, URL paths), and hashes the result. It returns both halves from one call, so the string that entered the digest and the normalized string persisted beside it can never diverge.
import { bugFingerprint } from "@nightowlsdev/core";
// Scrub -> normalize -> hash, returning BOTH halves from ONE call so the string that entered the digest and
// the string you persist as `normalized` can never diverge (D8 condition 4).
const { normalized, fingerprint } = await bugFingerprint({
message: "ETIMEDOUT connecting to https://api.example.com/v3/users/8f3c-... after 30000ms",
toolName: "http_get",
code: "network_timeout",
agentSlug: "researcher",
});
// normalized: "ETIMEDOUT connecting to <url:api.example.com> after <n>ms" — ids, timestamps, numbers collapsed
// fingerprint: "v1:<64 hex>" (or "v1k:<64 hex>" with a pepper). INTERNAL-ONLY — the tuple space is invertible,
// so a digest handed to a submitter is a confirmation oracle against every other report in the tenant.The order is the security property: the digest is taken over the scrubbed string, so a bearer token in an error message never reaches the hash input. The digest is a grouping handle and nothing else — not a security token, not a retry key — and it is internal-only, because the tuple space is small and genuinely invertible.
Free text is redacted on the way in. FR-050's redaction runs over machine-assembled snapshots; a human's free-text field needs it oningress. assembleReport scrubs the title and description through the redaction policy and records the policy's version on the row, so a later fix to the pattern set can identify which stored strings came from the weaker pass — an exported archive is a copy nothing can revoke. An operator's reply is scrubbed too: one policy, both directions.
API reference
- <IssueIntake>, createIssuesClient, and the IssueSubmitOutcome union (filed / rejected / rateLimited / failed — the first three are answers, not errors), all from @nightowlsdev/react.
- runner.issueIntakeRoutes() returns { submit: { POST }, mine: { GET }, detail: { GET } }; runner.issueTriageRoutes() is the admin half (the five mutations behind adminRouteGate("issues")), and runner.issueExportRoute() the audited archive export. Each throws at construction when its required facet is missing.
- toPublicReport / toPublicReportList / assertPublicReportShape: the one projection, the list form (drafts dropped), and the per-route runtime belt.
- assembleReport, ReportActor, InternalReport, PublicReport, ReportDraft — the record, its actor model, and the server-owned assembler, all from @nightowlsdev/core.
- createBugCapture, bugFingerprint, IssueSink (facets: store and health required; triage / delivery / audit optional and absence-refusing), createInMemoryIssueSink, and verifyIssueSink for a host adapter.
- reportIssue / checkIssue and ISSUE_TOOL_NAMES: the engine-neutral half of the two agent tools the native engines register from.
Deferred, on purpose. Public/anonymous intake — a host seam that resolves a tenant for an unauthenticated request, plus the stricter abuse controls it needs — is not in v1. The anonId field is reserved and never populated; submitter.userId is always a real, authenticated principal today.
Where to go next
Attachments
A report can carry a screenshot. Refs cross the wire, never bytes — and the intake route freezes who owns each one at the moment it is accepted.
Approval modes
Why report_issue sits at danger 2 and needs approval — and what that approval does and does not ratify.
@nightowlsdev/core
The record, the actor model, the projection belt, and the capture plane the native engines register their tools from.
Building on Night Owls? See the source on GitHub.