Skip to content

feat/manager console - #2

Open
MisreadableMind wants to merge 27 commits into
monad-developers:mainfrom
MisreadableMind:feat/manager-console
Open

feat/manager console#2
MisreadableMind wants to merge 27 commits into
monad-developers:mainfrom
MisreadableMind:feat/manager-console

Conversation

@MisreadableMind

@MisreadableMind MisreadableMind commented Aug 8, 2026

Copy link
Copy Markdown

Summary

Builds the manager's half of the product. Until now the app only had the
allocator's side: open a link, see a record, verify it. A manager could create
invites but had no way to see what those invites actually hand over.

The centrepiece is a disclosure matrix that measures rather than asserts. It
runs the projection once per profile and reports the serialized byte count and
the surviving top-level keys, so a dash in the table means the key is absent
from the JSON — not hidden in the UI. Clicking a column renders that exact
payload with the same components the allocator's browser uses, from the same
project() call, so the preview and the real thing cannot drift apart.

Changes

API — four admin-guarded routes: /owner (everything, no watermark),
/disclosure (the measured matrix), /preview/{profile} (the exact bytes an
allocator gets), /invites (who holds a key, and what it opens). Extracts
_projection_inputs from view(), which was already assembling those five
queries inline. 16 new tests.

Demo login/api/config serves the admin token so the console's login
screen fills itself in. None unless PODARENA_DEMO_FIXTURES is on, and a
fixtures instance holds nothing but the synthetic corpus. Both halves tested.
.env.example and render.yaml say plainly what that means for a real deploy.

Web/manage and /manage/:slug, still no router library. Page chrome
moves to shell.tsx, shared by both sides but visibly different: the console
gets an onyx rail so a manager can tell their own view from what they published
at a glance. Admin token in localStorage, not a cookie — no ambient authority,
so a link an allocator clicks can never carry the manager's credentials.

Visual system — the stylesheet is rebuilt on named tokens, and the trust
hierarchy is now weight, not hue: solid fill for signed evidence, then outline,
then quiet outline, then greyed. Chart points and badges use the same ranking;
deposits and withdrawals differ by dash pattern. A colour-blind allocator reads
the same ordering everyone else does.

Docs — README gains the two-sides section with the byte-count table and demo
credentials; strategy memo tightened and shipped as a PDF; the unlinked
blockchain primer is gone. Plus the Pod ArenaPodShop Arena rename,
isolated in its own commit.

How to test

uv sync && (cd web && npm install && npm run build)
export PODARENA_DATABASE_URL="sqlite:///var/dev.db"
export PODARENA_ADMIN_TOKEN="demo-manager-token"
uv run python -m adapters.bootstrap
uv run python -m demo.seed --reset --no-anchor
uv run uvicorn api.app:app --port 8000

1. Open /manage — the login screen should already hold the token. Sign in,
pick Meridian Global Macro.
2. In the disclosure panel, click across the four profile columns. Watch the
byte count go 4.0 kB → 37.4 kB and sections appear.
3. Click through to the preview and confirm the summary column really has no
evidence table, no chart, no positions.
4. Open /view/demo-meridian-full and /view/demo-meridian-summary side by
side — same fund, two windows.
5. Revoke an invite in the console, then reload the allocator tab.

<!-- greptile_comment -->

<h3>Greptile Summary</h3>

The PR adds the manager console, scoped fund and portfolio-manager access, disclosure previews, invite and access management, demo login support, and shared allocator/manager presentation components.
- Adds authenticated owner, disclosure, preview, invite, rebuild, and access-log API flows.
- Adds `/manage` console pages and shared record-rendering components.
- Adds disclosure and owner-console tests, deployment configuration, contracts, demo fixtures, and expanded product documentation.

<h3>Confidence Score: 3/5</h3>

The PR does not appear safe to merge because explicit NDA consent can still be inferred from an omitted field and rebuilds can continue displaying stale allocator previews.

An empty NDA request still records acceptance and unlocks gated resources, while the rebuild success path refreshes owner and disclosure state without invalidating the cached profile preview.

**Files Needing Attention:** api/app.py, web/src/manage.tsx

<details><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| api/app.py | Adds manager authentication, scoped console APIs, disclosure previews, invite workflows, and allocator NDA handling. |
| web/src/manage.tsx | Implements the manager console, record queries, rebuild actions, disclosure selection, previews, invites, and access history. |
| api/projection.py | Builds profile-specific allocator payloads used by both disclosure measurement and previews. |
| contracts/src/TrackRecordRegistry.sol | Adds on-chain record anchoring and deliberately permissionless access-event emission. |
| tests/test_owner_console.py | Exercises demo configuration, manager scoping, owner data, disclosure previews, invites, and access logs. |

</details>

<sub>Reviews (6): Last reviewed commit: ["docs(screenshots): the allocator&#39;s recor..."](https://github.com/monad-developers/monad-blitz-london/commit/ee457f8d144e88a394d4d100c4f85495dfd04913) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=51446481)</sub>

<!-- /greptile_comment -->

MisreadableMind and others added 14 commits August 8, 2026 16:11
core depends on nothing: no I/O, no clock, no network. That is what makes
results reproducible and the determinism testable.

The load-bearing choices:

- DKIM verification takes the DNS TXT record as an argument rather than
  fetching it. Selectors get retired, so a signature verifiable today is
  unverifiable in a year unless somebody captured the key at the time. Passing
  it in also keeps core pure and lets the standalone verifier run offline.
- l= is a hard fail, not a warning: only a prefix of the body is signed.
- Canonical serialization refuses to encode a float. Money is integer minor
  units; metrics compute in Decimal at a fixed scale. Float non-determinism
  across machines would give different roots for the same evidence.
- Merkle leaves and internal nodes hash under different prefixes, and an odd
  node is promoted rather than duplicated — the duplicate-last construction
  lets two different leaf sets fold to the same root.
- A tier is computed from evidence, never asserted, and a metric carries
  min(tier) over its inputs. min_tier([]) raises rather than inventing trust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Storage holds only the head and a counter; every historical root lives in
events, which cost a fraction of storage and are just as verifiable to anyone
reading the log.

seq is strictly monotonic per record, and that is the whole anti-backfill
mechanism — a manager who drops a bad month leaves a gap anyone can read. It
only means something if the sequence cannot be tampered with, so the first
anchor claims the record and only that address can advance it. Without that,
a griefer could manufacture the very gaps that are supposed to be evidence.

logAccess is deliberately open to write: an access log only the platform can
append to is an access log the platform can decline to append to.

9 tests, including a fuzz case over the sequence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything impure lives behind this boundary so core stays testable.

- document and access_event are append-only by database trigger, not just by
  convention. An audit trail you can quietly edit is not an audit trail.
- dkim_verdict.dns_txt_record stores the key as captured. That column is the
  reason anchoring earns its place.
- No float column exists in the schema. Quantity and price are NUMERIC(38,12),
  matching the canonical serializer's scale so a round-trip through Postgres
  cannot change a hash.
- Blobs are content-addressed and write-once; a put over an existing key is a
  no-op rather than an overwrite, because identical bytes are the same object
  and different bytes under the same hash is a problem to surface, not fix.
- The chain client degrades to "skipped" with no key configured. A missing
  chain should weaken the proof, not break the app.
- as_utc() re-attaches the timezone SQLite drops on read. The canonical
  serializer rejects naive datetimes by design; the fix belongs at the driver
  boundary, not in a weaker serializer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seven steps from the plan — receive, verify, extract, normalize,
reconcile, compute, commit — as one plain function. It runs inline: a message
takes milliseconds to parse and Monad confirms in well under a second, so a
queue would add moving parts in exchange for nothing a user would notice.

Extraction runs regardless of the DKIM verdict. A tampered document still
parses; it just parses as self_reported. Refusing to read it would hide the
tamper rather than grade it.

Rejected documents are still committed to the tree. A message we turned down
is evidence of the rejection, and dropping it would let us quietly forget
having seen it.

The fence:
- Disclosure profiles build the response additively. Filtering a full object
  down fails open — add a field, forget the deny-list entry, ship it to
  everyone. Here a new field is invisible until someone writes the line.
- The NDA gate returns terms and a name. Nothing else is assembled, so there
  is nothing to peek at in the network tab.
- Consent is written before the first byte of data is served.
- Invites store only a token hash; a leaked database is not a working key.

verifier/ proves a record with no access to our API: the .eml files, the
captured DNS records, the Merkle proofs, and the cast call for the on-chain
root. If it ever disagrees with the UI, believe the verifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
viem reads the anchored root through an RPC the viewer types in. A "verified"
badge we computed on the server would be worth nothing, so every check in the
panel runs in the tab: re-fold all leaves locally, check a server-supplied
proof path against our own fold, then compare to the chain.

Charts are hand-rolled inline SVG. A library that silently interpolates or
drops a point would be a second, unversioned interpretation of evidence we
just went to some trouble to pin down.

The Merkle fold in verify.ts has to agree with core/merkle.py byte for byte —
same 0x01 node prefix, same promotion of odd nodes.

No webfonts: a product about working when nobody else's server answers should
hold that standard for its own page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
78 Python tests and 9 Solidity tests. The ones that carry weight:

- Determinism pins a golden Merkle root as a constant in the repo, so a
  different machine on a different day has to agree. Building twice in one
  process only proves the code is not obviously random.
- The DKIM corpus runs all 18 fixtures against real RSA, plus a test that
  fails if the verifier ever opens a socket.
- TestKeyRotation is the argument for anchoring, stated as a test: an archived
  message fails against live DNS and verifies against the captured record.
- The tamper drill flips one byte and asserts all four consequences.
- Projection asserts against the serialized wire payload, not the component.

Two bugs the tests found and this commit fixes: a cash flow dated after the
last NAV was silently dropped from the return (now a finding), and money() in
the UI hardcoded a /100 divisor that would misreport yen by 100x.

The demo is staged, not typed. Three records making one argument each — and
the corpus persists to disk after first generation, because regenerating keys
on every boot would make each restart look like a pile of brand new evidence.

Render: one web service, one Postgres, one disk. Everything wired except the
two chain secrets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Product name only — no behaviour changes. The remaining occurrences live in
files being rewritten anyway and land with those rewrites.
Four admin-guarded routes behind the manager's side of the product:

  GET /api/records/{slug}/owner            everything, no watermark
  GET /api/records/{slug}/disclosure       what each profile puts on the wire
  GET /api/records/{slug}/preview/{prof}   the exact bytes an allocator gets
  GET /api/records/{slug}/invites          who holds a key, and what it opens

The owner and preview views run through the same project() the allocator path
calls, at a wider profile, rather than around it. That is the point: preview and
production cannot drift, and a field invisible to every profile is invisible to
the manager too — so they find out before an allocator does.

The disclosure matrix measures rather than asserts. It runs the projection once
per profile and reports the serialized byte count and the surviving top-level
keys, so a missing section means the key is absent from the JSON, not hidden in
the UI.

Extracts _latest_snapshot/_projection_inputs from view(), which was already
assembling those five queries inline, and stamps viewer_email onto the open
payload so the page can show whose name is on the watermark.
…stances

A three-minute demo should not be three minutes of typing a bearer token, so
the console's login screen fills itself in. The field is None unless
PODARENA_DEMO_FIXTURES is on, and a fixtures instance holds nothing but the
synthetic corpus — both halves are asserted in tests/test_owner_console.py.

Documents the consequence where someone deploying will actually read it: this
value must never be one you would mind reading out loud while demo mode is on,
and turning demo mode off is the moment to rotate it.
Rebuilds the stylesheet around named tokens (onyx, steel, umber, cream, the
dawn-arc gradient) instead of ad-hoc hex, and rewrites the components that were
reaching for colour to carry meaning.

The trust hierarchy is now encoded by weight, not hue: solid fill for signed
evidence, then outline, then quiet outline, then greyed. Same ranking on the
badges and on the chart's points. Deposits and withdrawals differ by dash
pattern. A colour-blind allocator reads the same ordering everyone else does,
and nothing on the page depends on remembering which green meant what.

Also:
  - signed metrics get an explicit leading + , since there is no green to lean on
  - the NAV chart uses a viewBox so it scales instead of clipping
  - EvidenceTable takes `token: string | null` — the manager's preview has no
    invite to download against, so the row renders and the link does not
The other half of the product. A manager signs in with the admin token, picks a
fund, and sees the thing they actually worry about: not "what do I have" but
"what did I hand out".

  - DisclosurePanel draws the measured matrix — one row per payload section,
    one column per profile. A dash means the key is absent from the JSON.
  - AllocatorPreview renders any column with the same components the allocator's
    browser uses, from the same projection, so the preview cannot lie.
  - InvitePanel mints, lists and revokes links, with view counts and last-seen.
  - Two more routes and still no router library; the regex is four lines.

Page chrome moves to shell.tsx and is shared by both sides, deliberately
related but not identical — the console adds an onyx rail, so a manager glancing
at a screenshot knows in a quarter second whether they are looking at their own
view or at what they published. Confusing those two is the mistake this product
exists to prevent.

The admin token lives in localStorage rather than a cookie: no cookie means no
ambient authority, so a link an allocator clicks can never carry the manager's
credentials with it.

Staged demo data moves to demo.ts alongside a fourth invite — the same fund as
the first, through the narrowest profile. Opening both side by side is the demo.

Also rewrites the public copy in a plainer voice.
Drops the opening summary sentence — the memo says what it is by saying it —
swaps the two footnotes so the load-bearing caveat lands first, and dates it
properly. Adds the rendered PDF so the memo can be handed over without a
browser.
Nothing links to it, and it explained ground the build plan and README now cover
in place.
Adds the section a judge or an allocator reads first: what the manager sees at
/manage versus what the allocator gets from a link, with the measured
byte-counts per profile in a table, and the note that a dash there means the key
is absent from the JSON rather than hidden in the UI.

Documents both demo doors and the admin token, spells out that demo_admin_token
is served only under PODARENA_DEMO_FIXTURES, and updates the test count to 94.
@socket-security

socket-security Bot commented Aug 8, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm @noble/hashes is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: web/package-lock.jsonnpm/viem@2.55.11npm/@noble/hashes@1.8.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@noble/hashes@1.8.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm @noble/hashes is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: web/package-lock.jsonnpm/viem@2.55.11npm/@noble/hashes@1.8.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@noble/hashes@1.8.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: pypi pycparser is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: uv.lockpypi/cryptography@50.0.0pypi/pycparser@3.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore pypi/pycparser@3.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: pypi pycparser is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: uv.lockpypi/cryptography@50.0.0pypi/pycparser@3.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore pypi/pycparser@3.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

Comment thread api/app.py
Comment thread web/src/manage.tsx
MisreadableMind and others added 13 commits August 8, 2026 17:09
A relative sqlite URL copied from a laptop resolves against the working
directory, which is /app in the container and has no var/. SQLite will not
create a missing directory — it says "unable to open database file" and does
not say which file — so the deploy failed before the schema was applied.

Bootstrap now prints the URL (password hidden) before it connects, because the
failure this most often hits is that the URL is not the one you think it is.
…anager

The console had one "admin" credential doing two jobs. A pod shop has two
people inside the wall: the fund, which allocates across pods and sees the
roster, and a PM, who runs one book. Collapsing them was less code and a lie —
this product is about who can see what.

- PM tokens are pm_<slug>_<mac>, HMAC-derived from the fund's token. No new
  table, deterministic across a reseed, and knowing your own tells you nothing
  about the pod next door.
- Per-record console routes take ScopeDep; roster-level ones take FundDep. A PM
  asking after another pod gets 404, not 403 — a refusal that distinguishes
  "not yours" from "no such thing" leaks the roster one guess at a time.
- /api/me filters the roster server-side, so a PM's browser never receives the
  other names to hide.
- Sign-in is now one button per seat, and the buttons carry the argument:
  "Fund · all 3 pods" next to "Meridian · this pod only".
- One rail colour per seat, so a screenshot says whose eyes you're looking
  through before you read a word.

99 tests, five of them new and negative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing to copy from anywhere — the fund's token is already in the field, so
the whole login is one press of Go, or one click on a seat. Typing takes over
the moment you touch the box, and on a real instance there is nothing to
prefill so it starts empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…demo

The front page opened onto three doors and assumed you already knew why you
were picking one. Now the hero carries the argument and a single CTA straight
into an allocator's view of Meridian, and a three-step strip underneath says
what actually happens: forward the statement, we check the signature and write
the DNS key down on Monad, the allocator verifies it without us.

Three steps because there are three, not because three looks good on a landing
page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It read like a document: a wall of hero prose, then cards of paragraphs
explaining the idea to someone who had already decided to care. Nobody
decides to care while reading paragraph four.

Five sections now, each making one point and handing off to the next:
the problem stated as the three things an allocator can actually do
(two of which fail), the pipeline as four steps, the Meridian/Northwind
pair as one picture (same +21.89%, twelve marks, one of them dashed),
and a seat picker that ends on the demo links.

The page also moves for the first time. Sections fade up as you reach
them, the pipeline's connectors run in the direction the evidence
travels, and the hero card checks a DKIM signature on a seven-second
loop, which is the whole product in six lines. It is theatre over a page
that reads fine standing still, so prefers-reduced-motion turns all of
it off without changing a word.

Two CTAs rather than one, because "sign in as a fund or a PM" is a door
the old page buried below three screens of prose.

Copy is rewritten without em dashes throughout.
The rebuild still read like a memo with pictures next to it. Every tile
carried three sentences explaining an idea the tile had already made.

Body copy is now one line per card. The visuals take the weight: line-art
glyphs for the four pipeline steps, drawn inline for the same reason the
charts are, since an icon font is a network dependency on a page whose
argument is that it needs nobody else's server. The two failing options
now look like they failed, with no fill and a dashed edge, instead of
saying so in a paragraph.

Also cut, all of it text that was explaining rather than showing:

- the hero's "everything is pre loaded" note
- the seat picker's heading, since three labelled doors are the heading
- the PM rail's lecture about the roster next door
- the footer's "the signature was in your inbox the whole time"
- the landing footnote that narrated its own missing registry, which is
  now simply absent when there is no address to link to

Northwind's strategy field said "Same numbers, one month short of proof",
which is a note to the audience wearing a fund's clothes. It reads as a
real strategy now, and the record page stops emitting a stray separator
when a record has no strategy at all.
One stroke doing both jobs the product does: it starts as a check mark and
leaves as an equity curve. Drawn by the same dawn arc as the wordmark, but
stopped at the steel→cream half — the umber end is dark-on-dark against Onyx
and is gone by 16px.

svg for modern browsers, a 16/32/48 .ico for everything else (each size
rendered from the vector, not downscaled from one), and a 180px touch icon
with a square ground so iOS can apply its own corner mask.
web/tsconfig.tsbuildinfo is an incremental compile cache. It has no business
in review — it rewrites itself on every build, so it turns up dirty in
`git status` after anyone runs the web build and gets swept into unrelated
commits.
`access_event` is append-only in Postgres and a trigger rejects UPDATE, so
filling `chain_tx` in after the insert raises. Call `log_access` first and
write the row complete.

The ordering costs almost nothing: `log_access` never raises, it returns a
failed receipt, so a network blip still writes the row with a null
`chain_tx`. The only loss is a crash during the RPC itself.
The platform is the system of record for PM and fund statements, so
arguing against "the bank's API" argued against something that isn't in
the story. The rung we actually own is `platform_observed` — "We saw it.
Our word only." Saying that out loud is what makes the DKIM card earn
its tick.
Without a `branch:` key Render builds the repo's default branch. Ours is
`main`, which is 18 commits behind and still contains the append-only bug
fixed in f476baa — so the fix was committed, pushed and reviewed while the
running server kept raising on it.

Pinning the branch here says out loud which code is the deployed code.
The App row still said 'not yet deployed', which is the one sentence a
judge checking eligibility reads as a failed gate. It now points at the
deployment and at the staged allocator links.

The strategy memo is internal — pricing, the ARR ramp, the list of likely
acquirers, the size of the raise. The README was linking the whole room
to it. The files are still in docs/; only the invitation is gone.
The 1..jpg name was a slip of the screenshot tool. This is the full-detail
allocator view of meridian-global-macro, watermark and all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant