← All projects

Midgard: ESG & Sustainability Management Platform

Cover Image for Midgard: ESG & Sustainability Management Platform

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

Let's build something together.

Got an idea? I'm always up for a new challenge, whether it's a side project, a startup, or something in between.

© 2026 Cyrus David Pastelero. All rights reserved.

Built with Next.js, TypeScript & Tailwind CSS