Skip to content

Roadmap ​

Aletheia is self-deployable risk management infrastructure for payment companies, marketplaces and fintechs. This page says what the platform does today and what has been deliberately left for later. It is generated: the "Deferred, by theme" section below is the project's register of postponed work rendered for the site, so the two never disagree.

What works today ​

Identity and tenancy. Zitadel provides identity, with one organisation per tenant and project roles mapped to API permissions; Postgres row-level security sits under every repository. Applicants never sign in: they open a signed, expiring link. See Concepts.

Rules and workflows. Six rule types (comparison, list lookup, score threshold, CEL expression, velocity over ingested events, and app calls), grouped into rule sets with aggregation policies and score bands; a draft rule can be dry-run and backtested against recorded evaluations before it is published. Workflows run on Temporal with idempotent activities and steps that evaluate rules, call apps, branch, wait for a collection, open cases and emit decisions. See Rules.

Vendors and documents. Vendors are apps: WebAssembly modules a tenant installs with its own configuration and secrets, run by a separate app runner in a sandbox that holds one tenant's data for one call and reaches only the hosts the install approved. Calls have per-action timeouts, retries and idempotency keys, a task queue of their own, circuits, asynchronous callbacks and signed webhook ingestion; tenants upload, publish and install their own apps, and a release can ship platform apps to every tenant in its catalogue. The deployment's operator lists every tenant's apps, blocks a module everywhere at once, reviews tenants' versions before they publish and ships a platform version outside a release with pnpm ops (Operations). The catalogue's model apps (Claude, ChatGPT, Gemini, Jev and any OpenAI-compatible server) put a language model in a step or a rule, an answer to a prompt or decisions with a probability to route on, through the runner, which holds the install's keys (AI models). Documents upload straight to S3-compatible object storage, where their type and size are checked, images are re-encoded and PDFs with active content are refused. See Apps, the App catalogue and Documents.

The collection terminal. The applicant app (apps/collection-terminal) opens a tenant's collection flow from a link, hosted or embedded in a merchant's page through an iframe loader: a welcome screen, steps with conditional fields and branches, saved answers, document uploads, a review before the submit and a screen that follows the decision. See Collection flows and embedding.

The admin console. The staff app (apps/admin-console): case queues with claiming, SLAs, notes and attachments; subjects with a timeline and decision history; runs; editors for rules, rule sets, lists, workflows and collection flows with validation, version diffs and four-eyes approval before publish; apps with their installs, versions, approvals and health; the audit trail with digested exports; tenant settings. See Admin console, Admin console: Define and Audit.

Running it. OpenTelemetry traces, metrics and correlated logs with dashboards and alert runbooks; container images on GHCR, a one-VM compose stack and a Helm chart with migrations in the pipeline; Changesets for the published packages and tagged platform releases; replay tests over recorded workflow histories and a rule-evaluation benchmark. See Deployment, Operations, Releasing and Performance.

Building on it. This site, with a guided path from an empty machine to a first decision (Get started, the tutorial and the pilot runbook); an app authoring guide written against the published SDK, with testing helpers that run the built module through the real host, a scaffold and a standalone example; and six example policy packs that load through the API or a CLI.

How the register works ​

The register is docs/plans/deferred.md. An item is added whenever a change leaves known work for later and removed when it ships. Each item says what exists today, what is missing and, where there is one, what would trigger the work. Items that code comments refer to carry a short id: B1 to K for admin console features that wait on the API, and API-n for API additions the collection terminal waits on. CI fails when this page and the register disagree (pnpm roadmap:check).

Deferred, by theme ​

Everything below is a decision to postpone work, recorded in the register of postponed work together with what would trigger picking the item up. These are statements of intent, not deadlines: the project has no dates, and an item ships when its trigger arrives.

Identity, tenancy and authorization ​

  • SpiceDB authorization. The API authorizes through the Authorizer interface (packages/auth/src/authorizer.ts), implemented over Zitadel project roles. Object-level rules (deciding a case, publishing a definition, approving a change, working a queue) need a SpiceDB implementation behind it. Intended schema: a tenant with admin, analyst and integration relations (manage for admins, view for admins and analysts); a case with its tenant and an assignee (view through the tenant, decide for the assignee or a manager); a definition set with a publisher (publish for the publisher or a manager). Role relationships would be written with TOUCH from a token's roles claim when it is verified, so nothing has to sync Zitadel into SpiceDB. The same object rules would give analysts a narrower run scope (only the runs behind the cases in the queues they see), which the handoff's analystRunsScope asks for.
  • Zitadel Login V2. The stack uses Zitadel's built-in login everywhere. Revisit if branding or passkey flows need the newer login UI.
  • Multi-organisation users. One Zitadel session per organisation; switching means signing out and in. A session-level organisation switcher is a console follow-up.
  • Tenant self-service. Tenants are provisioned by operators (pnpm tenant:add). An admin UI or instance-level API for provisioning is pending a notion of instance admins.
  • Private-key JWT for machine callers. Service users use personal access tokens via introspection in development and CI; production guidance should prefer private-key JWT.
  • Local user mirror. The reviewer directory (with a 60 s cache) and Settings › Team read Zitadel live; a person's last activity comes from the audit log. Mirror users into a local table when SpiceDB relations or offline reporting need them.
  • Token revocation. The API verifies access tokens (JWTs) locally until they expire, so a console sign-out or idle lock does not invalidate a token already issued. Short token lifetimes or a revocation check would close the gap.

Rules and workflows ​

  • jsonata and JSON Logic expression languages. CEL is the only language. jsonata would run with guardrails inside a worker thread; JSON Logic would back a visual condition editor.
  • Tenant-configurable velocity keys. Velocity rules group by subject and four promoted columns (ip, device_id, card_fingerprint, email). Other keys need a secondary key table and a configuration UI.
  • Automatic workflow triggers. A workflow definition records a trigger (manual, subject.created or event), but nothing acts on it: every run starts through POST /workflow-runs or as a replay, and ingested events are rule inputs only. Starting a run when a subject is created, or a monitoring run when events of chosen types arrive, is the next step; such runs would record their own run trigger beside api, console and replay.
  • Workflow simulation. The workflow editor validates structure only; dry-running a workflow against sample data is not built.
  • Retiring a definition. A version is archived only when a newer one is published; no route retires a key. GET /definitions/usage is the check such a route would make first.
  • Rolling aggregates. Velocity is computed on the fly over indexed ranges. Time-bucketed counters or TimescaleDB continuous aggregates when per-key windows grow large or distinct counts over long windows get slow.
  • Events partitioning and retention. The events and rule-evaluations tables are created unpartitioned with partition-ready primary keys; monthly range partitions (pg_partman) and retention jobs are an operations step once volume demands it.
  • List versioning. Lists are live data, so backtests use current list contents rather than the contents at evaluation time.
  • Backtest backfill. Backtests replay stored evaluation snapshots only; there is no backfill from earlier decisions.
  • CSV import over 5 MB. Larger imports should stream through Postgres COPY into a staging table.
  • Backtest history paging. GET /backtests has no ruleKey filter, so the admin console's "previous backtests" for a rule pages through the tenant's whole list and filters client-side; add the query parameter when histories grow.
  • Backtest progress precision. Progress is informational: a retried batch can be counted twice (capped at the total). The summary travels with the workflow and is exact.
  • CEL budget is cooperative. The 50 ms expression budget races a timer against evaluation and only interrupts at an await, so a purely synchronous comprehension runs to completion (the benchmark measured 140 ms and 500 ms cases). Production list lookups do I/O, so the budget holds there; a hard limit needs a worker thread or an interpreter with a step counter.
  • Why a call_app step failed. A run whose call_app step fails records the activity's failure, Activity task failed, as its error rather than the call's own (install jev-main action decide failed: …), which the worker's log and the invocation's record keep. Recording the failure's cause would say why on the run itself; the trigger is an operator reading failed runs in the console.

Apps and vendors ​

  • Remote apps over HTTP. Every app is a WebAssembly module the platform's runner executes. An app served as a vendor's or a tenant's own HTTP service, called with the same envelopes, would admit integrations that do not compile to WebAssembly (native dependencies, long sessions); it waits for one that needs it.
  • Runner on wasmtime. The runner uses Extism's JavaScript SDK on Node, whose worker mode (what enforces a call's deadline, and bounds the start code of the instances the runner makes ahead of calls, whose workers it finds through Node's worker_threads diagnostics channel) is marked experimental. A runner on Extism's wasmtime-based runtime, behind the same /v1 routes and host interface, would take that mode out of the path; the trigger is a release of the JavaScript SDK that breaks the deadline, the memory cap, asynchronous host functions or how its workers start.
  • OAuth 2.0. No grant is supported: the host builds no OAuth 2.0 auth and http-api refuses oauth-2, so a vendor behind it needs an app of the tenant's own that fetches its token itself and keeps it between calls in the install's cached values (ctx.cache). Client credentials would have the host fetch the token from the install's token endpoint with its client secret and keep it per install until it expires, in the install's cached values. The grants that need a person (authorization code with PKCE) would add an Authorize flow on the install's page that stores the refresh token as one of the install's secrets. The trigger is the first vendor a tenant cannot reach without it.
  • Hand-off through http-api. http-api declares no handoff, so a vendor whose check needs the applicant again on its own hosted page (a liveness check, its own document capture) needs an app of the tenant's own. An install setting that reads the vendor's page for a pending session (a request whose answer names the page's URL and when it expires) would hand the applicant off without code; the trigger is a tenant that wants such a vendor through http-api.
  • Base64 documents in JSON bodies. http-api sends a document only as a multipart part or a binary body, which the host streams without its bytes entering the module, so a vendor that takes a document as base64 inside a JSON field needs an app of the tenant's own. A placeholder the host fills with a document's base64 in a JSON body, kept to the run subject's documents as document bodies are, would cover it; the trigger is a vendor a tenant cannot reach otherwise.
  • Fallbacks declared on the install. A fallback is declared where an install is called, on a call_app step or an app rule, and reviewed and published with its definition. A fallback set once on an install, which every call through it would take, would be a new data flow from one administrator's save: it would need the review a save that adds hosts gets (held for another administrator while the tenant requires approvals for apps), and a check that both installs run versions whose actions take and answer the same, which http-api installs of different vendors never do. The trigger is a tenant whose definitions call one vendor from many places.
  • Exact app quotas. Quotas and budgets are weighed against what their period used as each call reads it, so calls in flight on several workers can pass one by as many as run at once, and an HTTP-request limit refuses only once the period used it all, since a call's requests are known after it ran. Reserving a call's cost before it goes out (a counter claimed in the gate and settled with the record) would make them exact; the trigger is an agreement that bills or caps on hard limits.
  • App metering for billing. Metering counts calls, their HTTP requests, webhook handlings, hand-offs and spend at the tenant's own prices, per UTC day and month. Runner time and document reads are not kept, HTTP requests are no metric of their own (only the share of a quota used is), a tenant's spend adds up per install in each install's currency only, and the operator's export is the JSON of ops tenants usage, no CSV. A billing integration (usage events to a metering service, an export per billing period in the tenant's calendar) waits for the first commercial agreement that needs it.
  • Quota warnings for tenants. A tenant hears of its operator's quotas and its installs' budgets once a call is refused (app.quota.exceeded, the app_quota_exceeded notification); only the operator's alert rule warns before that, at 80 % of a quota. A notification at a share of each budget or quota, once per period, would let administrators raise a budget or ask for more before runs fail.
  • A circuit's trial lease. A half-open circuit's trial call holds the slot for the install's open time, after which another worker may send a second trial: a trial slower than the open time (actions may run for two minutes) overlaps the next, and a trial the runner refused as busy holds the slot until it lapses (its call ends busy, for the step's retry to try again). A lease of its own, as long as the action's timeout and freed when the trial ends without reaching the vendor, would send exactly one; the trigger is a vendor whose calls outlast the open time.
  • Hawk's stale timestamp. A Hawk vendor whose clock differs from the runner's answers 401 with WWW-Authenticate: Hawk ts="…", tsm="…", its own time signed with the install's key; the host returns that answer rather than checking tsm and signing again on the vendor's clock, so the call fails until the runner's clock is set right. The trigger is a vendor that relies on it.
  • Asymmetric webhook signatures. An http-api install's webhook checks an HMAC of the body (hmac, hmac-timestamped, standard-webhooks) or a shared token, through the host's hmac_verify, so a vendor that signs its webhooks with a key pair (RSA or ECDSA, a JWS, Ed25519) needs an app of the tenant's own that checks the signature itself. A host function that verifies a signature under a public key from the install's config (or a JWKS on one of its hosts), and a webhook.signature scheme of http-api over it, would cover them; the trigger is a vendor a tenant cannot reach otherwise.
  • Signed per-session callback URLs. Under a scheme that signs, an http-api install reads a webhook's external id only from what the signature covers (the body, or a header the scheme signs): an id in the URL or an unsigned header would let whoever replays a signed request complete another pending session of the install. A vendor whose signed webhook names the session only in its callback URL therefore needs an app of the tenant's own, which reads the id from the URL with that risk; a token signs nothing, so its install authenticates the sender only. The platform would hand each session a callback URL whose id it signs (an HMAC of the install and the external id under a key of its own, checked by the API before any module runs), so the URL form could be trusted for every app, http-api included. The trigger is a vendor a tenant needs whose signed webhooks name the session only in the callback URL.
  • A lease on a cached value. Two calls of one install that both miss a cached value (a vendor's token) each fetch it, and the value stored last stays; a vendor that keeps one live token per client revokes the other call's, which then sees a 401, drops it and fetches again, so heavy concurrency can thrash. A lease per install and name (the first call that misses takes it, the others wait for what it stores) would fetch it once; the trigger is a vendor whose token endpoint is rate-limited or allows one token at a time.
  • A step run again after a worker crash. When the worker stops after an asynchronous action recorded its session and before the run heard of it, the step runs again and calls the vendor once more. The run keeps waiting on the session recorded first, as long as the second answer asked (the first answer's wait is not recorded); the second session's id is logged and kept on its invocation record, for an administrator to settle with the vendor. Reading the step's recorded session before calling the vendor again would spare it the request, at the cost of the module's own answer about how long to wait; the trigger is a vendor that charges for each session or refuses a second one.
  • Asynchronous app steps in a loop. A definition may visit a step again in one round (a loop through it, up to 100 steps), but a call_app step's idempotency key and the id of its session are its round's, not its visit's: an asynchronous action visited twice reuses the first visit's key, and its second visit waits on the first visit's session, settled already, until it times out. Ids per visit would let a definition poll a vendor in a loop; the trigger is a definition that needs to.
  • Instances for bursts of calls. The runner makes the instances it keeps ahead of a module's calls one at a time per module, about 30 ms each, so calls of one module back to back outrun them and about two in five still make their own (their p95 stays where it was without them). Making two at a time per module, or as many as its calls come in, would cover bursts at more CPU and memory; the trigger is a module called in bursts whose latency matters.
  • A module's worker that dies during a call. A worker that fails while its instance is made fails the creation at once, but one that dies during the call (its thread crashed or exited) leaves the call waiting out its deadline, which then reads timeout. Watching the call's worker as the creation does would end such a call at once as trapped; the trigger is a module whose crashes hold calls up.

AI models ​

  • Documents and images for models. A model app sends text and JSON only (ai takes no file part), so a step cannot ask a model about the run subject's document or a photo. File parts the host fills from the run subject's clean documents, as it fills document bodies for http-api, kept to the models that read them, would cover it; the trigger is a tenant whose decision needs a model to read a document.
  • Streaming answers. A model's answer comes whole, within the step's time; a step that shows an answer as it arrives (a reviewer's summary in a case) would need the host to stream it to the console. The trigger is a reviewer-facing use that cannot wait for the whole answer.
  • Tool calling. A model answers once, with no tools: an agent that calls the tenant's installs or looks data up between turns would need the host to run tool calls inside one call's time and limits. The trigger is a decision that needs more than one model turn.
  • Token budgets. An install's budgets and the operator's quotas count calls, HTTP requests and spend at the tenant's own prices, not tokens, and a call's model requests are not among its HTTP requests; a budget of tokens per period, refused as a quota is, would cap what a model costs. The trigger is a tenant whose model spend needs a cap of its own.
  • More ways to reach the vendors. Vertex AI (Google's OAuth with a service account), Azure OpenAI through Entra ID and Amazon Bedrock with an STS role are not connections: a key or an IAM access key is. Each needs a credential the host exchanges and refreshes; the trigger is a tenant who may not use keys.
  • Calls over two minutes. An action declares two minutes at most, which a step's activity holds with its margin; a longer call (a model thinking at length about a long state) would need an asynchronous action the vendor completes, or a longer activity and grace periods. The trigger is a model whose useful answers take longer.
  • Retention and erasure of model data. What a step sends a model and its answer stay in the run's context, the invocation's record, the rule evaluations and the context of a case the run opens, which no sweep removes; the vendor keeps what its own terms say. Erasing one subject's prompts and answers from those records, or keeping them for a period of their own, waits for a tenant whose obligations ask for it.
  • Error route on a call_app step. A failing call_app step fails its run: there is no error branch, and the run gets no decision and no case; an app rule errors instead and sends the decision to review. An error route on call_app steps would let a workflow handle vendor downtime or quota failures explicitly, for example by opening an escalation case. The trigger is a tenant wanting workflows that degrade gracefully on vendor failures.
  • A model step's input checked on save. Saving a definition checks a call_app step's install, action and mapped fields, and the action checks its input when the call runs: a definition saved through the API with an input a model app refuses (an empty state, an answer schema the host refuses, a <<name>> no variable holds, a model the install does not allow) fails at its first run, while the console lists those problems as the step is written. An app rule's input is not checked on save either, and there the refusal reads as the rule's error outcome on every evaluation. Checking an input against its action's schema and the model apps' rules on save would refuse it before; the trigger is a tenant that writes its definitions through the API.

Documents and storage ​

  • Virus scanning. Documents pass through a no-op scanner (DOCUMENT_SCANNER=none). Add the ClamAV implementation (clamd INSTREAM over TCP, 3 GiB container, EICAR test) before production use.
  • HEIC uploads. image/heic is not an accepted document type: images are re-encoded on ingest to strip metadata and polyglot payloads, and the prebuilt libvips bundled with sharp cannot decode HEVC. Accept HEIC once a libheif-enabled build (or a conversion service) is in the stack. Whether phones convert HEIC photos to JPEG when a browser's file input picks them is not checked.
  • Deep PDF inspection. inspectPdf is a byte scan for active-content names; names hidden by #xx escapes or inside compressed object streams are not detected. A real parser (or rasterising PDFs to images) closes that gap when documents carry real risk. The scan also refuses /OpenAction and /AA, which ordinary PDFs use to set their opening view; how often that turns legitimate files away is not measured.
  • Object storage versioning and object lock. Garage has neither; production evidence retention should target S3 with versioning and Object Lock through the storage interface.

Case work ​

  • Email and chat notifications. Notifications (an assignment, a breached SLA, an approval request, a failing app or webhook) appear in the console's bell and as webhooks; nothing is emailed or posted to chat, and nobody can mute a kind. A per-person channel would hang off the worker's dispatcher (apps/worker/src/outbound).
  • Auto-assignment and queue ownership. Queues are saved filters and a run can give its case a routing label they select on; assigning cases to people by rule, and queues that own their cases, can be layered on assigneeId later.
  • Case reopen. A closed case cannot be reopened.
  • Mentions and rich notes. Case notes are plain text of up to 10 000 characters, without mentions of colleagues or formatting.
  • Keyboard-driven review. The workbench and case page have no shortcuts to approve, reject or open the next case.
  • Bulk actions that fail part-way. POST /cases/bulk applies ids one at a time and lists the cases that cannot change, but an unexpected error answers 500 after earlier ids were applied, so the caller cannot tell which were. It should report every id.

Definitions and policy packs ​

  • Policy pack import screen. The console's import dialog takes an uploaded or pasted pack, lists problems as flat path: message lines and shows the created versions only after the import. It should show the API's per-item report and, before anything is written, the versions an import would create, which needs a validate-only mode on POST /definitions/import.
  • Transactional pack import. POST /definitions/import validates every item before writing and then writes sequentially, because the route layer has no handle to open one transaction across repositories. A unit-of-work dependency on AppDeps (one scoped transaction passed to every createVersion, upsert and publish) would make a part-way failure leave nothing behind; today it leaves inert drafts.
  • Pack export with dependencies. GET /definitions/export?keys= exports exactly the definitions named: a workflow comes without the rule sets, rules, flows and lists it depends on, so the pack fails to import elsewhere unless every dependency is listed by hand. A closure=true parameter (and --closure on the CLI) would add each dependency once, in a stable order.
  • Flow checks on save and publish. Saving or publishing a collection flow applies only the core schema (unique step ids, existing entry and targets, field shapes); the validator behind POST /collection-flows/validate is advisory. A published flow can therefore hold duplicate field keys, a select without options or a branch cycle, which leave applicants unable to finish.

Audit ​

  • Audit hash chain and retention. Exports ship with digests; a per-tenant hash chain over audit rows (tamper evidence) and retention policies come later.
  • Scheduled export destinations. Scheduled audit exports write to the deployment's object storage, in UTC, buffering each file in the worker (256 MiB at most). A tenant's own bucket (with a credential reference), streaming multipart uploads for larger files and schedules in a tenant time zone are not built.
  • Settings changes in the audit trail. tenant.settings.updated records the embed origins only; changes to the SLA and approval settings leave no detail in the trail.

Security and privacy ​

  • Per-tenant token secrets. One SUBMISSION_TOKEN_SECRET signs every tenant's collection links. An admin revokes a tenant's links by moving its linksValidAfter; rotating the secret itself still invalidates every tenant's links.
  • Free text is not masked. Responses are redacted for the caller (personal data without subjects:pii, run context and app payloads without runs:context), but free text comes back as stored: a list-lookup rule's reason quotes the value it found (an email on a blocklist, say), and decision messages, notes, approval comments, file names and rejection reasons are unmasked.
  • 'unsafe-eval' in the console's CSP. The console's schema forms validate with Ajv, which compiles each JSON Schema at run time, so its Content-Security-Policy allows 'unsafe-eval' (scripts still load from its own origin only). Precompiled or CSP-safe validators would remove it.
  • The console's /api proxy is open-ended. nginx forwards every API route and method except /api/docs, inbound vendor webhooks and applicant routes included, and the dev server forwards everything; an allow-list of what the console calls would narrow it.
  • Search terms in logs. The API logs request URLs with their query strings (apps/api/src/app.ts), which carry search terms such as names, and nginx's error log writes the full request line on upstream errors; the access logs of both apps already keep paths only.
  • Gates that differ between console and API. The audit event sheet shows request metadata to admins only but copies and prints the whole payload.
  • Mutual TLS to the runner. The API, the worker and the app runner authenticate each other with one shared bearer token (APP_RUNNER_TOKEN) over the cluster network, and the chart's NetworkPolicy limits who reaches the runner. Mutual TLS between them is left to a service mesh; certificates of their own would matter on a deployment without one.
  • An external secret manager for the store. App secrets and outbound webhook signing secrets are sealed in Postgres under one deployment key (SECRET_STORE_KEY). Keeping them in a secret manager (Vault, a cloud KMS), or wrapping the key with one, would add rotation without re-sealing and an audit of their use outside the database; the trigger is a deployment whose policy forbids keys held by the application.
  • Local origins on production Zitadel applications. The Zitadel seed turns on development mode for any application with an http:// origin, so adding http://localhost:5176 to a production admin-console application, to let a local console sign in, puts that application in development mode; use a separate one.

Operations ​

  • Deploying without the repository. The repository is private, but parts of a deployment still come from a checkout of it: the compose stack (deploy/compose: the compose file, the Caddyfile, the env example and the Postgres init scripts), the chart's README (deploy/helm/README.md, outside the packaged chart), the Grafana dashboards and alert rules (deploy/grafana), the Zitadel seed (pnpm --filter @aletheia-dev/auth seed) and tenant provisioning (pnpm tenant:add). Each needs a published home (a deploy bundle beside the images, the chart itself, or a command in an image, as the operator's commands have in the API and migrate images, node dist/ops.js) before a customer deploys on their own.
  • An operator console. The deployment's operator acts through pnpm ops (node dist/ops.js in the API's image): the instance-wide list of tenants' apps, blocks, platform versions outside a release, the review of tenants' versions, tenants' app quotas and their use, and the instance's own audit trail, each action named by a --by that nothing checks, and an alert rule as the only signal that reviews wait. The trust policy for modules' provenance is the environment's (APP_SIGNATURES, APP_TRUSTED_SIGNERS), read at start. Once instance administrators exist (see tenant self-service), an Instance area of the console over /instance routes, behind an instance-administrator role in Zitadel and a recent sign-in, would put these on screens under an authenticated operator, with in-app notifications for reviews waiting and the trust policy kept in the database; the commands would stay for scripts and break-glass.
  • Sigstore trusted roots on Kubernetes. Where the API has no egress to Sigstore's TUF repository, APP_SIGSTORE_TRUSTED_ROOTS lists trusted_root.json files the API reads, but the chart mounts no file into the API (no api.extraVolumes), so an air-gapped cluster that verifies keyless signatures needs an image of its own; the trigger is such a cluster requiring signatures.
  • Error-biased sampling. Traces are head-sampled (parent-based ratio); errors are not force-kept. Tail sampling in the collector (keep every trace with an error span) is the production answer and needs a collector pipeline, which the LGTM dev image does not model.
  • Benchmark regression comparison. The benchmark gates on fixed p95 budgets and keeps its results per commit; comparing against the previous run and failing on relative regression waits for a stable reference machine.
  • Load test on the reference VM. The numbers in docs/performance.md come from a laptop running the compose stack, where Postgres and the Temporal server saturate first: API latency met its target (p95 under 300 ms at 50 virtual users), but 14 % of runs outlived their 30 s budget. Re-measure on the reference VM before quoting the numbers as sizing guidance.
  • In-cluster dependency subcharts. The Helm chart assumes external Postgres, Temporal, Zitadel and object storage; optional subcharts for a self-contained dev cluster can follow.
  • Managed service recipes. Temporal Cloud and managed Postgres (RDS, Cloud SQL) values and notes are not written yet.
  • Log-based alerting and exemplars. Alerts come from metrics only; Loki alert rules and exemplar links from histograms to traces are not configured.
  • Worker autoscaling on queue depth. The HPA on Temporal task-slot usage is documented but off; scaling on task-queue backlog needs the Prometheus adapter and Temporal's backlog metrics.
  • Package builds before TypeScript 7. tsup's declaration build sets baseUrl, which TypeScript 6 deprecates, so tsconfig.base.json carries "ignoreDeprecations": "6.0". TypeScript 7 drops the option: the packages need a declaration build without it (tsdown, or tsc emitting declarations) first, and the setting goes with tsup.
  • Blue/green chart upgrades. Upgrades are rolling with a migration hook; a blue/green or canary strategy for incompatible interpreter changes is not provided.
  • Duplicate env names caught at render time. A workload's env or extraEnv that repeats a name the chart sets on that container, or one in the other, passes helm lint and helm template and fails only when Helm applies the release server-side (duplicate entries for key), possibly with part of the release applied (deploy/helm/README.md, "Values"). A fail in the templates, or the schema refusing the chart's names as it refuses APP_RUNNER_ALLOW_PRIVATE_NETWORKS, would stop it before the apply; the trigger is a values file that needs one of those names.
  • API address resolved once in the web image. nginx resolves the API host when it starts, so in compose a recreated API container is unreachable from both apps until the web container restarts; a resolver with a variable upstream would re-resolve it.
  • Outbound webhook metrics and retention. Deliveries and notifications have no metrics or alert rule (a given-up delivery is a log line, a notification and a Home item), and webhook_deliveries, notifications and their read marks are kept forever; delivery counters by outcome and a retention job follow once tenants rely on webhooks.
  • Webhook delivery throughput. Each worker attempts eight deliveries at a time and one worker dispatches up to 200 events every two seconds; a tenant with a slow receiver delays the others' deliveries on that worker. Per-endpoint concurrency and fairness wait for real volumes.
  • Index builds that block writes. Migrations create indexes on existing tables without CONCURRENTLY (0015, 0041, 0043, 0044 and 0045 among them, on workflow runs, app webhook events, drop-off events, app installs and the audit trail), blocking writes on the tables they index while they run, and the migration lint does not flag such builds; on a deployment with data, apply them in a quiet window.

Docs ​

  • Versioned docs. The site documents main only. Per-release snapshots of the docs come with 1.0, when the API and SDK stop changing between minors.
  • Translated docs. English only; VitePress supports locales once customers need another language and someone commits to keeping it current.
  • Hosted search. Search is VitePress's built-in local index, built at deploy time; a hosted service (Algolia DocSearch or similar) is worth it once the site outgrows the client-side index.
  • example-app CI job goes strict. examples/apps/acme-screening is built and tested against @aletheia-dev/app-sdk, @aletheia-dev/app-host and @actanomos/aletheia-core packed from the workspace (scripts/ci-local/example-app.sh), installed over its lockfile, because the SDK's first release is not on npm yet. Once it is, refresh the example's package-lock.json and let npm ci install the published SDK, so a release that breaks external authors fails CI.
  • create-aletheia-app npm initializer. pnpm app:new (scripts/app-new.mjs) scaffolds an app from templates/app, in-repo or standalone, but only from a checkout, which app authors outside the project do not have; they follow the authoring tutorial instead. An npm create aletheia-app package gives them the scaffold.
  • Platform release notes on the docs site. Each platform release's notes (scripts/release-notes.mjs: features, fixes, migrations, workflow compatibility) go to its GitHub release, which the private repository keeps from customers; the changelog page says so. Rendering them into the site when it is published, from the releases, needs no commit to main; worth it before customers upgrade between releases.
  • Screenshots and recorded walkthroughs in the tutorial. docs/start/first-decision.md describes the console and terminal screens in words beside each API call. Screenshots and a recording wait until the UIs settle, since every layout change would make them wrong.

Admin console ​

  • Walk-through on a real deployment. The console is tested against mocks only: sign-in and its return path, the /api proxy with a real token, uploads to object storage (an attachment on a case), the schema forms under the CSP and every write path have not run end to end. Deploy the current code, sign in as an admin and as an analyst and walk every screen; check the inline PDF preview in Safari and Firefox (Chrome works). Then revisit keeping Settings out of the analyst navigation (queue managers reach Settings › Queues from the workbench).
  • Expired sessions. A 401 on a load sends the user to sign-in, losing open dialogs, and a 401 on a save, claim, decide or approve shows a generic error toast; neither tries one silent token refresh first, and there is no session-expired card. Toasts for 5xx errors offer no Retry, and the 403 page names neither the refused API route nor where to ask for access.
  • Lists cut off without saying so. A case shows at most 50 documents and 50 vendor checks, a run 50 invocations, 50 callbacks and 50 cases, and an approval card's backtest reads the last 50 backtests, none with a "more" marker.
  • Earlier warnings in the http-api editors. The install editor warns when a credential field names no secret variable, but not about a secret placeholder typed by hand into the config or a secret variable in the name of one of the install's headers or parameters; the app refuses each of them on every call, so the warnings would only come sooner.
  • A path picker for the app rule's input mapping. The app rule's inputMapping is edited as JSON, as rule types offer no data paths to pick from; the call_app step picks them from the run's context.
  • Request load. The definition lists fetch pending versions with one request per row, and the runs page resolves workflows the same way, uncapped and with failures swallowed; the runs page reloads every 5 s without waiting for or cancelling the previous request; Home makes about 18 requests on an admin's first load, some of them twice; and every mount refetches, since there is no shared query cache.
  • Editor chunks. The Define area imports the schema forms (rjsf and Ajv, 119 kB gzipped) and CodeMirror (137 kB gzipped) statically, so the rules list loads both before an editor opens.
  • Audit export in memory. The export download holds the file three times (the chunks, the decoded text and a Blob) while it hashes it; it should stream and hash incrementally.
  • SLA overrides and drafts. Settings › SLA reads each workflow's latest version, so a workflow with a newer draft drops out of the per-workflow overrides.
  • Accessibility. Accent text (#ec3013) is below 4.5:1 on the console's backgrounds (3.5 to 4.2:1) on the active navigation item, ghost buttons, outline tags, card kickers, plain links and the link buttons of several areas; the darker accent (#ae1800) reaches 6.4:1. Reasons for disabled controls are only in title; below 1280 px the health indicator is a colored dot; no table header is sortable (aria-sort; cases sort through a select); a run's steps grid is ARIA roles on divs; four role="menu" lists handle Escape but no arrow keys; and the workflow canvas has not been tried with a keyboard.
  • Tests and CI. Sign-in (AuthProvider, SignInGate), 401 recovery, hooks.ts and overlays have no tests. The database suites for runs, case counts, claim-next, bulk actions, audit by id and app health skip in CI, whose test job sets no DATABASE_URL, and the smoke scripts call none of those routes and never load the console. The console's nginx config and headers are checked only by helm test, in a job that runs when deploy/ or the Dockerfile change.
  • Build id. The console shows no version or build id.
  • Duplicated code. The flow preview's sample check (src/areas/flows/sampleCheck.ts) restates the validation of @aletheia-dev/collection-flow, which a lint rule keeps out of the console, so the two can drift. The _new create path is defined in the palette and in src/areas/define/governance.ts, and editor styles are copied into src/areas/define/define.css.
  • Field properties and the live preview. Fields accept a unit and author messages, which the terminal renders, but the field editor has no inputs for them, and saving a flow in the console drops both (cleanField in src/areas/flows/flowModel.ts). The live preview renders the shared @aletheia-dev/collection-flow-ui components in the console's look, counts every step and ignores branches; it should render the terminal's components with the tenant's theme, at phone and desktop sizes, so authors see what applicants see.
  • Language and week start. The console is in English and formats dates in the browser's locale; a workspace language and first day of the week wait on a message catalogue like the terminal's.
  • Search inside names. GET /search matches subject names, legal names and external ids by prefix, which the expression indexes cover; finding a word inside a name needs trigram indexes (pg_trgm). Results carry no highlight ranges: the palette marks the typed text itself.
  • Screens the API already supports. Not built yet: a bundled starter pack and a tutorial link on Home; optimistic claim and assign, and the open case's row highlighted in the workbench; a 90-day window on a subject's risk history and "widen range" in filtered empty states; "was x in vN" hints beside changed fields, published and draft versions in one cell on the workflow and flow lists, and dragging step types and fields; on Connect, a health filter and a 24-hour window, a failures-only view, an invocation's raw request and response (GET /app-invocations/{id}) and a status filter on callbacks; decided app publish requests and install changes under Operate › Approvals › Decided (GET /apps/approvals?open=false, GET /app-install-changes?status=decided), which lists the definitions' only; related records by name in the audit event sheet and a File column on past exports.
  • The install editor's hosts preview. The editor previews the hosts a draft config gives under the platform's strictest rules, since it knows neither the deployment's own hosts nor whether private networks are allowed (APP_RUNNER_ALLOW_PRIVATE_NETWORKS). On a development stack an http:// or 127.0.0.1 URL therefore shows an issue there, and the API names the host to approve when the install is saved; elsewhere a URL on one of the deployment's own hosts passes the preview and is refused on save. A route that reads the API's host rules would make the preview exact.
  • Conditions in the install form. The install editor renders a version's configuration schema with rjsf, which, once the schema holds a condition (if/then) anywhere but at its top level, adds each absent object field of the configuration back, empty, whenever another field changes, so a form whose optional object has required fields cannot be saved. http-api's form leaves its conditions out and checks the rule itself; leaving out every app's conditions and checking them with the save would serve any app. The trigger is a tenant app whose configuration has one.

Collection terminal ​

  • Previews with sample data. A flow preview link (/preview) opens the version empty: the sample answers typed in the console's live preview do not travel with it.
  • Walk-through on a real deployment. The web image serves apps/collection-terminal on port 5177 behind the collect host, with the /api proxy, a paths-only access log, a Content-Security-Policy, 404s for unknown addresses and deployment-wide branding from the COLLECTION_TERMINAL_* settings. It is tested against the mock API and inside the web image, not yet end to end: a link from a run, uploads to object storage, the submit resuming the run, and the frame on a merchant's page.
  • Bundle size and request waterfall. The terminal ships about 136 kB of gzipped JavaScript (a 97 kB entry chunk with React and Zod, then a 39 kB app chunk) against a target of 90 kB; CSS (7 kB) and the loader (under 1 kB) meet their targets of 15 kB and 2 kB. Getting there needs package work rather than app work: a zod/mini build of @aletheia-dev/collection-flow's validation, or core schemas that tree-shake (pure annotations or per-module output). The first step also waits on two dependent requests after the entry script (the app chunk, then the submission with the tenant's look) against a target of two.
  • Performance on a phone. Not measured. The target, under Lighthouse mobile throttling (slow 4G, 4× CPU slowdown): the first step interactive within 3.5 s, and at the 75th percentile LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1.
  • Accessibility checks. The terminal is built for WCAG 2.2 AA but has no automated check (no axe in its tests) and no manual pass with VoiceOver on iOS and macOS, TalkBack and NVDA, hosted and embedded, light and dark, at 320 px and at 400 % zoom. Page titles name the step and the tenant but not the position ("step 2 of 3").
  • Browser targets. vite.config.ts sets no build.target, so the build follows Vite's default (Chrome and Edge 111, Firefox 114, Safari 16.4). Set the supported browsers explicitly (including Samsung Internet and the in-app browsers of mail and chat apps) and try them.
  • Unsaved answers. Answers save shortly after each change and on Continue, but not when a field loses focus or the page is hidden (pagehide), and closing a hosted page with unsaved answers gives no warning (beforeunload).
  • After the submit. "You can close this page" is missing on the processing, approved, not approved and failed screens (the first screen says it updates by itself); the support contact shows only on the review, not approved and already-sent screens; the compact screen inside a frame has neither. Status polling stays at a fixed 2 s for up to 10 minutes and should back off after the first minute. No "Done" action clears the token from the tab (forgetToken in src/token.ts is unused).
  • Link and error states. Load failures are mapped by HTTP status rather than by error code: a missing submission (404) shares the "link not valid" screen, and the server-error and offline screens offer no support contact (the hosted header's Help aside). The aletheia:error posted on a load failure carries no request id; the app keeps one for 5xx errors only.
  • Poor networks. Saves retry with back-off, but reads are not retried automatically, and the upload to object storage (an XHR in packages/api-client/src/documents.ts) has no timeout.
  • Broken flow definitions. A flow the validator would reject renders without crashing, but the applicant is stuck: a select without options keeps Continue blocked, and a branch cycle comes back from the submit as a 400 without issues, shown as "Check this answer". Neither gives a reference to quote to support or a way on (see "Flow checks on save and publish").
  • Embedded frames. The origin check runs only with embed=1: framed without it, the page renders as hosted, guarded only by the deployment-wide frame-ancestors. The loader hears about step changes but does not scroll the frame's top into view, and the embedding page hears nothing about upload outcomes.
  • Content-Security-Policy. The terminal's policy (rendered by deploy/web/entrypoint.sh) allows form-action 'self' where 'none' would do, and falls back to connect-src https: when no storage origins are configured.
  • The terminal's own words in other languages. Flows come in the tenant's languages, but the terminal's own words (buttons, notices, step counts, the welcome screen's lists, error messages from the engine's defaults) stay English and dates and numbers are formatted for en-US, so a French form reads "Continue", and on a right-to-left page an English sentence ends with its full stop at the wrong end. Copy lives in per-module copy.ts files with hand-written plurals and a few inline strings. Needed: a message catalogue with plural rules and locale-aware formatting, and its translations for the languages tenants offer.
  • Theme tokens. Colors, radii, fonts and borders are tokens a tenant's branding overrides at run time, but spacing (about 70 values) and font sizes are literals, so density cannot be themed, and one white is hard-coded (src/ui/ui.css).
  • Client error reporting. None. An optional reporter, off unless a deployment enables it, would send errors with their request ids and no personal data.
  • Web Component embed. The iframe loader is the only way to embed; a Web Component built from the same Vite library build can follow if merchants need inline UX.

Released under the Apache-2.0 License.