A demo built by Secure23 AI Services Contact
Super Amazing Fund Management

Architecture

How the app is put together, what it talks to, and the path a report takes from a form to something an investor can be sent. For what it does and why, see About; for how to do a particular job, see How to.

The shape of it

A FastAPI app rendering its own pages, with SQLite behind it. It is layered so that the rules can be read without a database: what a report is, what may happen to it and who may do it are plain Python with no session in sight, and the stores are the only code that knows SQLite exists.

The app in six layers, from the screens down to the database and the services outside it. What you see Generate a report / and /subject Reports and review /reports · /review Funds /funds, a page each Templates and admin /templates · /admin The web layer — FastAPI routers main.py forms, jobs, JSON archive_router /reports review_router /review fund_router /funds · /admin/funds templates_router /templates admin.py /admin and its tabs The rules — none of this touches the database report/model.py the report + provenance db/lifecycle.py statuses, roles, gates report/template.py blocks · normalise() audience · guidance how it is worded fund_profile.py a fund's own setup Stores — the only code that talks to the database archive.py every report template_store layouts fund_store + fund profiles audience_store + guidance_store db/service.py versions · audit settings · usage · mail The database SQLite — fundreport.db reports · immutable version snapshots · append-only audit events · source reviews · users · funds · fund profiles Outside this app Anthropic API web search and drafting · research/provider.py An SMTP server takes what the outbox in mail.py hands it A returns database — planned slots in behind FundDataSource
Each layer only calls the one below it. Nothing in “the rules” imports a store, which is what lets the same rules be tested without a database and read without one.

Where it runs

One Linux VPS in New Zealand, and nothing else. nginx takes the request and hands it to the app over the loopback interface, so the app itself is not reachable from outside the machine; the database is a file on the same disk. The only traffic that leaves is outbound, and only when a report is being researched or a message is being sent.

A browser reaches nginx on one New Zealand VPS, which proxies to the app, which reads a database file on the same disk. Anyone with the link no sign-in yet Request Response VPS — NZ hosted · Ubuntu · systemd nginx TLS, reverse proxy uvicorn + FastAPI loopback only SQLite a file on the same disk outbound Outbound only — nothing out there calls in Anthropic API web search and drafting An SMTP server whatever the outbox hands it
The app runs as its own service account that cannot log in, and systemd restarts it if it falls over. Both outbound calls are optional: with no API key the research step does nothing and says so, and with no mail server the outbox holds messages rather than failing whatever was trying to send one.

How a change reaches the server

Pushing to master is the deploy. The build agent runs on the VPS itself, so there is no SSH hop to configure and nothing holding a key to the box. The tests are the gate: they run in a throwaway virtual environment, and only if they pass does anything get copied.

A push to master runs the tests on an agent on the VPS, and only a green run copies the code in and restarts the service. git push to master Azure DevOps — a self-hosted agent, on the VPS itself Run tests in a throwaway venv pass The deploy step one scoped sudo rule On the VPS Copy the new code in the app and its deploy scripts Install, then migrate schema before restart, never after Restart the service the old one serves until it does
Each step is a place it can stop safely. A failing test copies nothing; a failing migration leaves the previous version serving, which is the right way round, because a migration that did not apply is a service that should not be answering. The privileged half runs from a root-owned script the deploy cannot overwrite, and it refuses to run at all if it differs from the one in the commit being deployed.

What is on the box

Four kinds of state, and only one of them survives a restart in a way that matters.

The app directory, the database file, the environment file and the in-memory documents all live on the one machine. On the VPS, and only there The app directory the code and its virtualenv The database file reports · versions · audit · users An environment file read by systemd, never by a page Finished documents in memory, lost on restart
The API key and the mail password are read from the environment at the point they are used. They are not settings, they are never written to the database, and no screen in the app can show them. The documents held in memory are why a restart loses a report that was still being generated — the first thing to replace if this ever runs on more than one machine.

Where the figures and the words come from

The split down the middle of this diagram is the whole design. Figures come from a data source and are never asked of a model. Words are either written by a person or drafted by a model that has searched an approved list of sites, and a drafted block carries a footnote to what it was drafted from. Both halves meet in one report object, and both output formats are rendered from that — so the Word file and the PDF cannot drift apart.

Fund figures arrive through one interface; narrative is written or drafted; both meet in one report object. The figures — quantitative The words — narrative Demo funds, in code data/balanced.py · global_equity.py Funds entered here typed into Admin › Funds A returns database planned FundDataSource one interface, any backend Approved site list 15 sites · allowlist.json Anthropic API one search per holding Written by a person no footnote needed Drafted by the model footnoted to its source One report object every block says where it came from Word (.docx) PDF
The model is only ever asked what the market said about a holding, never what the fund returned. The research step is configured on this server. 2 funds can be reported on today.

How a report gets made

A report that researches half a dozen holdings takes minutes, which is longer than a browser or a proxy will hold a request open. So the form queues a job and the page polls it. Validation happens before the job is queued, on purpose: a typo in a date comes back immediately instead of as a job that dies a minute later.

The form queues a job; a worker reads figures, researches, drafts and renders; the result is archived as a draft. In the request The form fund · period · template POST /generate/job validated now, not later 202 and a job id the only key to it In a background worker Reading fund figures from the data source Researching market reaction approved sites only Drafting commentary house style applied Rendering the document Word or PDF Ready to download held in memory After it finishes Saved to the archive with how it was made The browser polls /jobs/{id} the id lives in localStorage Still a draft approval is its own step
The job id is a random token and there is no route that lists jobs — with no sign-in yet, an enumerable id would be an open filing cabinet. Jobs live in the process, so a restart loses anything in flight; that is the first thing to replace when this needs to run on more than one machine.

Which template a report is built with

Three places could decide the layout, and they are tried in this order. The last two are what makes an unattended run possible: nobody is at a form to choose, so the fund has to carry the choice, and a fund that has chosen nothing still has somewhere to fall back to.

A template is taken from the request, or failing that the fund, or failing that the app-wide default. Did the request name one? the generate form always does no Does the fund have a default? set on the fund's own page no The app-wide default the one marked on /templates yes yes The layout the report is built with normalise() puts back what a report may not go out without its sources, the notices, the footer
Editing a template changes how a document is laid out. It cannot make an unresearched figure appear, remove a source, drop the notices or delete the footer — normalise() puts those back — and template prose is never sent to a model.
TemplateWhere it comes fromFor
Fund report (short) opens on this Built in Fund reports
Standard fund report Built in Fund reports and subject notes

A fund report with nothing named falls back to Standard fund report.

What can happen to a report

This diagram is generated from the same rules the review screen enforces, so it cannot describe a lifecycle the app does not have. Nothing is ever deleted: archiving retains the report, and every version is an immutable snapshot.

The report lifecycle: draft, in review, approved, published, with the ways back. Send for review — Analyst Send for review Send for review — Analyst Send for review Request changes — Approver Request changes Withdraw — Analyst Withdraw Approve — Approver Approve Publish — Approver Publish Revoke approval — Approver Revoke approval Draft In review Changes requested Approved Published Archived
An administrator may make any of these moves; the table below says who else can. Archive: any status → Archived, Administrator.

Who can do what

Move From and to ViewerAnalystApproverAdministrator
Send for review Draft or Changes requested → In review — yes — yes
Request changes In review → Changes requested — — yes yes
Withdraw In review → Draft — yes — yes
Approve In review → Approved — — yes yes
Publish Approved → Published — — yes yes
Revoke approval Approved → Draft — — yes yes
Archive any status → Archived — — — yes

What stops a report being approved

Two of these are switches. The third is not, and is the one the rest of the design hangs off.

BlockerCan it be switched off?
The approver last edited the report Yes — on here
Model-written text that cites no source Yes — on here
An open-web or supplied source nobody has signed off No. Deliberately not configurable.

Where this is going

Reports are generated by someone at a form. The point of giving a fund its own setup is that a run does not need anybody there — a schedule reads the fund, not a form. Two pieces of that exist and two do not, and the dashed boxes below are the ones that do not.

A planned schedule would read each fund's own setup and run the same generation, producing a draft. A schedule — planned nobody at a form Its default template set on the fund's page Where it is published — planned SharePoint · file share · email the fund carries both The same generate run nothing new in the pipeline A draft for a person to approve
A schedule can create work. It cannot sign it off: the provenance gate and the two-person rule do not know whether a run had somebody watching, and that is the point.

Generating report

This can take a couple of minutes when AI drafting is enabled, since each holding is researched live.