Trust centre

What is built, and what is not

Twenty-three capabilities, the level each one is at, the file or script that is the evidence for it, and what stands between it and the next level. This page is generated from the repository — none of it is written by hand, and a claim whose evidence disappears fails our build.

Live
25
Beta
8
In progress
8
Planned
1

Live means a named script has been run against production, not that tests exist. That bar is why most of this list is not live, and why the ones that are can be relied on.

Foundation

Tenant isolation

Live

Every table carries an organisation and the database refuses cross-tenant reads — not the application, the database.

Evidence

  • src/lib/server/db.ts — withOrg() sets systeni.organization_id per transaction
  • src/lib/server/schema.ts
  • scripts/prove-onboarding.mjs — 'another tenant cannot read these answers', run against production
  • scripts/prove-tenant.mjs — asks pg_catalog directly: every table carrying an organization_id is enabled, forced and carries a policy, in the database this is connected to

Not there yet

  • Found today and fixed the same day, and it is recorded here because a level this high has to carry its misses: knowledge_chunks — the passages of every uploaded document — had no policy in production. It was listed in the RLS file and the file is applied by hand, so the migration that created the table ran and the list did not. Nothing leaked, because every read and write of it goes through withOrg and filters explicitly; the backstop under that was missing for the one table whose entire content is somebody else's material
  • The audit that should have caught it read the migration file rather than the database, and so was green throughout. Reading the intent will always pass once the intent is written down. prove-tenant now asks Postgres, and redeploy.sh applies the policy file on every deploy rather than leaving it to somebody's memory

Accounts, sessions and one-time links

Live

Sign-up, sign-in, password reset, address confirmation and invitations. Links are single-use, hashed at rest, and say which way they failed.

Evidence

  • src/lib/server/auth.ts
  • src/lib/server/tokens.ts
  • scripts/prove-auth.mjs — 15 assertions against production, including a simultaneous double redemption

Roles and capabilities

Live

Four roles and seventeen capabilities. Every mutation is checked on the server; the interface only decides what to show, and a refusal names the permission that was missing.

Evidence

  • src/lib/server/capabilities.ts
  • src/lib/systeni/types.ts — can()
  • scripts/prove-access.mjs — 10 assertions against production, including all 68 role-capability pairs

Not there yet

  • No per-object permissions — a role that can reply can reply to everything
  • No custom roles

Rate limiting

Live

Sign-up, sign-in, resets, invitations, one-time links and inbound traffic are bounded per origin and per subject.

Evidence

  • src/lib/server/ratelimit.ts
  • scripts/prove-limits.mjs — refusal proved at the documented ceiling, against production
  • scripts/wiring-audit.mjs — reports any limit defined and never enforced

Not there yet

  • Counters are per node; a second application server would double every ceiling

Audit trail

Live

Two records, deliberately separate: what people did through the API, and what the machine did on its own. Every step, decision and approval is written as it happens rather than reconstructed afterwards.

Evidence

  • src/lib/server/graph.ts — the event stream
  • src/lib/server/schema.ts — auditLog for user actions, events for machine actions
  • src/lib/server/api.server.ts — record()
  • scripts/prove-engine.mjs — 18 assertions against production, including the approval gate holding against repeated advances
  • src/lib/server/audit.api.ts — the audit log screen for owners and admins, and its CSV export (0049)
  • src/lib/server/retention.ts — per-workspace retention periods and the daily purge (0049)

Not there yet

  • The event stream is not exportable from the console. The audit log is, as CSV, since 0049 — built and tested, not yet run against production
  • Retention periods per workspace exist since 0049 and have not yet purged anything in production; the database-backed proofs run in CI only
  • An actor deleted from the system leaves their entries with a null actor — deliberate, and the reason ten orphaned sign-up rows exist in production from proof runs whose scratch users were cleaned up. Investigated twice before anybody wrote it down

Backups and restore

Live

The database is dumped nightly and the dump is restored into a scratch copy weekly, which is what makes it a backup rather than a file. A failure of either mails somebody.

Evidence

  • deploy/backup.sh
  • deploy/restore-test.sh
  • deploy/alert.sh — one mail per failed job, and never the key
  • scripts/log-audit.mjs — nothing in the server writes a secret or a customer's words to the journal, which is what that mail carries
  • deploy/systeni-alert@.service — what OnFailure points at on every scheduled job
  • scripts/prove-alerting.mjs — 12 assertions on the server, including a unit made to fail on purpose, one real message posted, and the watchdog having no handler of its own
  • scripts/argv-audit.mjs — no script hands a secret to a command, where ps could read it
  • Run on 13 August 2026: 35 tables and 2 users restored into a scratch database, then dropped

Not there yet

  • One machine. The dumps sit on the host they were taken from, so the failure they do not survive is the one that takes the disk — off-site storage is a cost the owner has deliberately deferred
  • Nightly, so the most that can be lost is a day. Point-in-time recovery would need WAL archiving and somewhere to put it
  • Until today the restore test had never run on this installation. The timer existed and its first firing was still four days away; it works, and nobody knew that
  • The dump is read for the tables it cannot be restored without, but not for row counts. A dump of the right database on a day when a table was accidentally emptied would still pass
  • Fixed the morning after it shipped: the table check ran zcat into grep -q per table, and grep -q closes the pipe at its first match — so under pipefail a table found early in the dump was reported missing and one found late was not. It failed the backup at 02:32 for a table that was demonstrably there

Phones and installed use

Live

Fifteen screens measured in a real browser at four phone sizes: nothing runs off the edge, nothing is under the touch floor, and the safe areas are paid by the elements that touch them.

Evidence

  • scripts/mobile-audit.mjs — a real browser at 320, 390, 412 and 768px, public pages and the console
  • scripts/overlap-audit.mjs — for every text node: is something opaque on top of it, and is more text there than is being drawn. 27 public pages and 15 console screens, down the whole page rather than the first screen
  • src/styles.css — the safe-area insets, the touch floor, and min-width on grid and flex items
  • public/site.webmanifest — standalone display, maskable icon, shortcuts

Not there yet

  • Chromium only. WebKit differs on exactly the things that matter here — 100vh, form controls, safe areas — so an iPhone is reasoned about rather than measured
  • Landscape is not walked. The safe-area inset that matters most on a notched phone is the side one, and it only appears when the phone is turned
  • The whole of this entry was green while the home page looked, on the owner's phone, like a sentence sliced along the top of the screen with a badge printed across the label beneath it. The audits measured whether things fit; nothing asked whether one thing was painted over another, and both boxes were exactly where the stylesheet had put them. Fitting and not-covering are different properties, and only one of them was being checked
  • Fixed with it, and it had been open since the entry was written: 'a heading too wide for its container is cut off silently and this does not say so'. It was not hypothetical — every row of the home page's activity list was cut mid-word, in the panel whose job is to show the product working. Comparing scrollWidth against clientWidth is the check that was missing

Browser-side hardening

Live

HSTS with preload, nosniff, a frame policy, a referrer policy, a permissions policy, and a content-security policy that enforces rather than observes.

Evidence

  • deploy/Caddyfile
  • scripts/csp-audit.mjs — 29 public routes fetched and checked against the policy that is actually shipped

Not there yet

  • 'unsafe-inline' stays in script-src and style-src, because the framework inlines the hydration script and the critical styles. So the policy stops a script loaded from another origin and does not stop one somebody managed to inject inline — closing that needs per-request nonces
  • No reporting endpoint. Enforcing means a violation breaks rather than reports, which is the right trade here and does mean nobody sees the ones a browser refuses
  • Until today the policy was Report-Only with no report-uri and no report-to, and a comment saying it collected what it would have blocked. It collected nothing: it neither blocked nor told

Handover — a copy the customer runs

Live

The terms say a customer keeps the system when the engagement ends, or when they buy a single build instead of subscribing. This is the thing that makes that sentence true: the application, the schema with its isolation policies, every row belonging to them, an .env.example naming the two accounts they must replace, and a start script.

Evidence

  • scripts/handover.mjs — built from `git archive`, so it carries what is committed and no key, no build, no deploy state
  • scripts/prove-handover.mjs — 17 assertions: no live credential of ours anywhere in it, no other tenant's rows, and it loads into a fresh database with row-level security still on
  • src/routes/_site/terms.tsx — clause 8 names the test, and the test exists

Not there yet

  • Written today because the contract was ahead of the product, which is the one direction it must never be. Clause 6 had promised 'you keep the system' since the terms were published, and the only thing that could be handed over was tenant.mjs export — a JSON file of rows. Nobody can maintain a JSON file
  • The proof restores the bundle and queries it; it does not npm install and start the application. The database half is asserted, the runtime half is a person following start.sh
  • The cross-tenant assertion is currently vacuous: there is one organisation on this server, so 'no other tenant's rows' has nothing to find. It becomes a real check with the second customer and is written now so it is not written later
  • A customer who modifies their copy is on their own with it, which is what clause 9 grants and clause 10 disclaims. There is no supported upgrade path from a handed-over copy back to an engagement

Who operates it, in law

Live

Systeni Limited, incorporated in Hong Kong on 18 August 2026, company and business registration number 81059428. The imprint, the privacy controller, both sets of terms and the governing-law clause all read from one module, so the operator is a fact stored once rather than a name written in five places.

Evidence

  • src/lib/legal/entity.ts — both companies, with OPERATOR derived from whether the current one's three required fields are filled
  • docs/COMPANY.md — every value with the certificate it was read from, and the renewal dates nothing in the code can check
  • docs/PARTNER_AGREEMENT.md — the party clause, the notices table and the signature block now name the company rather than a placeholder
  • scripts/legal-audit.mjs — asserts the operator is complete, that no page renders a company name as a literal, and that the protected PI-NNC1 details are absent

Not there yet

  • Whether an EU representative is needed under Article 27 GDPR is open. It turns on whether the offering targets the Union, and the site presents as global: English by default, prices in US dollars, and twenty-two locales including Japanese, Korean, Arabic and Hindi. The owner confirms it is sold globally. None is named, and none is invented — a question for a lawyer who knows the commercial plan, recorded rather than resolved here
  • No solicitor has read the terms or the partner agreement. Both are working drafts written from decisions the owner actually made; the four clauses that decide what happens when a partnership ends are marked in the document
  • The product now prints reverse-charge wording on a tenant's invoice when a person marks the buyer for it (the invoices entry). Systeni's own subscription invoices come from the payment provider, and whether an invoice from a Hong Kong supplier to an EU business needs that wording is still the accountant's question, not answered in code
  • The business registration expires 17 August 2027 and the annual return falls due 42 days after the incorporation anniversary. Nothing here can check either — they are diary entries for a person

Data requests and deletion

Live

A customer asking for a copy of what is held about them, or for it to be deleted, can be answered — every tenant-scoped row as JSON, or removed and counted afterwards to be sure.

Evidence

  • src/routes/_site/privacy.tsx — the promise being kept
  • scripts/tenant.mjs — list, export, delete; the slug typed twice before anything is removed
  • scripts/prove-tenant.mjs — 17 assertions against production: what the customer said is in the export, the password hash is not, and nothing survives the deletion

Not there yet

  • By hand, not self-service. The privacy page says to write to us, which is what the law asks for and what this answers; a button in the console would be better and is not there
  • An export is JSON. It is complete and it is not a format anybody enjoys reading — 'portable' in the legal sense, not in the human one
  • Deleting a workspace does not delete the people in it. Somebody can belong to two, so that is a decision rather than a consequence, and the tool reports the accounts left behind instead of acting on them
  • Nothing is deleted on a timer. A customer who leaves and says nothing keeps their rows until somebody runs this

Execution

Execution engine

Live

Durable runs and steps, executed in order, and a step may carry a condition answered when it is claimed. Work is claimed by conditional update, so two workers cannot take the same step, and a redelivery is not a second run.

Evidence

  • src/lib/server/engine.ts
  • src/lib/server/conditions.ts — a closed set of operators over JSON, never an expression, because a tenant writes them
  • src/lib/server/engine.test.ts
  • src/lib/server/intake.ts — idempotency on (organisation, channel, external id)
  • scripts/prove-engine.mjs — 37 assertions against production: the approval gate holding against repeated advances, a condition taken and not taken, an unreadable one failing loudly, and a published workflow changing what an arriving message actually runs

Not there yet

  • One process; no queue and no horizontal workers
  • No scheduled retries with backoff
  • Conditional steps, not a graph: no loops, no parallel paths, nothing that rejoins after a branch

Workflow editor

Live

One workflow — the inbound enquiry — is editable and executable. Reorder its cards, remove one, add a step the engine has registered, gate one on a condition built from the fields a step actually writes, save, check, publish; the next enquiry follows the edit.

Evidence

  • src/components/systeni/WorkflowEditor.tsx
  • src/lib/systeni/context-fields.ts — the fields a condition may ask about, checked against steps.ts by audit:fields
  • src/lib/server/intake.ts — planChain, the single reading the editor and the engine share
  • src/lib/server/api.server.ts — saveWorkflowSteps, validating against the step registry and the condition parser
  • scripts/prove-editor.mjs — 10 assertions against production, including an edit changing which steps a message actually runs, in the new order

Not there yet

  • One executable template. The other eight in the library are drawn, labelled 'drawn, not executed' on the card, and five of their condition cards ask for a scheduler, a sentiment or a keyword list that do not exist
  • The check reads the workflow; it is not a dry run. It can say a card names a step that does not exist and cannot say the step will do what was hoped — that needs an execution mode that writes nothing
  • A condition combining several tests runs and cannot be taken apart in the screen. The editor says so on the card rather than offering to edit one and flattening it
  • No version history. Saving replaces what was there, and a customer who breaks a published workflow restores it by editing it back
  • Fixed today, and worth remembering: the editor changed nothing at all. chainFor kept the cards whose kind matched a registered handler, and a card's kind is 'action' where a handler is 'qualify'; it also queried for a template id no template had

Step library

Beta

Twelve kinds run today: qualify, record, answer, offer, reply, task, approval, quote, quote.send, invoice, invoice.send and connector. The offer step now books against a real diary — opening hours per weekday in the tenant's own timezone, every taken slot blocking, and the database refusing a double booking.

Evidence

  • src/lib/server/steps.ts
  • src/lib/server/slots.ts — availability against the tenant's hours, read through Intl rather than by adding an offset
  • drizzle/manual/0002_appointments.sql — an exclusion constraint over the time range, because the window between reading the calendar and writing to it cannot be closed above the database
  • scripts/prove-booking.mjs — 19 assertions against production, including three simultaneous bookings of one slot where exactly one survives
  • src/lib/server/engine.test.ts

Not there yet

  • No step writes to an outside system, because no adapter exists yet — an appointment lands in this calendar, not in Google Calendar, which needs an OAuth client
  • No reminder before the appointment, and no waiting list when one is cancelled. Both are scheduled work on a scheduler that exists; neither is written
  • Fixed today, and it was the largest gap in the product: answerFromKnowledge — retrieval, grounding, the citation check, the confidence floor — was called by nothing at all. There is an 'answer' step now, it is in the default chain, and the agent's own switch governs it
  • No step is conditional on another step's failure — a condition reads the context, not the chain

Model qualification

Live

An inbound message is read, scored, summarised and given an intent and an urgency, or it stops and asks a person.

Evidence

  • src/lib/server/model.ts
  • src/lib/server/steps.ts — the qualify step
  • scripts/prove-qualify.mjs — 10 assertions against production, model answering live

Not there yet

  • One provider, no fallback model
  • No evaluation set — quality is judged by reading

Inbound endpoint

Live

An authenticated HTTP endpoint that accepts a message from anywhere and starts a run. This is how anything reaches the system today, and its contract is published.

Evidence

  • src/lib/server/webhook.ts — a per-organisation shared secret, stored as a peppered digest and compared in constant time; per-origin and per-tenant limits in front of it
  • src/routes/_app/operations.tsx — the endpoint and its secret, shown once
  • src/routes/_site/developers.tsx — the published contract
  • scripts/prove-inbound.mjs — 9 assertions over the network against the live endpoint, including that an unknown organisation is indistinguishable from a wrong secret

Not there yet

  • A shared secret, not a signature. Good enough for a caller we control, and the wrong mechanism for a provider that signs its own webhooks — that check goes in front of this one when there is a provider to have it
  • Callers must send the secret themselves; there is no adapter that does it for them
  • The success path is proved from inside rather than over the network, because a script that carries a real workspace secret is a script that leaks one

Governance

Policy engine

Live

Rules decide before a step acts: go ahead, ask a person, or refuse. Enforced in the engine, not suggested to a model.

Evidence

  • src/lib/server/policy.ts
  • src/lib/server/policy.test.ts
  • src/routes/_app/policies.tsx — including a rule tester
  • scripts/prove-engine.mjs — 18 assertions against production, including the approval gate holding against repeated advances

Not there yet

  • Conditions are exact matches; no ranges or expressions beyond amount thresholds

Approval gates

Live

A held step stops everything behind it until a person decides, records who released it, and says why it stopped in one of eight sentences.

Evidence

  • src/routes/_app/approvals.tsx
  • src/lib/server/engine.ts — claimNextStep() holds the ordering; approveStep(), rejectStep()
  • scripts/prove-engine.mjs — 18 assertions against production, including the approval gate holding against repeated advances

Not there yet

  • No delegation and no time-out — a held step waits indefinitely
  • Was open until 2026-08-13, and the answer was a live defect rather than a bad test: advance() returning at awaiting_approval only closed the gate for the pass that reached it, and advance is called from the scheduler and from intake on every new message in a conversation. A parked run stepped over its own gate the moment the customer wrote again. Rejecting a gate had the same hole one decision later. Both are fixed by ordering in the claim query and proved by the script above

Why did that happen

Beta

Six questions a business owner actually asks, each answered by a query and rendered beside the clause saying which rows it came from. No model: a model given tools produces answers that read well and will one day say a mailbox is connected because the phrasing made that the likely next sentence.

Evidence

  • src/lib/server/professor.api.ts — six answers, every one a count with its provenance and a tone the rows support
  • src/components/systeni/Professor.tsx — the panel, which is deliberately not an ask-me-anything box
  • src/lib/server/professor.api.test.ts — an empty workspace is described as empty rather than as fine; a failed run stops the tone being calm; no workspace is described to another

Not there yet

  • Six questions, and it says so. The ninety-fourth question a customer asks gets 'I cannot answer that, here is where to look', which is honest and is not an answer
  • Nothing has been proved against production — there is no prove-professor.mjs, which is what a 4 would need
  • It reports; it does not act. 'Why did that stop' is answered and 'start it again' is not
  • It reads runs, steps, contacts, tool executions and the go-live checks. It cannot see knowledge, quotes, deals or the diary, so a question about any of those gets the fallback

Handover to a person

Live

When an agent stops, the conversation and the reason go to a queue somebody works. A person can answer the customer from the handover — by email or into the website chat, under the workspace's rules — and the answer is kept on the handover beside the agent's draft; resolving it records who resolved it and takes it out of the queue.

Evidence

  • src/routes/_app/operations.tsx
  • src/lib/server/engine.ts — raiseHandoff()
  • src/lib/server/answers.api.ts — answerHandoff()
  • src/lib/server/human-answers.test.ts
  • src/lib/server/api.server.ts
  • scripts/prove-access.mjs — 10 assertions against production, including all 68 role-capability pairs

Not there yet

  • No assignment, no ownership, no due time
  • `status` is free text with a vocabulary that only exists in a comment — a typo writes a row the console's filters never match, which is quieter than a rejected write. Found by this proof writing "closed" where the column means "resolved", and accepted

Channels

Outbound email

Live

Transactional mail in twenty languages, right-to-left where the language runs that way, with delivery failures logged rather than swallowed.

Evidence

  • src/lib/server/mail.ts
  • src/lib/server/mail-auth-copy.ts
  • scripts/prove-mail.mjs — 17 assertions over 82 intercepted messages: no unfilled placeholder, both parts present, customer input escaped, every link absolute, Arabic marked rtl, no locale falling back to English, the idempotency key reaching the wire

Not there yet

  • Delivery is checked by hand rather than by a proof. Three messages were sent down the ordinary path on 2026-08-21 — to a personal mailbox, to the published contact address and to an address that exists only through the catch-all — and all three arrived. Nothing repeats that automatically, because asserting delivery needs a mailbox to read, and reading one from a proof means credentials the proofs deliberately do not hold
  • One provider, and no per-tenant sending domain — mail is from systeni.com, not from the customer
  • No bounce or complaint handling: a hard bounce is logged and nothing marks the address bad

Channel adapters

Beta

Website chat carries a real conversation: a visitor asks, an agent answers from the customer's knowledge base, and a question it cannot ground opens a handover for a person. Email arrives by forwarding: each workspace gets a private address, a mail provider posts what reaches it to a signed endpoint, and the message runs the same chain — on an installation with a receiving mail domain, and not otherwise. Voice, SMS, WhatsApp and Instagram listen to nothing — messages reach those through the signed endpoint, through a custom connection, or not at all.

Evidence

  • scripts/prove-chat.mjs — 50 assertions against production: a published key bounded by the origin list rather than by secrecy, a refusal that grants no CORS header to a page which has not proved it belongs, and the visitor's question written to the transcript before the model is called, so an enquiry arriving during an outage survives it
  • src/lib/server/chat.ts — one turn: resolve the key, check the origin, store the question, answer or escalate
  • src/lib/server/inbound-mail.ts — forwarded mail: provider signature, the recipient's token naming the workspace, a Message-ID claim against redelivery, threading by In-Reply-To and References, and auto-replies stored without being answered
  • src/lib/server/inbound-mail-protocol.test.ts — the wire format, the signature and the loop rules, without a database
  • src/lib/server/inbound-mail.db.test.ts — against Postgres: the token policy, tenant isolation, dedupe, threading, rotation and refusals that write nothing
  • src/lib/systeni/reach.ts — which channels can carry a conversation, in one place the console imports, instead of three screens each assuming separately
  • src/lib/systeni/studio.ts — NOT_CONNECTED, named in prose on the directory rather than shown as cards

Not there yet

  • Voice, WhatsApp, Instagram and SMS: every adapter
  • Inbound email is built and tested and not switched on in production: no receiving domain, MX record or mail provider adapter is configured there, so no forwarded message has arrived through it yet, and the channels card says so rather than showing an address
  • A reply to a thread whose run has already finished is stored and shown in the inbox, and starts nothing automatically — a person answers it
  • The website chat has been proved against production and has not yet answered a real customer's visitor
  • The seventeen 'coming soon' entries were removed from the customer-facing directory on the owner's instruction, and it was the right call once there was something behind them. They are named once, in prose, with what to do instead — a buyer whose operation runs on WhatsApp has to know before they pay

Waiting on: A WhatsApp Business account and Meta app review; A telephony account and a number; OAuth clients for Google and Microsoft; For email: a mail provider with inbound parsing, an MX record for in.<domain>, and SYSTENI_INBOUND_MAIL_DOMAIN and SYSTENI_INBOUND_MAIL_SECRET on the host

Business systems

Authoritative pricing

Beta

An organisation enters its own prices, and an agent may say which catalogue entry an enquiry is about and how many — never what it costs. The resolver loads the amount itself and refuses an unknown item, an archived one, another tenant's, an unstated quantity or two currencies in one quote.

Evidence

  • src/lib/server/pricing.ts — resolveCommercialLines has no parameter an amount could arrive through
  • src/lib/server/steps.ts — the price step shows the model a catalogue with the prices removed
  • src/lib/server/pricing.test.ts — 18 assertions against real Postgres, including cross-tenant refusal read through the policy with no application filter
  • scripts/mutate-pricing.mjs — 12 mutations, each behaving as expected
  • drizzle/0023_price_bounds.sql — the range enforced at the column, matched to the application constants by a test
  • src/lib/server/quote-step.test.ts — the persisted line now records source_type and source_id, so a quote can be audited back to the catalogue row that produced it; and 250,000 yen stays 250,000

Not there yet

  • No customer has priced a real enquiry with it yet
  • quote_lines.quantity_milli models a fractional quantity nothing produces; the domain is whole numbers and the conversion happens at the write
  • The quote still carries its legacy `_cents` columns beside the canonical `_minor` ones, because the artifact a rollback lands on inserts them. They go one release after the last reader does — docs/money-naming-inventory.md

The quote a customer can inspect

Beta

Every quote the workspace has produced, openable, with the catalogue entry behind every figure and the figures as they were sent — unchanged when the price book moves afterwards.

Evidence

  • src/lib/server/quote.ts — createQuote has no parameter a total could arrive through; the totals are derived and the lines snapshot what priced them
  • src/lib/server/quote.api.ts — two reads behind requireOrg, assertCan and withOrg; another tenant's id returns the same answer a deleted one does
  • src/routes/_app/quotes.tsx — the list, and an empty state that says what a quote is and what produces one
  • src/routes/_app/quotes_.$quoteId.tsx — the detail and the provenance panel: the snapshot on the left, the catalogue as it stands now on the right
  • src/lib/server/quote.test.ts — 12 invariants against real Postgres, six of them properties of Postgres rather than of the code
  • src/lib/server/quote-step.test.ts — the A9 path proved end to end: canonical columns, provenance, 250,000 yen staying 250,000, the snapshot not moving when the catalogue does
  • scripts/mutate-quote.mjs — 12 mutations, each behaving as expected
  • scripts/prove-quote-ui.mjs — 12 assertions in a real browser against the release-candidate artifact, and mutation-proved: rendering the current catalogue name in the snapshot column fails exactly one of them
  • docs/releases/2026-08-30-866736e.md — what was deployed, measured rather than intended

Not there yet

  • prove-quote-ui has never run against production. It creates a workspace and a quote, so running it there would put synthetic commercial records in a customer database — the level-4 criterion this file states, unmet on purpose until a production-safe test organisation exists or a customer relies on it
  • No customer has produced a quote with it
  • Nothing moves a quote from this screen. Sending happens inside a run, behind the approval gate, which is deliberate — but it means an operator who wants to resend must go through the workflow
  • The quote list caps at 200 and says so; there is no search and no pagination behind it
  • The quote total reaches the activity feed through events.amount_cents, which is still a narrow integer with no minor-unit peer. Above its width the amount on the event is absent rather than wrong — Money Storage Expansion, not this capability

Invoices with seller, buyer and VAT

In progress

An invoice issued from a quote carries the seller and buyer with full addresses, the seller's tax number or VAT ID, the invoice and supply dates, a gapless number, quantity, description and rate per line, and net and VAT per rate — or the small-business or reverse-charge note — as an A4 PDF in German or English and as UBL XML in the XRechnung 3.0 structure. Issued invoices are never edited; a correction is a cancellation invoice. Which VAT treatment applies is the customer's decision with their tax advisor.

Evidence

  • src/lib/systeni/invoice-compliance.ts — the rules without a database: field checks (IBAN checksum, VAT ID shapes), the rate a line gets and the refusal when it has none, tax rounded once per rate group half away from zero, and the cancellation lines
  • src/lib/systeni/invoice-compliance.test.ts — rounding edge cases to the minor unit, amounts past 2^53, a cancellation reversing every figure exactly, and every refusal by name
  • src/lib/server/steps.ts — the invoice step: escalates with a translated reason when settings, a buyer address or a rate are missing, snapshots the document at issue time, and returns the existing invoice on a retry
  • src/lib/server/invoice-pdf.ts — the A4 document, from the snapshot only, in a standard font whose encoding carries umlauts, ß and €
  • src/lib/server/invoice-xml.ts — EN 16931 UBL 2.1 with the XRechnung 3.0 CustomizationID, escaped by one helper
  • src/lib/server/invoice-documents.test.ts — the PDF parsed with unpdf and searched for every § 14 Abs. 4 UStG field and total; the XML checked against BR-CO-10, BR-CO-14, BR-CO-15, the per-rate breakdown, dates and escaping
  • src/lib/server/invoice-cancellation.ts — the one correction: the next number, the original's lines reversed, one per invoice, audited in the same transaction
  • src/lib/server/invoice-link.ts — the download link in the invoice mail, signed over organisation, invoice and expiry
  • src/lib/server/invoice.api.ts — settings and billing details for owners and admins, downloads for members, all behind requireOrg, assertCan and withOrg
  • src/lib/server/invoice.step.test.ts — the step's refusals, the retry, the link and the role checks, without a database
  • src/lib/server/invoice.db.test.ts — against Postgres in CI: the snapshot from a real run, another organisation unable to load, download or cancel, the immutability trigger, the cancellation
  • src/routes/_app/invoices.tsx — the invoice details and the list
  • src/routes/_app/invoices_.$invoiceId.tsx — the document, its downloads, and the cancellation behind a typed confirmation

Not there yet

  • The XML has not been run through the official KoSIT validator. The rules checked locally are sums, breakdown, dates and escaping; the German CIUS rules on the seller contact (BR-DE-5 to -7) are only met when the optional phone and email are filled in
  • No ZUGFeRD: the XML is a separate download and is not embedded in a PDF/A-3, and the PDF itself is not PDF/A
  • invoice.db.test.ts had not run when this entry was written; it runs in CI
  • No payment reconciliation: nothing reads a bank statement or a payment provider to mark an invoice paid, and nothing in the console marks one paid either
  • No dunning: an overdue invoice is not chased
  • The date of supply is always the issue date. A business that delivers before it invoices has no way to state an earlier date or a period yet
  • No per-line exemption reason. A 0 % line under standard VAT is printed and exported as zero-rated; an exemption under § 4 UStG that needs its own wording cannot be expressed
  • Reverse charge is checked for being printable — an EU buyer in another member state with a VAT ID — and the VAT ID is not verified with the EU's VIES service
  • The invoice mail links to the documents rather than attaching them, because the mail path carries no attachments
  • Deleting a workspace deletes its invoices. Keeping them for the retention period is the business's obligation, met by downloading them; this is not an archive
  • Names outside the Western European character set are printed with a substitute letter in the PDF; the XML carries them exactly
  • scripts/prove-invoice.mjs predates the snapshot: it raises invoices from quotes without settings or billing details, which the step now refuses, and has not been updated or run

Connections to other systems

Live

One connector that speaks the protocols rather than the brands: any HTTP API, four authentication schemes, retries only where they help, and an execution record for every call. Plus outbound webhooks, signed and retried, and SMTP through the customer's own mail server.

Evidence

  • src/lib/server/connector.ts — method, URL, headers, body template, timeout, bounded retries
  • src/lib/server/secretbox.ts — AES-256-GCM, never returned to a browser, scrubbed out of responses
  • src/lib/server/outbound.ts — HMAC over timestamp.body, deliveries as rows with a next attempt
  • src/lib/server/smtp.ts — the customer's own server, so deliverability stays their domain's
  • scripts/prove-connector.mjs — 31 assertions, including five inward addresses refused before a socket opens
  • scripts/prove-outbound.mjs — 20 assertions, the signature verified the way a receiver would
  • scripts/prove-tooluse.mjs — 11 assertions: a failed call raises a handover rather than reporting success
  • scripts/prove-smtp.mjs — 15 assertions, and it names the one thing it cannot prove

Not there yet

  • SMTP is proved as far as somebody's password: the address guard, a real server reached, a wrong credential told apart from a wrong port. That a message *arrives* needs an account to send from and a mailbox to receive in, and neither exists here — so the last hop is reasoned about rather than measured
  • IMAP is not built, and is no longer the plan: mail arrives by forwarding to a workspace address instead (see Channel adapters), which needs no stored mailbox password and no restricted Google scope
  • No OAuth. Every credential here is one the customer already holds; a connector that needs an authorisation dance needs a registered client, which is the thing none of this has
  • The body template substitutes values and nothing else — no conditionals, no loops. That is deliberate: an operator's template runs on our server against a customer's data, and anything expressive enough to be useful is expressive enough to be a sandbox to get wrong

Subscriptions and checkout

Beta

Stripe and PayPal: checkout, webhooks, signature verification, plan changes and access rules are written and tested. No keys are installed, so nobody can pay.

Evidence

  • scripts/prove-billing.mjs — 13 assertions with no account: a tampered body refused, a replayed signature refused, an absent secret refusing rather than accepting, and PayPal in sandbox unless the word is exactly 'live'
  • src/lib/server/billing
  • src/lib/server/billing.test.ts
  • src/routes/_app/billing.tsx

Not there yet

  • Not switched on

Waiting on: The sixteen values in deploy/README.md, which need the company licence first

Google Workspace

Live

Gmail and Calendar, connected by signing in at Google. Seven tools: send mail, list calendars, find free time, and create, read, move and cancel events. Every call goes through the one executor, so it is claimed once, checked against policy, and recorded. Reading the mailbox was withdrawn on 2026-09-13 with the gmail.readonly scope, which Google classes as restricted; mail now arrives by forwarding instead.

Evidence

  • src/lib/server/integrations/google.ts — a definition and seven adapters, with no OAuth, refresh, retry or idempotency of its own, and a list of Google's restricted scopes the requested set is tested against
  • scripts/prove-google.mjs — run 2026-09-02 against the live API from the production host: the nine tools of that day, one email sent to the connected account and found again in the mailbox by subject, a repeat of the same logical send deduplicated rather than sent, an event created with a deterministic id then read, moved and cancelled, and a send without an approval refused. 19 executions recorded, none carrying a credential. The two Gmail reads it used have since been withdrawn, and the script no longer asks for them.
  • src/lib/server/integrations/google.slice.test.ts — the whole chain against real Postgres, with only the socket replaced
  • src/lib/server/injection.test.ts — 56 adversarial cases, each run as though the model had already been fully persuaded
  • src/lib/server/connection.test.ts — the OAuth handshake, including two callbacks racing one state

Not there yet

  • One connected account, in one workspace, for one day. Nothing is proved about it over time or under load.
  • Token refresh is implemented and unit-tested and has never been triggered live: the proof run finished with 45 minutes left on the access token.
  • No webhook or push channel from Google. Mail reaches Systeni by the customer forwarding it, which is not a Google feature and does not depend on this connection.
  • The OAuth app still has to pass Google's verification for its sensitive scopes, gmail.send and calendar. Until it does it runs as Google's testing app: a hundred test users, and refresh tokens that expire after seven days. No restricted scope is requested, so no annual security assessment is part of that.
  • F14's generated certification suite does not exist. Three named things back this level; a fourth was never built.

CRM and calendar adapters

In progress

A workflow step can call any HTTP API with the customer's own credential — HubSpot, Pipedrive, a calendar, anything with an address. What does not exist is a branded adapter: no OAuth, no field mapping, no health check. The customer supplies the endpoint and the key, and the console shows what every call returned.

Evidence

  • src/lib/server/connector.ts — method, address, headers, body template, four auth schemes, retries only on the statuses worth retrying
  • scripts/prove-connector.mjs — 31 assertions, including that a credential never reaches the execution history
  • src/routes/_app/connections.tsx — four states, and only a request that succeeded turns one green

Not there yet

  • OAuth flows, token refresh, field mapping, conflict handling, disconnect, health checks
  • A person has to read the vendor's API documentation once. That is the whole difference between this and a branded adapter, and for a tenant with an IT department it is an afternoon

Waiting on: A developer account and an OAuth client per vendor, for the branded version

Knowledge base

Live

A bounded set of documents an agent may answer from — pasted, uploaded as PDF or Word, or read from a page of the customer's own site. Cut into passages and searched as passages, with citations back to the document. Anything outside it goes to a person.

Evidence

  • src/routes/_app/knowledge.tsx
  • src/lib/server/chunking.ts — deterministic, on the boundaries the writer already put there
  • src/lib/server/agent.ts — retrieval over passages, citing documents
  • src/lib/server/safe-fetch.ts — resolves before fetching, checks every address and every redirect hop
  • src/lib/server/html-to-text.ts — script, style, navigation and footer dropped before the words are kept
  • src/lib/server/extract-file.ts — reads .xlsx as well: the strings live in a shared table and the cells hold indexes into it, which is why a reader that only walks the sheets returns a page of small integers
  • scripts/prove-spreadsheet.mjs — 10 assertions against a workbook built byte by byte in the proof rather than committed as a fixture nobody can read in a diff
  • src/lib/server/extract-file.ts — PDF, Word, text, Markdown and CSV; the ZIP and the XML read here, the PDF by pdf.js
  • scripts/prove-files.mjs — 17 assertions against files built in the script itself, including a scanned PDF refused by name
  • scripts/prove-import.mjs — 29 assertions, most of them refusals: the cloud metadata endpoint, loopback, the private ranges, and each of those written as an IPv4-mapped IPv6 address
  • src/lib/server/steps.ts — citation checking
  • scripts/prove-knowledge.mjs — 18 assertions against production: nothing lost, the ceiling enforced, a whole question finding the paragraph that answers it, and 1,266 characters handed to the model where 18,060 used to be

Not there yet

  • A scanned PDF cannot be read at all — its pages are pictures of text. It is refused by name and told what it is, rather than saved as a document that looks fine and answers nothing, and reading text out of an image needs OCR that does not exist here
  • One page at a time, not a site. There is no frontier, no budget and no re-visit policy, so a page read in today does not notice that the price changed tomorrow — somebody has to read it in again
  • Full-text search, not embeddings. It matches words, so a question phrased entirely differently from the document finds nothing — which escalates to a person, correctly, but a customer will read it as the agent being dim
  • Found while proving this and fixed: retrieval required every word of a question to appear in one passage, because plainto_tsquery joins terms with AND. It would have gone quiet the day passages shipped

Before, after, and permission to say so

Beta

A workspace states what things were like before, once, and cannot restate it later. Beside it sit counts of what happened since — records anybody can open, never an estimate, and never one subtracted from the other. Consent is separate, granted at a scope by the owner, and withdrawal outranks it.

Evidence

  • drizzle/0033_baselines.sql — the before, with every figure nullable because a default is not an answer
  • drizzle/manual/0005_baselines_rls.sql — read and insert, no update and no delete: append-only in the database
  • src/lib/server/baseline.ts — figures stated on purpose, not read out of a wizard whose sliders start at numbers this product invented
  • src/lib/server/value.ts — the after: eight counts of rows that exist, no coefficient, no hours saved
  • src/lib/server/case-study.api.ts — consent by the owner at a stated scope, withdrawal outranking it, and the two halves returned side by side
  • src/components/systeni/Baseline.tsx — empty fields, because a pre-filled one records our number as their claim
  • src/lib/server/baseline.test.ts — the database refuses an edit and a delete; a stated zero stays zero and an unanswered question stays null

Not there yet

  • No customer has stated a baseline. Everything here is exercised by tests and by nobody's business
  • The window is fixed at 28 days. A business that started in March cannot ask what its first month looked like
  • Nothing produces the artefact. The figures and the permission are in the product; the case study itself is still a person writing prose in another tool
  • The counted half cannot see outcomes — whether a deal closed, whether the customer was happy. It counts what the system did, which is the honest half and not the whole story

Deals and campaigns

In progress

Campaigns reach only contacts who agreed, never anybody who unsubscribed, and never the same person twice. A deal opens when a quote is sent to a customer, exactly once, carrying the quote's own figure and currency; a person decides how it ended, through one boundary, and the screen can prove the chain from the records written at the time. Not yet driven end to end by a customer's enquiry.

Evidence

  • src/lib/server/campaigns.ts — audienceFor requires a consent timestamp and excludes unsubscribes, with no parameter that relaxes either
  • src/routes/_app/deals.tsx — the operational list: customer, stage, value, currency, source quote, created, with per-currency totals and no forecast anywhere
  • src/routes/_app/deals_.$dealId.tsx — one deal, its source quote, and a proof chain read from stored business events
  • src/lib/server/deal-stage.ts — the one boundary a deal changes stage through: three stages, human evidence only, no reopen
  • scripts/prove-pipeline.mjs — the real transition, not a copy of it: one deal.won and not two, a won deal that cannot be lost, no way back to open
  • scripts/prove-deal-ui.mjs — 40 assertions in a real browser: the empty state, two currencies never summed, JPY and KWD, a deal past the width of an integer, won and lost behind a confirmation, and a phone
  • scripts/prove-deal-proof.mjs — 30 assertions driving a real run: a quote held for approval opens no deal, and the chain reads quote sent then deal opened then decided
  • scripts/mutate-deal-api.mjs — 12 mutations over the console surface, each behaving as expected
  • scripts/prove-campaign.mjs — 24 assertions, mostly about who is excluded, including an unsubscribed contact re-imported with a fresh consent date
  • src/lib/server/quote-step.test.ts — the registered quote step run against real Postgres opens a deal whose value and currency match the quote it came from

Not there yet

  • No customer enquiry has driven this. The proof starts at the quote step with the resolver's output; the model choosing which catalogue item an enquiry means is the untested half
  • No opens or clicks. Both need a pixel or a redirect in front of the reader, which requires its own consent in several jurisdictions and which every serious mail client now defeats or fakes — an open rate built from proxy prefetches is a number that is wrong and looks precise
  • No schedule. A campaign that fires while nobody is watching reaches everybody before anybody notices the mistake, so sending is a decision somebody makes in front of the recipient count
  • Consent is recorded by hand on a contact. Nothing yet captures it at the moment it is given — a tick box on the chat or the lead form would, and does not exist
  • One audience: everybody who agreed. No segments, no filters, so a business cannot write to the people who bought last spring without writing to everyone
  • Marking an invoice paid closes its deal, through the same boundary as everything else — but nothing in the console marks an invoice paid, so the path has a caller in the RPC layer and no button
  • A deal cannot be opened by hand, which is deliberate — it opens when a quote is sent. A business whose first commercial event was a telephone call has nowhere to record it until they quote
  • No search, no filter and no pagination on the deal list. It caps at 200 and says so

Partner programme

In progress

Partners apply against a versioned agreement and are approved by a platform admin; an approved partner's link attributes a new workspace once, at creation. Commission is written to an append-only ledger when a subscription payment clears and reversed on refund or chargeback, stated monthly, and paid out by hand on request.

Evidence

  • src/lib/server/partner-referral.ts — signed first-valid-code cookie, thirty days, self-referral refused
  • src/lib/server/partner-ledger.ts — commission and reversals from the billing webhook, idempotent per event and per payment, monthly statements
  • src/lib/systeni/partner-ledger.test.ts — the arithmetic to the minor unit
  • src/lib/server/partners.rls.test.ts — a partner reads only their own ledger; the database refuses a second attribution, an edited ledger line and a reversal larger than its commission
  • scripts/partner-copy-audit.mjs — no income promises in partner-facing copy

Not there yet

  • No live payment has reached the ledger: the Stripe keys are not installed, so every figure has come from a signed fixture
  • Payouts are sent by hand and marked paid with a transfer reference; nothing moves money
  • The partner page and clause 12 of the partner terms still describe attribution and statements as manual — changing the terms needs a new agreement version and a decision, not a code change
  • No currency conversion: a payment in a currency other than the dollar earns commission in that currency and is paid only where a minimum is configured for it
  • A chargeback that is later won is not reinstated automatically

Waiting on: Live Stripe keys, to see a real payment reach the ledger

Platform

Operator console

Live

Seventeen screens on live data with no sample rows, in twenty languages: the Command Center, inbox, execution record, approvals, customers, policies, team, billing, settings, workflows, agents, the Blueprint, industry setups and the operator console.

Evidence

  • src/routes/_app
  • src/components/systeni/AppShell.tsx
  • scripts/prove-console.mjs — 7 assertions over HTTPS against the deployed build, as a signed-in member of a workspace with nothing in it

Not there yet

  • The academy was English by decision and out of the navigation; since 0047 it is in the navigation for owners, admins, operators and partners, its chrome is translated and audit:console-i18n holds it at zero like every other screen. prove-console does not walk it yet
  • The walk asserts each screen renders and refuses correctly; it does not click anything. A form that submits wrongly would still pass
  • Found by that proof and fixed: the operator console rendered its own layout to an ordinary owner during the server pass and only refused after hydration
  • Removed today, and it was the last invented data anywhere in the product: a login-page button loaded a fabricated account — a named owner, three colleagues with roles, four connected channels, 4,218 conversations — and navigated into the gated console, which redirected the visitor straight back to the page they had left. Dead and dishonest at once. What is offered in its place is the run on the home page, which uses the engine that ships
  • audit:cta now refuses any signed-out control that navigates into the console without a written reason, and that check immediately found a second one: the verification page's Continue button, for the phone that followed the link from a mailbox and had never held a session

Twenty languages

Live

The whole public site, the sign-up path and the daily console screens, with a build that fails on a missing key.

Evidence

  • src/lib/i18n
  • scripts/i18n-audit.mjs — key parity and plural coverage across twenty locales

Not there yet

  • The academy curriculum is English and German only — about 1,200 lines of teaching text per language. The other eighteen languages get translated chrome around the English curriculum and are told so on the screen, because a sales curriculum nobody has read in Korean teaches somebody to say things nobody checked
  • Legal pages — terms, privacy, the imprint, the trust centre — are English by decision: a commitment that shifts meaning between two languages is worse than one everybody reads in one
  • Nothing checks that a translation still matches its English source once both exist
  • Twenty languages on screen is not twenty languages in search. Only English and German have addresses of their own (see search-presence): the other eighteen catalogues are near complete on most public pages and between 72.8% and 84.8% on /security, below the 90% a language must reach on every translated page before it is published under its own prefix. On a bare address they still render, chosen by cookie or browser, as before

The public site in search

In progress

Each translated public page has an address per language — /pricing in English, /de/pricing in German — with a canonical address and hreflang alternates in its head, a sitemap generated from the page list, and a robots.txt checked against the route tree.

Evidence

  • src/lib/site/locale-url.ts — the prefix, removed and restored by the router's own rewrite on the server and in the browser; the address's language wins over cookie and browser and sets no cookie
  • src/lib/site/site-locales.test.ts — a language is offered only when every translated page reaches 90% translated keys in it; English and German today
  • src/lib/site/head.test.ts — one self-referencing canonical, the same alternate set on every language version, nothing beside noindex
  • src/lib/site/pages.test.ts — every public route is indexed or excluded with a reason
  • src/lib/site/sitemap.test.ts
  • src/lib/site/robots.test.ts — every console, portal and sign-in side door disallowed; no rule matching a public page in any language

Not there yet

  • Not deployed. No search engine has fetched a German address, and Search Console has not been asked whether it accepts the alternates
  • English and German only, by the rule rather than by choice — see i18n for what holds the other eighteen back
  • Eight pages are English whatever the language — the legal texts, developers, integrations, the roadmap and the trust centre. They are reachable under /de/ so the menu stays German, and name the English address as canonical rather than claiming a translation
  • A page's words are measured by the catalogue keys its source renders. Text written straight into a component is invisible to that measure; the page list's declaration of English-only pages is what covers it, and a test holds the two together
  • No lastmod in the sitemap: there is no honest source for a page's modified date

Site measurement without identifying anyone

In progress

Daily totals for the public site, counted on our own server: page loads by page and language, referring sites by name, campaign tags, and enquiries, sign-ups, checkouts and activated subscriptions where each happens — shown to platform operators with the funnel as ratios of totals. No cookie, no stored address or browser string, no third-party script.

Evidence

  • src/lib/site/measurement.ts — what a page load adds and what is left out: bots by user agent, signed-in visitors, prefetches, anything not a public page; hosts only, never a referring URL; ids never stored
  • src/lib/site/measurement.test.ts
  • src/lib/server/site-measurement.ts — an upsert per daily bucket with a cap per kind folding the rest into (other), an in-memory burst guard keyed by a salted hash, and no entry point that can fail the thing it counts
  • src/lib/server/site-measurement.boundary.test.ts — one module opens the measurement scope; funnel calls carry the step and nothing else
  • src/lib/server/site-measurement.db.test.ts — the upsert, the cap, and a tenant transaction reading and writing nothing, against Postgres in CI
  • src/components/systeni/SiteMeasurement.tsx

Not there yet

  • No unique visitors, by design, and no attribution per person: nothing counted can tell one visitor from another, so the funnel's rates divide totals and are not shares of people. That will not change without identifying visitors, which this was built not to do
  • A page load is the first page of a visit or a reload. Pages opened by clicking inside the site are rendered in the browser and never requested from the server, so they are not counted; counting them would take a script reporting back from the page
  • Bots are recognised by user agent only. One that claims to be a browser is counted, up to thirty page loads a minute per address
  • A subscription is counted as activated when a card checkout completes active or PayPal reports it active. A bank debit that clears days later is not counted
  • The daily cap is exact to within the number of concurrent writers, not to the row
  • Not deployed, the database suite has not run against the change, and totals are kept without an end date — they describe nobody, but no retention rule has been written for them

Academy and certification

Beta

A curriculum a person can complete without being taught by someone: seven modules, lessons, practice questions, objection drills and industry playbooks in English and German, progress kept on their account, and an exam marked on the server that issues a certificate anybody can check by its link.

Evidence

  • src/lib/server/academy.api.ts — progress, the exam and the public certificate check, behind requireLearner and row-level security keyed to the person
  • src/lib/systeni/academy-exam.ts — a paper drawn from every module with shuffled options, marked against the stored order, 80 % to pass, curriculum version pinned to the question bank
  • src/lib/systeni/academy-exam.test.ts
  • src/lib/systeni/academy.de.test.ts — the German curriculum has the English one's shape, ids, order and right answers
  • src/lib/server/academy.rls.test.ts — one person cannot read another's progress, a certificate cannot exist without a passed attempt behind it
  • src/components/systeni/Academy.tsx

Not there yet

  • Not run against production. The database suite runs in CI; no proof script has sat the exam on the deployed build
  • Unproctored, and the question bank is the curriculum's own twelve multiple-choice questions — the practice screens show their right answers. A certificate says a person passed this exam, and the verification page says no more than that. A held-out bank the practice screens never show is the next step
  • Self-ratings no longer count toward anything but the review queue, which means the objection drills are practice only; nothing examines a spoken answer
  • No screen revokes a certificate; the column and the policy exist, the button does not
  • English and German only; eighteen console languages show the English curriculum

Public API and outbound webhooks

In progress

Outbound webhooks work: five events, an HMAC signature over timestamp and body, six attempts on a widening schedule, and every delivery visible with what came back. There is still no public REST API — the internal server functions are not a product surface and are not documented as one.

Evidence

  • src/lib/server/outbound.ts — signed deliveries, backoff from 30 seconds to an hour
  • scripts/prove-outbound.mjs — 20 assertions
  • scripts/wiring-audit.mjs — the server functions, none of them public

Not there yet

  • Key issuance, scopes, versioning, rate limits per key, an event catalogue, signatures

Health and watchdog

Live

A health endpoint, a watchdog every fifteen minutes that alerts once rather than every cycle, and four timers whose state is visible.

Evidence

  • deploy/watchdog.sh
  • src/server.ts — /api/health
  • scripts/ops-agent.mjs
  • scripts/prove-alerting.mjs — 14 assertions, including that a dry run posts nothing

Not there yet

  • No latency or token metrics per tenant
  • No alert routing beyond email
  • Fixed today, and it was doing the one thing an alerting system must not: the proof of the alerting posted a real failure mail to the owner on every run. Its scratch failure pointed at the live handler, and the --send flag it advertised governed only the message the script composed itself — systemd sent its own regardless. He found out by asking why the server mails him after every deploy, which is how a person learns that failure mail is noise
  • The proof now asserts that it posted nothing, counted across the run rather than over a time window — the first version of that assertion went red once for an alert a deliberate regression test had fired a minute earlier, which is the same fault reversed
  • And the second source of the same mail, found while looking for the first: housekeeping runs every five minutes and a deploy stops the application for a few seconds, so the two collided three times in three days and each collision reported a failure for a service that was working. It now retries a refused connection for twenty seconds and gives up after that, so a restart is ridden out and a real outage is still reported — both halves forced on purpose and measured, not assumed
  • And the mail itself was quoting the wrong thing. alert.sh took the unit journals last forty lines, which for a job running every five minutes is about ten previous successful runs — so an alert about a failure showed everything working and the line explaining it had been pushed off the top. It now quotes the failed run alone, by its systemd InvocationID, and prove-alerting fails a job twice with different markers to check that only the second appears

SSO and SCIM

Planned

Neither exists. Access is email and password with server-side roles, optionally with an authenticator-app code.

Not there yet

  • SAML single sign-on and SCIM provisioning

Waiting on: An identity provider to test against

Two-factor authentication

In progress

After the password, a six-digit code from an authenticator app (TOTP), with ten single-use recovery codes; an owner can require it for everyone in a workspace.

Evidence

  • src/lib/server/totp.ts — RFC 6238 from node:crypto, tested against the RFC 4226 and 6238 vectors
  • src/lib/server/two-factor.ts — a sealed secret, a code's step accepted once, recovery codes spent once
  • src/lib/server/two-factor.signin.test.ts — no session before the code, including after a password reset or an invitation
  • src/lib/server/two-factor.db.test.ts — the replay and single-use races, against Postgres in CI

Not there yet

  • Not yet deployed, and not proved against production
  • TOTP only: no passkeys or WebAuthn
  • An owner cannot reset a member's lost factor from the console; the member uses a recovery code, or an operator removes it

What we do not have

Stated here rather than left for you to discover during procurement.

  • No SOC 2, no ISO 27001, and no audit in progress. Neither is claimed anywhere on this site.
  • No independent penetration test. The security work here has been reviewed by the people who wrote it.
  • No data processing agreement yet. The privacy and security pages are what a buyer currently has to work from.
  • No SSO and no SCIM. Access is email and password with server-side roles; each person can add an authenticator-app code, and an owner can require one for the whole workspace.
  • One region, one host, one database. Backups run nightly and a restore is tested weekly; there is no second site.
  • No customer references. Nobody uses the product yet, and no logo on this site claims otherwise.

Blocked on something we cannot supply ourselves

Each of these is waiting on an account, a credential or a review from outside this company — not on engineering time.

Channel adapters

  • A WhatsApp Business account and Meta app review
  • A telephony account and a number
  • OAuth clients for Google and Microsoft
  • For email: a mail provider with inbound parsing, an MX record for in.<domain>, and SYSTENI_INBOUND_MAIL_DOMAIN and SYSTENI_INBOUND_MAIL_SECRET on the host

Subscriptions and checkout

  • The sixteen values in deploy/README.md, which need the company licence first

CRM and calendar adapters

  • A developer account and an OAuth client per vendor, for the branded version

Partner programme

  • Live Stripe keys, to see a real payment reach the ledger

SSO and SCIM

  • An identity provider to test against

Ask us about any line on this page

Systeni Limited operates Systeni. If your security review needs something that is not here, the answer will be what is true rather than what is convenient.