Try it: midgard.pastelero.ph opens a seeded demo, no account needed. The AWS backend described below has since been retired; the section near the end explains what replaced it.
The problem
Companies face increasing pressure to measure and report their environmental impact. ESG (Environmental, Social, Governance) compliance isn't optional anymore. But most companies track sustainability data in spreadsheets, scattered across departments, with no centralized way to calculate emissions or generate reports.
I saw an opportunity to build a platform that makes sustainability tracking as straightforward as any other business metric.
What I built
Midgard is a B2B SaaS platform for ESG management. Companies track carbon emissions across their operations, run their sustainability policies through AI analysis, assign compliance tasks, and generate reports for stakeholders.
Core features
- Emissions calculated from energy use, fuel, business travel, and purchases
- Policy upload and analysis, with AI suggestions on what is missing
- Compliance tasks tracked across environment, social, and governance
- Team management: invite people, assign roles, set permissions
- Receipts and invoices pulled automatically out of Gmail and Outlook
- WebSocket alerts when a task or policy changes
- Public sustainability scorecards
- Internationalization through next-intl and Crowdin
Tech stack
| Layer | Technology | Why |
|---|---|---|
| Frontend | Next.js 14 | App Router, Server Components |
| Language | TypeScript | Type safety across full stack |
| Auth | AWS Cognito | Enterprise SSO, built into Amplify |
| Database | DynamoDB | Serverless, scales automatically |
| Storage | S3 | Document storage with presigned URLs |
| Functions | AWS Lambda | 18 serverless functions |
| AI | OpenAI API | GPT-4o / GPT-4.1 policy analysis |
| Payments | Stripe | Subscription billing |
| UI | Ant Design + Radix | Enterprise component library |
| Charts | Highcharts | Data visualization |
| State | Zustand + React Query | Client state and server cache |
| Infra | AWS Amplify Gen 2 | Full-stack serverless deployment |
Architecture
System architecture
Data model
Email processing pipeline
Serverless functions
Eighteen Lambda functions cover the backend. Four of them handle the Gmail and Outlook OAuth flows and pull documents out of inboxes. Another set does the arithmetic: distances, coordinate lookups, applying emission factors. The AI ones analyze policy text, generate tasks, and suggest compliance fixes. The rest handle notifications through WebSocket, Slack, and email.
Real-time updates
WebSocket subscriptions push updates as they happen, so when someone uploads a document or finishes a task, everyone sees it. Getting that right against eventual consistency was the hard part.
What building this taught me
1. DynamoDB access patterns require upfront planning
I restructured the data model three times. DynamoDB is incredibly fast and scales infinitely, but you can't just add a new query later. Every access pattern needs a GSI (Global Secondary Index) designed from the start.
Spend a week mapping out every query your application will need before you write a line of code.
2. Policy analysis needs structured prompts
My first attempt at AI policy analysis returned inconsistent results. Sometimes it found compliance gaps, sometimes it missed obvious issues. The breakthrough came from structured prompting:
What shipped is a prompt that specifies the JSON contract key by key, then forbids everything else:
;`Only output valid JSON—no markdown fences, comments, or extra fields.
Ensure each key follows the described detail:
- suggestedText: list each policy line with original, measurable suggestion, and correct category.
- analysis: single paragraph summarizing compliance gaps and strengths.
- suggestions: array of short actionable compliance steps.
- missingClauses: array identifying any required clauses absent from the document with importance value.`
Naming the keys was not enough on its own. Saying what each key must contain, and banning the markdown fences the model kept adding, is what made the output parseable often enough to build on.
3. Email integration is surprisingly complex
I thought OAuth would be the hard part. It wasn't. The hard part was:
- Handling rate limits from Gmail/Outlook APIs
- Parsing receipts in dozens of different formats
- Extracting structured data from unstructured emails
- Managing token refresh without user intervention
I did not solve the volume problem. The function fetches up to a hundred messages per invocation and processes them inline; smaller batches, retry on failed OpenAI calls, and exponential backoff for rate limits are still sitting in that function's README as things to do. Naming the fix is not the same as shipping it.
4. Multi-tenant isolation belongs in the schema, not the client
This is the one I got wrong, and I would rather write it down than quietly leave it out.
Every list query in the app filters by companyId, and I treated that as tenant isolation. It isn't.
Those filters are arguments the browser supplies:
// src/app/.../employees/ManageEmployees/employeeUtils.ts
const { data: employeeData } = await client.models.Employee.list<any>({
filter: { companyId: { eq: companyId } },
authMode: 'userPool',
})
Meanwhile every model in the schema was authorized like this:
// frontend/amplify/data/resource.ts - all eighteen models
.authorization((allow) => [allow.authenticated()])
allow.authenticated() is unscoped: it grants any signed-in Cognito user full CRUD on every row of
every model, and all 302 call sites in the app authenticate the same way, with authMode: 'userPool'.
So the filter is what the client asks for, not what the server enforces, and dropping it in devtools
returns other companies' rows. A filter the client controls is a display preference wearing a security
costume.
The enforcement point in Amplify Gen 2 is the authorization rule on the model itself, because AppSync
resolves client.models.X.list() straight to DynamoDB and never touches my Lambdas. No amount of
function-level middleware can cover a path that does not run functions. The rule has to read the tenant
from a verified token claim rather than from the request:
.authorization((allow) => [
allow.ownerDefinedIn('companyId').identityClaim('custom:companyId')
])
Two things follow from that. Authorization must never accept companyId as a parameter from the
caller, which is the shape every IDOR bug takes. And declaring Cognito groups is not the same as using
them: I defined ADMINS and MEMBERS in defineAuth and then never referenced either one in a rule,
so role lived on as an enum field that any authenticated user could rewrite.
The bigger realization
B2B SaaS runs on trust. Companies hand you sensitive data about how they operate, so a feature that works most of the time is a feature that does not work. The calculations have to be right and the data has to stay where it belongs.
The interesting features mattered far less than the boring ones: accurate data, role-based access, audit trails, and reports people can read.
What I would do differently
I would start with a much simpler emission model. What I built handles dozens of emission factors, unit conversions, and regional variations. Most users want to type in their electricity bill and see the carbon number.
The complexity could have come later, once someone asked for it.
What happened to it
The AWS backend is gone. The AppSync endpoint stopped resolving, midgard.earth went with it, and a
platform that only exists as eighteen Lambda functions is a platform nobody can look at. A portfolio
piece that 404s is not a portfolio piece.
So I made it run without a backend. Demo mode aliases the seven Amplify entry points to shims and
generates a mock data client from the model_introspection block already sitting inside
amplify_outputs.json. That block is the schema, so all eighteen models stay in sync for free, and the
seventy-nine files that import Amplify never needed touching. The app boots against seeded data and
behaves the way it did in production: same navigation, same disclosure register, same task list.
The lesson generalised further than I expected. Everything that made the app testable, typed data access and a single place where the client is constructed, is exactly what made it survivable once the infrastructure went away.
Keeping the compliance content honest
Reporting products age badly, because the regulation moves and the copy does not. Between 2024 and 2026 the CSRD changed twice: a directive that paused the deadlines, then Omnibus I, finalised as Directive (EU) 2026/470, which raised the threshold to 1,000 employees and EUR 450 million of turnover and took roughly 90% of previously in-scope companies back out. The simplified ESRS followed in C(2026) 5010, cutting mandatory datapoints by 61%.
The marketing page had described the world before all of that. Rewriting it once would have fixed nothing, so every regulatory claim now lives in one typed module with the primary source it came from and the date it was checked, and tests assert that no claim renders without a citation.
That caught two mistakes I had already made. I had cited the ESRS by the URL of the voluntary-standard act, whose number differs by a single digit, and I had kept calling E3 "Water and marine resources" after the 2026 rewrite renamed it to "Water". Both are now regression tests. Sourcing a claim and sourcing it correctly turn out to be different problems, and only the second one is worth anything.
References
- AWS Amplify Gen 2 Documentation
- GHG Protocol for emission calculation standards
- CSRD Requirements
- Directive (EU) 2026/470, the Omnibus I simplification package
- C(2026) 5010, the simplified ESRS
- DynamoDB Single Table Design
- OpenAI API Reference
