ADR 0009 — System One (sys1): provider-agnostic decision tool, exposed to services through a tracked workflow
- Status: Accepted — implemented (2026-10-05) on
sys1; see Implementation notes at the end. Live verification against a real Ollama ≥ 0.35 (plan step 6) is still open. - Date: 2026-10-05
- Target branch:
sys1 - Related: ADR 0004 (agent/workflow/tool RBAC layer — this ADR adds a policy and corrects two of its claims, see Decision 6), ADR 0008 (layout and "schema is the agent's manual" precedent),
src/mastra/workflows/challenge/challenge-ingestion-workflow.ts(existing service-triggered workflow pattern),src/utils/providers/model-factory.ts(existing chat-model provider switch — deliberately not reused, see Decision 1),src/utils/providers/ollama.ts
The requester added a requirement: services must trigger System One through a Mastra workflow, not the tool, because a workflow run is persisted (input, every step's input/output/timing/error, final result, caller identity) and the tool route records nothing. Mastra's run model and behaviour were read from the installed version's embedded docs (@mastra/core 1.74.0 dist/docs, per the repo's mastra skill) and verified with local probes (Context → Workflow runs).
- New Decision 7 — workflow
system-one(validate → evaluate), the only service entry point:POST /v6/ai/workflows/system-one/start-async. - The tool is no longer registered on the Mastra instance, so
/v6/ai/tools/system-one/executereturns404. This supersedes revision 1's confirmed decision "HTTP surface: Mastra tool route only". The tool remains the unit of logic (the workflow calls it) and the future agent-facing interface. - Images are recorded as
{ sha256, bytes }in run snapshots viapruneSnapshot, not as base64. - The workflow does its own input validation (
validateInputs: false+ a firstvalidate-requeststep), so invalid requests become recorded failed runs instead of an unrecorded HTTP 500. - RBAC gains a workflow policy (same roles/scopes) and a
TARGET_ID_ALIASESentry; Decisions 5, 6 and the example are rewritten for the workflow surface. - Run-record retention is a follow-up, not part of this ADR.
- Tracking = run snapshots; enabling Mastra observability/tracing is a separate follow-up ADR (W7).
- Two Mastra behaviours found by probing are designed around: in-place snapshot redaction corrupts the live run (W2 → deep copy), and a reused
runIdoverwrites another caller's record (W8 →onStartgate,409).
"System One" is Ollama's typed-decision API: instead of generating text, a small local decision model (Nimble, Tev1, Clef, Clef Flash) scores a fixed set of candidate answers and returns probabilities. It answers three question shapes — pick one of N labels (choice), yes/no (noul), and position on an ordered rubric (score) — in one cheap, deterministic-shaped, non-streaming call.
That is a capability this service does not have today. Every classification, triage, or rubric judgement in tc-ai-api currently costs a full chat-model generation plus structured-output parsing (src/utils/structured-output-wrapper.ts), and returns a single answer with no calibrated alternative. System One returns the whole distribution, so a caller can threshold, abstain, or escalate on low confidence.
The request is to expose it as one tool that:
- is usable by agents (Mastra tool) and callable by other services over HTTP — services through a workflow, so every call is tracked (revision 2);
- is restricted to the
administratormember role and to M2M tokens carrying thesys1:usescope; - sits behind a provider wrapper, Ollama first and default, so the backend can change without changing callers;
- takes Ollama's System One request as its input and returns its response as its output, with extensions only where the goals above need them (the compatibility layer);
- has schemas descriptive enough that an agent can use it correctly from the schema alone.
Sources: https://docs.ollama.com/api/systemone.md (embeds the OpenAPI operation verbatim) and https://docs.ollama.com/capabilities/decision.md. Requires Ollama v0.35.0+; Clef/Clef Flash (images) require v0.35.1+. Local models only — cloud models are rejected with 400.
Request SystemOneRequest (required: model, state, questions):
| Field | Type | Semantics |
|---|---|---|
model |
string, non-blank | A local System One model, e.g. nimble. GGUF weights + scoring-capable runner; cloud and MLX/Safetensors not supported. |
state |
SystemOneContent |
The shared input every question is asked about. A non-blank string, or an object/array that is serialized as JSON text. Not chat messages, not multimodal. |
images |
string[] (base64, format: byte) |
Shared by all questions, in order. PNG/JPEG/WebP. Clef / Clef Flash only. URLs and data URLs rejected. state is still required. |
questions |
object, 1–64 entries | Named questions about state. Answers are not passed to later questions — each is evaluated independently against state (+ images). |
keep_alive |
string | number | How long to keep the model loaded ("5m", seconds; 0 unloads; negative keeps forever). Default = server setting. |
Question variants (discriminated on type; instructions is SystemOneContent in all three):
type |
criteria |
Notes |
|---|---|---|
choice |
required object, 2–26 entries: optionKey → description | null |
null uses the key itself as the description. Ties follow option order. |
noul |
optional { "false"?: string, "true"?: string }, no other keys |
Defaults "No" / "Yes". |
score |
required array of 2–26 strings | Ordered lowest (index 0) to highest. Defines a 0…N-1 scale. |
Response SystemOneResponse: { model, answers: { [questionName]: Answer }, usage: { input_tokens, output_tokens } }.
Answer type |
Fields | Semantics |
|---|---|---|
choice |
choice, probabilities, confidence |
choice = argmax key; probabilities keyed by option key, sum ≈ 1. |
noul |
noul |
P(true) in [0,1]. A number, not a boolean. No confidence. |
score |
score, legend, probabilities, confidence |
score = Σ index × P(index), in [0, N-1] — not rounded, not normalized to 0–1. legend and probabilities keyed by zero-based index as strings. |
confidence = 1 − H(p)/ln(N): 0 = uniform, ~1 = one candidate dominates. Upstream is explicit that it is not calibrated correctness.
Limits and errors: request ≤ 64 KiB without images, ≤ 32 MiB with images (incl. base64 + JSON); the whole input must fit the loaded context and is never truncated. 400 invalid request / unsupported model / prompt exceeds context; 404 model not pulled; 413 size; 500 load/render/score failure. Error body { "error": string }. No streaming, tools, or generation controls.
Mastra mounts POST /v6/ai/tools/:toolId/execute, body { "data": <tool input>, "requestContext"?: {...} }, requiresAuth: true. The handler resolves the tool by .id from (1) server-registered tools, (2) mastra.getToolById() — i.e. new Mastra({ tools }), then (3) findToolInAgents() — any tool attached to any registered agent. It calls tool.execute(data, { mastra, requestContext, ... }) with the server RequestContext, so withAccessPolicy sees the authenticated user. It returns the tool's result as the JSON body; a thrown error becomes HTTPException(error.status ?? 500).
Findings that matter for this ADR:
- F1. Agent-attached tools are already HTTP-executable.
/v6/ai/tools/search-members/executeresolves today viafindToolInAgents. ADR 0004 and the comment onTARGET_ID_ALIASES.tool("Tools are never addressed by URL") are wrong. RBAC still holds because the guard is insideexecute(withAccessPolicy) — but nothing inauthorizeAccessPolicy'sparseTarget()recognises/tools/paths. - F2. Tool denials surface as HTTP 500.
ToolAccessDeniedErrorhas no.status, sohandleErrormaps it to500 { "error": "Access denied for tool \"…\"" }. Correct outcome, wrong status for a service caller. - F3. Input-schema failures surface as HTTP 200. Mastra's
validateToolInputreturns{ error: true, message, validationErrors }instead of throwing, so the route answers200with that body. The access guard wraps the validatingexecute, so a denied caller gets 403/500 before any schema detail is revealed. - F4. Request-body
requestContext.useris not a reserved key.mergeRequestContextdrops onlymastra__*/organizationId; plainuserfrom the body lands in the context first. Middleware order is: context merge →resourceIdMiddleware(which trusts a presentuserand skips verification) → per-routecheckRouteAuth, which unconditionally overwritesuserandmastra__resourceIdfrom the verified JWT. Net effect today: the tool sees the verified user — safe, but only by ordering. Pinned with a regression test (Decision 6). - F5. Default body limit is 4.5 MB (
server.bodySizeLimit ?? 4.5 * 1024 * 1024in@mastra/deployer), below Ollama's 32 MiB image ceiling. Base64 inflates ~33%, so ~3.3 MB of raw image per request is the effective cap — kept as is (Decisions confirmed 5).
- W1. Runs are persisted in full by default.
WorkflowOptions.pruneSnapshotdocs: "user workflows persist full snapshots by default". The snapshot (Postgresworkflow_snapshots, schemaMASTRA_DB_SCHEMA) holdsrunId,status,context.input, per step{ payload, output | error, startedAt, endedAt, status },result, andrequestContext. Probe (LibSQL in-memory, successful run): the snapshot contained the input, the step payload,resourceIdand the caller'suser/mastra__userclaims;mastra__authToken(the raw bearer token) was not persisted. Base64 images appear twice or more — incontext.inputand in every steppayloadthat carries them. - W2.
pruneSnapshotruns on every persist, including mid-run, and the snapshot shares references with the live run. Probe (two steps, hook fired 6× for one run): a hook that rewroteimagesin place made step 2 receive{sha256}instead of the base64 — the live run was corrupted. The same hook operating on astructuredCloneleft step 2 with the real image and stored only hashes. - W3.
validateInputsdefaults totrue, and an invalid start input throws (Invalid input data: …, no.status) →start-asyncanswers HTTP 500 and the run outcome is never recorded. WithvalidateInputs: false, nothing validates the input at all — a malformed input reaches the steps unchecked (probe). - W4. Step failures do not fail the HTTP call. A step that throws
Object.assign(new Error('[SYS1_TIMEOUT] …'), { status: 504 })produces{ status: 'failed', error: { message, name, status: 504 } }—.statusand the message prefix survive — andstart-asyncanswers HTTP 200. The result envelope is{ status, steps, input, stepExecutionPath, result | error, runId, traceId, spanId }. - W5. Runs are owned by the caller.
start-async/create-runforceresourceIdfrom the authenticated context (getEffectiveResourceId) — userId for members,subfor M2M.GET …/runslists only the caller's runs;GET …/runs/:runIdandDELETEcallvalidateRunOwnership. An administrator cannot read a service's runs through the API — Studio or the database can. - W6.
createStep(tool)forwardsrequestContextandabortSignaltotool.execute(runToolEntry), sowithAccessPolicyruns inside a step exactly as over HTTP. But a tool's input-validation failure is a returned{ error: true }(F3), which a workflow without output validation would record as a successful step. - W7. No tracing exporter is configured.
src/mastra/index.tssets noobservabilityand no other file does (repo-wide search for@opentelemetry|Observability(|DefaultExporter|instrumentationhits onlyREADME.mdand the unusedpackage.jsondeps — the README's "OpenTelemetry via@mastra/observability" is stale), sotraceId/spanIdin results correlate to nothing today. The run snapshot is the tracking record. - W8. A reused
runIdoverwrites another caller's record.start-asynccallscreateRun({ runId, resourceId })with no ownership check (only read/delete//startcheck it). Probe: runXby A, thenstartwithrunId: Xby B → the stored record became B's (resourceId, input, result).createRunalone did not clobber it; the overwrite happens onstart. AnonStarthook that loads the existing run and throws{ status: 409 }blocked B and left A's record intact (probe).
- Services call a workflow, so "Ollama-compatible" means
inputDatais the Ollama body andresultis the Ollama response; the URL and envelope are Mastra's (Decisions confirmed 1). - W3/W4/W6 together mean the workflow must validate inside a step and turn every tool failure into a thrown error, or tracking records misleading outcomes.
- System One output is probabilities over caller-defined labels. An agent that writes vague criteria, splits context across questions, or reads
scoreas 0–1 will get confidently wrong results. The schema descriptions must teach this — they are the only manual the model reads. ai-sdk-ollama/createModel()are chat-model factories;/v1/systemoneis not a chat endpoint. The provider wrapper is a new, narrow interface.
In scope:
- A provider-neutral System One contract (Zod), a
SystemOneProviderinterface, and an Ollama implementation. - One tool,
system-one— the unit of logic and the future agent interface, not registered on the Mastra instance (no direct HTTP route). - One workflow,
system-one— the only service entry point, persisted per run, images hashed. - RBAC policy
administrator/sys1:useon both the workflow and the tool, plus the ADR 0004 corrections C1–C3. - Config via env, error→HTTP-status mapping, logging/usage.
Out of scope:
- Wiring the tool into any agent (Decisions confirmed 4) — follow-up ADRs.
- A custom Ollama-shaped route (
/v6/ai-api/sys1) — rejected (Decisions confirmed 1). - Run-record retention /
storage.prune()scheduling (Decisions confirmed 11) — follow-up; it applies to every workflow. - An admin-wide audit API over other callers' runs (W5) — follow-up if Studio/DB access is not enough.
- Enabling Mastra observability / a tracing exporter (W7, Decisions confirmed 12) — own ADR.
- A second provider implementation — the interface is designed for one; none is built.
- Fixing
resourceIdMiddleware's trust of an unverified bodyuser(F4) — itsresourceIdis overwritten by verified auth today; tracked as a follow-up.
- HTTP surface: the
system-oneworkflow only (revision 2 — supersedes "Mastra tool route only").POST /v6/ai/workflows/system-one/start-asyncwith{ "inputData": <SystemOneRequest> }. The tool is not registered on Mastra, so the direct tool route404s. No custom route. - Model choice: server allowlist.
modelis optional (defaults toSYS1_MODEL); a caller-supplied model must be inSYS1_ALLOWED_MODELS. Provider is server-config only (SYS1_PROVIDER), never a request field. - Images: accepted everywhere — in the workflow input and in the agent-facing tool schema.
- No agent wiring in this ADR.
- Body limit stays at Mastra's 4.5 MB default — images capped at ~3.3 MB raw per request; revisit with a real image use case (F5).
- Answers are returned verbatim — no derived fields; interpretation lives in the schema descriptions, thresholds with the caller.
keep_aliveis server-only — stripped from input, set bySYS1_KEEP_ALIVE.- Harden
withAccessPolicyin this ADR — read the reserved, non-injectablemastra__userkey first, fall back touser(Decision 6, C3). - Services use the workflow for full tracking (revision 2) — every call is a persisted run (Decision 7).
- Images are recorded as
{ sha256, bytes }in run snapshots, never as base64 (revision 2). - Retention is a follow-up (revision 2) — run records (incl.
state) are kept indefinitely until a repo-wide retention policy lands. - Tracking is the run snapshot (revision 2) — Mastra observability/tracing stays off; enabling it repo-wide is its own ADR.
- Base URL:
OLLAMA_API_URLby default,SYS1_OLLAMA_BASE_URLoverrides it when set (revision 2) (Decision 2).
// types.ts — the canonical contract (Zod), shared by the tool and every provider.
export type SystemOneProviderRequest = SystemOneRequest & { model: string }; // model resolved
export type SystemOneProviderResponse = SystemOneResponse; // Ollama shape, verbatim
// provider.ts
export interface SystemOneProvider {
/** Stable id surfaced in tool output and logs, e.g. 'ollama'. */
readonly name: SystemOneProviderName;
evaluate(req: SystemOneProviderRequest, opts: { signal: AbortSignal }): Promise<SystemOneProviderResponse>;
}
export type SystemOneProviderName = 'ollama';- The canonical contract is Ollama's schema, verbatim (including the name
noul). It is the published, versioned spec we were asked to match; inventing a neutral dialect now would add a translation layer with one implementation and no second consumer to validate it against. A future provider adapts to this contract inside its ownevaluate(). providers/ollama.ts—OllamaSystemOneProvider:fetch(POST {baseUrl}/v1/systemone), JSON body,AbortSignal.timeout(SYS1_TIMEOUT_MS)merged with the caller's signal, parses the response with the same Zod response schema (provider contract drift →502, not a silent pass-through).keep_aliveis set from config, never from the caller (Decision 3).index.ts—getSystemOneProvider(): memoised switch onSYS1_PROVIDER(defaultollama); unknown value throws an actionable error on first use (never at import — mirrorsgetRagConfig()).- Not added to
createModel()/SupportedProvider: those return AI-SDKLanguageModels; System One is a scoring RPC, not a chat model.
Lazy, memoised getSystemOneConfig() (+ _resetSystemOneConfigCache() for tests):
| Env var | Default | Notes |
|---|---|---|
SYS1_PROVIDER |
ollama |
Only ollama accepted in v1. |
SYS1_OLLAMA_BASE_URL |
OLLAMA_API_URL |
Optional override. By default System One uses the same Ollama host as the chat models (OLLAMA_API_URL); when SYS1_OLLAMA_BASE_URL is set it takes precedence, e.g. to point System One at a dedicated host. Resolution: SYS1_OLLAMA_BASE_URL || OLLAMA_API_URL. The env var is read directly — the hard-coded dev host fallback inside ollama.ts is not inherited, so a prod deploy can't silently score against dev. Both unset ⇒ 503 SYS1_NOT_CONFIGURED. |
SYS1_MODEL |
nimble |
Default when the request omits model. |
SYS1_ALLOWED_MODELS |
= SYS1_MODEL |
Comma list. SYS1_MODEL is always implicitly allowed. Add clef-flash/clef to enable image judging. |
SYS1_TIMEOUT_MS |
30000 |
Per provider call. |
SYS1_KEEP_ALIVE |
unset (server default, 5m) | Passed through as keep_alive. |
The allowlist is read at tool-module load only to build the description text (so the agent sees the real model list); enforcement re-reads config at execute time.
src/mastra/tools/system-one/system-one-tool.ts, id: 'system-one'. The input is SystemOneRequest with exactly these deviations:
| Field | vs. Ollama | Why |
|---|---|---|
model |
optional; must be in SYS1_ALLOWED_MODELS |
Decisions confirmed 2. Omitted ⇒ SYS1_MODEL. |
keep_alive |
not accepted (stripped if sent) | Model residency is a shared-infrastructure decision (a negative value pins a model in GPU memory forever). Server-controlled via SYS1_KEEP_ALIVE (Decisions confirmed 7). |
| everything else | identical, incl. bounds | state, images, questions (1–64), choice 2–26, score 2–26, noul {false?, true?} strict. |
Zod notes: question/answer unions are z.discriminatedUnion('type', …); questions and choice.criteria are z.record(...) with .refine on key count and non-blank keys (Zod has no minProperties); SystemOneContent = z.union([nonBlankString, z.record(z.string(), z.unknown()), z.array(z.unknown())]). Bounds are enforced locally so a bad call fails at our boundary with a field path (F3), not as an opaque upstream 400.
Descriptions (what the model sees). These are part of the decision, not decoration — draft text:
Tool description — Ask a small decision model to answer one or more classification, yes/no, or rubric-scoring questions about a single input, and get back probabilities rather than generated text. Use it to label, triage, filter, or grade content when the possible answers can be listed in advance. Do NOT use it to generate text, extract values, summarise, or answer open questions — it can only pick among the options you define. Put ALL the content being judged in
state— every question seesstate(andimages) but never sees other questions or their answers, so do not write a question that depends on another's result. Batch every question about the samestateinto ONE call (up to 64). Write criteria as short, mutually exclusive, concrete descriptions; the model's accuracy depends on them. Results are probabilities: report the winning answer with its probability/confidence, and treat low confidence (< ~0.5) or near-tie probabilities as "uncertain" rather than as a firm answer.
state— The content being judged: a non-empty string, or a JSON object/array (serialized to text). Include everything the questions need — questions cannot see each other. Not chat messages. Max ~64 KiB total request without images.
images— Optional base64-encoded PNG/JPEG/WebP images (raw base64 — nodata:prefix, no URLs), shared by all questions. Only image-capable models accept them ({list from config, e.g. clef-flash}); setmodelaccordingly.stateis still required and should say what the images are.
model— Optional. One of: {allowed list}. Omit to use {default}. {If a vision model is allowed: "Use {vision model} when sending images."}
questions— Map of your own question names (e.g."is_spam","priority") to question definitions; 1–64 entries. Answers come back under the same names. Each question is answered independently againststate.
type: "choice"— Pick exactly one of 2–26 named options.criteriamaps option key → description (e.g.{"bug":"Software errors","billing":"Payments and refunds"}); null description = use the key. Include an"other"/"none"option when the input may fit none of them — the model must otherwise pick one.
type: "noul"— Yes/no. Returns the probability that the answer is yes (true). Optionalcriteria: {"false": "...", "true": "..."}sharpens what yes and no mean (defaults "No"/"Yes"). Phraseinstructionsas a yes/no question.
type: "score"— Place the input on an ordered scale.criteriais an array of 2–26 level descriptions ordered from LOWEST (index 0) to HIGHEST. Returns a weighted average index in [0, N-1] — e.g. 1.7 on a 0–2 scale — not a 0–1 or 0–100 value.
instructions— The question, phrased for this type (e.g. "Which label fits this ticket?"). A string, or a JSON object/array.
Output = SystemOneResponse verbatim plus one envelope field:
{
provider: 'ollama', // extension: which backend answered (Decision 1)
model: string, // the RESOLVED model (default applied), as returned upstream
answers: Record<string, ChoiceAnswer | NoulAnswer | ScoreAnswer>, // unchanged
usage: { input_tokens: number; output_tokens: number }, // unchanged
}Output-field descriptions carry the interpretation rules, so an agent reading the result (or a developer reading the OpenAPI) gets them without the docs: noul = P(yes), a number not a boolean; score = weighted index in [0, N-1], use legend to name the levels; probabilities for score are keyed by string indices; confidence = concentration of the distribution, not accuracy; usage.output_tokens is internal scoring work, not response length.
Answers are not enriched (no derived level, boolean, or threshold) — any threshold is a policy choice that belongs to the caller (Decisions confirmed 6).
execute(input):
- Resolve config; base URL =
SYS1_OLLAMA_BASE_URL || OLLAMA_API_URL;503 SYS1_NOT_CONFIGUREDif neither is set. - Resolve
model(default / allowlist) —400 SYS1_MODEL_NOT_ALLOWED, message lists allowed models. - Strip
keep_alive; applySYS1_KEEP_ALIVE. - Size pre-check on the serialized body: > 64 KiB without images or > 32 MiB with images ⇒
413 SYS1_REQUEST_TOO_LARGE(mirrors upstream; saves a round-trip). Mastra's 4.5 MB body limit (F5) applies first over HTTP. provider.evaluate(...); log{ provider, model, questionCount, hasImages, usage, latencyMs }at info — neverstate,instructions, or images (may be member PII/content).
Errors are thrown as SystemOneError extends Error { status, code }. Verified 2026-10-05: an error thrown inside a createTool execute propagates out of the Tool's validating wrapper with .status intact (only validation failures are returned as values), and handleError → HTTPException(status) → the deployer's errorHandler answers { "error": err.message } with that status. The body carries only the message, so the code is prefixed into it — "[SYS1_TIMEOUT] System One provider did not respond within 30000 ms" — giving services a stable token to match and agents a readable error:
| Condition | HTTP | code |
|---|---|---|
| Input schema violation | 200 {error:true, validationErrors} (Mastra behaviour, F3) |
— |
| Access denied | 403 (after Decision 6 fix) | — |
| Model not in allowlist | 400 | SYS1_MODEL_NOT_ALLOWED |
| Local size pre-check | 413 | SYS1_REQUEST_TOO_LARGE |
Upstream 400 (bad request, context overflow, image to non-vision model) |
400 | SYS1_INVALID_REQUEST (upstream error text passed through) |
Upstream 404 (allowed model not pulled) |
503 | SYS1_MODEL_UNAVAILABLE — an ops fault, not the caller's |
Upstream 413 |
413 | SYS1_REQUEST_TOO_LARGE |
Upstream 5xx / response fails schema |
502 | SYS1_UPSTREAM_ERROR |
| Network error / connection refused | 503 | SYS1_PROVIDER_UNAVAILABLE |
| Timeout | 504 | SYS1_TIMEOUT |
Neither SYS1_OLLAMA_BASE_URL nor OLLAMA_API_URL set |
503 | SYS1_NOT_CONFIGURED |
No retries: a scoring call is cheap to repeat and the caller owns that choice; a retry on context-overflow or 4xx would be wrong.
The HTTP column applies wherever the tool's error reaches an HTTP handler directly. Through the workflow (the service path) the same errors arrive as HTTP 200 with { status: "failed", error: { message: "[SYS1_…] …", status } } (W4) — services branch on status, then on the [SYS1_…] prefix or error.status. Decision 7 lists the workflow's own HTTP-level outcomes.
Policies — the same rule in two categories:
// DEFAULT_ACCESS_POLICIES.workflow — gates every /v6/ai/workflows/system-one/* route
// (start-async, start, stream, create-run, runs, runs/:runId, …) at the auth hook → 403.
'system-one': { mode: 'restricted', roles: ['administrator'], scopes: ['sys1:use'] },
// DEFAULT_ACCESS_POLICIES.tool — the in-execute guard; runs again inside the workflow's
// evaluate step (W6), and is the gate for any future agent that gets the tool.
'system-one': { mode: 'restricted', roles: ['administrator'], scopes: ['sys1:use'] },Plus TARGET_ID_ALIASES.workflow.systemOneWorkflow = 'system-one' (registry key ≠ .id; ADR 0004 C1 — without it /workflows/systemOneWorkflow/start-async bypasses the policy). Env overrides: ACCESS_POLICY_WORKFLOW_SYSTEM_ONE_* and ACCESS_POLICY_TOOL_SYSTEM_ONE_*. Overriding one without the other is a trap: widening only the workflow policy yields runs that start and then fail in evaluate with Access denied for tool "system-one" — recorded, but confusing. Document both together. DISABLE_AUTH=true bypasses both.
Registration — new Mastra({ workflows: { …, systemOneWorkflow } }). The tool is not added to new Mastra({ tools }) and no agent has it, so /v6/ai/tools/system-one/execute resolves nothing and returns 404 (Decisions confirmed 1). A test asserts the tool is unreachable (unit-level: not resolvable via getToolById, not on any registered agent): the day an agent is given this tool, findToolInAgents makes it HTTP-executable again (F1) — untracked — and the failing test forces that ADR to decide (e.g. a tool-path deny in parseTarget).
Correction C1 (F1) — tools are URL-addressable. Add TOOL_PATH_RE = ^${API_PREFIX}/tools/([^/]+) to parseTarget() → { category: 'tool' }. This gives every restricted tool an early, cheap 403 at the auth hook (before body handling), in addition to the in-execute guard. /agents/:agentId/tools/:toolId/execute is still matched as an agent path first — the in-execute guard covers the tool there. Fix the TARGET_ID_ALIASES.tool comment and add an Implementation note to ADR 0004 pointing here.
Correction C2 (F2) — denials return 403, not 500. ToolAccessDeniedError gains readonly status = 403. This changes the HTTP status for every restricted tool reached over HTTP (500 → 403); in-agent behaviour is unchanged (still a thrown tool error). Existing access-control tests assert the error type, not a status — extend them.
Hardening C3 (F4) — read the identity from a key the body cannot set. withAccessPolicy resolves the caller as requestContext.get('mastra__user') ?? requestContext.get('user'). mastra__user (MASTRA_USER_KEY, @mastra/server constants) is in RESERVED_CONTEXT_KEYS, so mergeRequestContext never copies it from a body or query; Mastra sets it only from the verified token, alongside user. The user fallback keeps in-process callers (workflow steps, tests) that populate only user working. Applies to every tool. Import the constant if @mastra/core exports it; otherwise a local constant with a test asserting it matches the string the auth middleware writes.
Regression test (F4). Member token with no roles + body requestContext: { user: { <rolesClaim>: ['administrator'] } } → denied. Implemented unit-level (no HTTP harness exists): withAccessPolicy with user spoofed to admin and mastra__user clean must deny. With C3 this holds independent of middleware order; the test also guards against a Mastra upgrade that stops writing mastra__user.
7. Workflow system-one — the tracked service entry point (src/mastra/workflows/system-one/system-one-workflow.ts, new)
export const systemOneWorkflow = createWorkflow({
id: 'system-one',
description: '<the tool description from Decision 3, plus: "Each call is recorded as a workflow run.">',
inputSchema: systemOneInputSchema, // Decision 3 — published by GET /v6/ai/workflows/system-one
outputSchema: systemOneOutputSchema, // Decision 4
options: {
validateInputs: false, // W3 — validation happens in a recorded step instead
autoRestartActiveRuns: false, // see below
pruneSnapshot: redactSystemOneImages, // W1/W2 — Decisions confirmed 10; MUST deep-copy
onStart: rejectRunIdReuse, // W8
},
})
.then(validateRequestStep) // 'validate-request'
.then(evaluateStep) // 'evaluate'
.commit();Step validate-request — systemOneInputSchema.safeParse(inputData); on failure throws SystemOneError(400, 'SYS1_INVALID_INPUT') whose message lists each issue path (questions.label.criteria: must have 2–26 options). Then runs the shared resolveSystemOneRequest() (default/allowlisted model, strip keep_alive, size pre-check — the same function the tool's execute uses, so the two can't drift). Output: the resolved request — the run record therefore shows the effective model, not just what the caller sent.
Step evaluate — a plain createStep (not createStep(systemOneTool)) that calls systemOneTool.execute(resolved, { mastra, requestContext, abortSignal }) and throws if the result is Mastra's { error: true } value (W6). Re-entering through the tool keeps one implementation and one access guard; the re-validation inside the tool is cheap.
Why two steps: the record separates "your request was wrong" (validate-request failed) from "the provider failed" (evaluate failed), and isolates provider latency in evaluate's startedAt/endedAt.
Image redaction (pruneSnapshot) — must operate on a structuredClone of the snapshot and never mutate its argument: the hook fires mid-run on objects shared with the live run, and an in-place rewrite sends hashes to the provider instead of images (W2, probed). On the copy it replaces every images array found in context.input, each step's payload, and each step's output with [{ sha256, bytes }] (hash of the decoded image; bytes decoded length). Runs on every persist (W2). What was sent stays provable; ~4.5 MB of base64 is not stored 2–3× per run. Consequences: a run with images cannot be restarted or time-travelled (the stored input is no longer valid input) — acceptable, as these are one-shot calls; and the start-async HTTP response is not redacted (it is built from the live result, W4), so callers get their own images echoed back in input/steps.
runId reuse gate (onStart) — callers may supply ?runId= for correlation, but Mastra would let a second start with the same id overwrite the first caller's record (W8). rejectRunIdReuse loads the run by id; if it exists and is not this caller's freshly created pending run (status !== 'pending' or resourceId differs), it throws SystemOneError(409, 'SYS1_RUN_ID_CONFLICT') → start-async answers 409 and the original record is untouched (probed). onStart errors propagate and the run never executes. Residual: two concurrent starts with the same brand-new id can both pass the pending check — narrow, and only between callers who already share an id; documented, not engineered around.
autoRestartActiveRuns: false — on boot Mastra re-drives runs left running by a crash. For a synchronous scoring call the caller has already seen a dropped connection, and a redacted snapshot couldn't be replayed anyway. Such runs stay recorded as interrupted.
What a run records (the tracking contract): runId, workflowName, resourceId (member userId or M2M sub), createdAt/updatedAt, status; the input (images hashed); per step payload, output or error ([SYS1_…] message + status), timestamps; the final result (incl. provider, model, usage); and the caller's verified claims (user/mastra__user — roles or scopes) from requestContext. Not the bearer token (W1). state/instructions content is stored — that is the point of tracking, and it is why retention is a named follow-up. Logs carry runId alongside the Decision 5 fields so log lines join to runs.
Service HTTP contract:
| Call | Use |
|---|---|
POST /v6/ai/workflows/system-one/start-async[?runId=<uuid>] { "inputData": … } |
Recommended. Runs to completion and returns the result envelope incl. runId. A supplied runId must be a fresh UUID; reuse → 409 (W8). |
POST …/create-run then POST …/start?runId= |
Fire-and-forget; poll GET …/runs/:runId. |
GET /v6/ai/workflows/system-one/runs/:runId / GET …/runs |
Read back own runs (W5). ?fields=result,steps trims the payload. |
| Outcome | HTTP | Body |
|---|---|---|
| Success | 200 | { status: "success", result: <SystemOneResponse + provider>, runId, … } |
Any tool/provider/validation failure (Decision 5 table, SYS1_INVALID_INPUT) |
200 | { status: "failed", error: { message: "[SYS1_…] …", status }, runId, … } — recorded |
| Missing role/scope | 403 | { error } — auth hook, before any run exists |
Reused runId |
409 | { error: "[SYS1_RUN_ID_CONFLICT] …" } — original run untouched |
| Body > 4.5 MB | 413 | Mastra body limit (F5) — before any run exists |
| Infrastructure fault (storage down, …) | 5xx | { error } |
curl -X POST "$TC_AI_API/v6/ai/workflows/system-one/start-async?runId=$(uuidgen)" \
-H "Authorization: Bearer $M2M_TOKEN_WITH_sys1:use" \
-H 'Content-Type: application/json' \
-d '{
"inputData": {
"state": {"ticket": "I was charged twice. Please refund the extra payment."},
"questions": {
"refund": { "type": "noul", "instructions": "Is the customer requesting a refund?" },
"urgency": { "type": "score", "instructions": "How urgently does this need a response?",
"criteria": ["Routine", "Soon: customer inconvenienced", "Immediate: service down"] },
"label": { "type": "choice", "instructions": "Which queue owns this ticket?",
"criteria": {"billing": "Payments and refunds", "bug": "Software errors", "other": null} }
}
}
}'
# 200 → { "status":"success", "runId":"…",
# "result": { "provider":"ollama", "model":"nimble",
# "answers": { "refund":{"type":"noul","noul":0.9989},
# "urgency":{"type":"score","score":0.83,"legend":{"0":"Routine",…},"probabilities":{"0":…},"confidence":…},
# "label":{"type":"choice","choice":"billing","probabilities":{…},"confidence":…} },
# "usage": {"input_tokens":…,"output_tokens":…} },
# "steps": {…}, "input": {…} }
# failure → 200 { "status":"failed", "runId":"…", "error": { "message":"[SYS1_MODEL_UNAVAILABLE] …", "status":503 } }- Contract + config:
types.ts(Zod request/response, descriptions),system-one.config.ts, sharedresolveSystemOneRequest(); tests for bounds, resolution and config parsing. - Provider: interface, Ollama adapter, factory; tests with mocked
fetchcovering every row of the Decision 5 table. - Tool:
system-one-tool.tswrapped inwithAccessPolicy(not registered on Mastra); tests for output passthrough +provider, error propagation. - Workflow:
system-one-workflow.ts+redactSystemOneImages+rejectRunIdReuse; register; tests (in-memory LibSQL, as in the probes) — success; invalid input → recorded failed run withSYS1_INVALID_INPUT; provider error → failed run withstatus; tool{error:true}→ failed (not success);evaluatereceives the original base64 while the stored snapshot holds only hashes;redactSystemOneImagesdoes not mutate its argument; reusedrunId→409and the first record intact; tool unreachable (mastra.getToolById('system-one')fails and no registered agent's tools include it). - RBAC: both policies, workflow alias, C1, C2, C3; ADR 0004 implementation note. There is no HTTP/auth test harness (
tests/integrationcovers extraction only), so RBAC tests are unit-level:authorizeAccessPolicyon both workflow spellings, andwithAccessPolicywithuserspoofed to admin butmastra__userclean → denied (F4). - Verify against a real Ollama ≥ 0.35 with
nimblepulled (see Prerequisites), incl. one small Clef Flash image call; inspect the stored run in Studio.
| File | Change |
|---|---|
src/utils/providers/system-one/types.ts |
New — canonical Zod contract + descriptions, resolveSystemOneRequest() |
src/utils/providers/system-one/provider.ts |
New — SystemOneProvider, SystemOneError |
src/utils/providers/system-one/ollama.ts |
New — Ollama adapter |
src/utils/providers/system-one/index.ts |
New — getSystemOneProvider() |
src/utils/providers/system-one/*.test.ts |
New |
src/config/system-one.config.ts (+ .test.ts) |
New — Decision 2 |
src/mastra/tools/system-one/system-one-tool.ts (+ .test.ts) |
New — Decisions 3–5 |
src/mastra/workflows/system-one/system-one-workflow.ts (+ .test.ts) |
New — Decision 7, incl. redactSystemOneImages |
src/mastra/index.ts |
Modified — workflows: { …, systemOneWorkflow } (tool deliberately not registered) |
src/config/access-control.config.ts |
Modified — 'system-one' workflow + tool policies; systemOneWorkflow alias; fix TARGET_ID_ALIASES.tool comment |
src/utils/auth/access-control.ts |
Modified — TOOL_PATH_RE in parseTarget (C1); ToolAccessDeniedError.status = 403 (C2); withAccessPolicy reads mastra__user first (C3) |
src/utils/auth/access-control.test.ts |
Modified — tool-path targets, 403 status, F4 regression, both alias spellings of the workflow |
docs/adr/0004-…md |
Modified — implementation note referencing C1–C3 |
README.md / env docs |
Modified — SYS1_* vars, sys1:use scope, workflow service contract, stale observability claim (W7) |
- + A cheap, probability-returning decision primitive available to services now and to agents later.
- + Every service call is a persisted, caller-attributed run with per-step timing and errors — including rejected input — readable back by the caller and in Studio.
- + Provider is swappable behind one interface; callers and the tool schema don't change.
- + C1–C3 improve every restricted tool: correct
403s, an early auth-hook check over HTTP, and a caller identity that cannot be injected through the request body. - − The canonical contract is Ollama-shaped (
noul,keep_alivesemantics). A future provider with different semantics (e.g. no probabilities) would need lossy adaptation or a contract revision. - − Not drop-in for an Ollama client: services use Mastra's
{ "inputData": … }envelope, readresult, and must branch onstatus— failures arrive as HTTP200(W4). - − Run records grow without bound and contain caller content (
state) and caller claims until retention lands (Decisions confirmed 11). Treat theworkflow_snapshotstable as holding member content. - − Runs with images can't be restarted/time-travelled (redacted input), and the
start-asyncresponse echoes images back un-redacted. - − Two policies (workflow + tool) must be kept in step when overridden.
- − No span-level tracing (W7): a run shows step timings and errors, not the HTTP call to Ollama. Covered by the follow-up observability ADR.
- Risk: W2/W8 are Mastra behaviours, not documented contracts; the deep-copy and
409tests are what catch a regression on upgrade. - − Images are capped at ~3.3 MB raw per request by Mastra's global 4.5 MB body limit (Decisions confirmed 5), well below Ollama's 32 MiB.
- − C2 changes an observable status code (500 → 403) for existing restricted tools over HTTP; anything keying on 500 must adapt (none known).
- Risk: tool schemas with
z.record+ discriminated unions underadditionalPropertiesmay be reshaped by Mastra's schema-compat layer for some model providers. Not exercised until an agent is wired; check then with the target model.
- Body limit for images → keep 4.5 MB (Decisions confirmed 5).
- Derived answer fields → none, verbatim (Decisions confirmed 6).
keep_alive→ server-only (Decisions confirmed 7).mastra__userhardening → in this ADR, C3 (Decisions confirmed 8).- Direct tool route once the workflow exists → removed; tool not registered (Decisions confirmed 1, revision 2).
- Images in run records →
{ sha256, bytes }(Decisions confirmed 10). - Run-record retention → follow-up (Decisions confirmed 11).
- Tracing → run snapshots only; observability is its own ADR (Decisions confirmed 12).
- Ollama ≥ v0.35.0 (≥ v0.35.1 for Clef) on the target host, with
nimblepulled (andclef-flashif images are to work). Not verified:ollama.topcoder-dev.com:11434was unreachable from the authoring machine on 2026-10-05. -
OLLAMA_API_URLset in every environment (System One uses it by default). SetSYS1_OLLAMA_BASE_URLonly where System One should run on a different Ollama host than the chat models — decision models are small, but scoring 64 questions on a shared host competes with chat inference. -
sys1:usescope created on the M2M Auth0 API (AUTH0_M2M_AUDIENCE) and granted to the consuming service clients. - Owner agreed for the retention follow-up (run records hold caller content indefinitely until then).
Implemented as designed. These details were settled while building it:
runIdgate checks the verified caller, notinfo.resourceId.Workflow.createRun()returns the cached in-processRunfor a knownrunId, along with the first caller'sresourceId. SoonStart'sinfo.resourceIdcan't tell B apart from A when B reuses A's still-pendingid.rejectRunIdReusecompares the stored run'sresourceIdwithrequestContext.get(MASTRA_RESOURCE_ID_KEY), which verified auth sets per request, and falls back toinfo.resourceIdfor in-process callers. A test covers the pending case. It fails if the check usesinfo.resourceId. A run with no owner (always the case underDISABLE_AUTH=true) is stored withresourceIdNULL in Postgres while the context hasundefined. Both mean "no owner" and are compared as equal. The first version compared them strictly, so it rejected every such run with409(fixed 2026-10-06; regression tests added).mastra__useris a local constant (MASTRA_USER_CONTEXT_KEYinaccess-control.ts). Neither@mastra/core/request-contextnor any public@mastra/serverexport provides it. A test resolves@mastra/serverthroughmastra→@mastra/deployerand asserts that itsMASTRA_USER_KEYstill equals that string.- Response schemas are
z.looseObject. Additive upstream fields pass through verbatim (Decisions confirmed 6). A missing or mistyped required field is still a502. SYS1_KEEP_ALIVE: a purely numeric value is sent as a JSON number (seconds), anything else as a duration string. Ollama rejects a unitless duration string.- A caller abort is rethrown as-is. Only the provider's own timeout maps to
504 SYS1_TIMEOUT. - Blank question/option names are rejected by a refine on the record, not by the key schema. Zod reports a failing key schema only as "Invalid key in record", with no useful path.
- Image redaction touches only the
imagesfield ofcontext.inputand of each step'spayload/output. A caller'sstatemay legitimately contain animageskey and is left alone. A snapshot with no raw images is returned as-is, without a clone. Tests confirm that breaking the deep copy makes the live run fail, as W2 predicts. - The "tool unreachable" test lives in
src/mastra/index.test.ts. It imports the real Mastra instance with Postgres swapped for in-memory LibSQL and Auth0/workspace env stubbed.