diff --git a/src/__tests__/vault.test.ts b/src/__tests__/vault.test.ts new file mode 100644 index 00000000..c1e44c49 --- /dev/null +++ b/src/__tests__/vault.test.ts @@ -0,0 +1,163 @@ +import { + extractWikilinks, + loadVaultGraph, + splitFrontmatter, + summarize, + FEATURE_STATES, +} from "@/lib/vault"; + +describe("splitFrontmatter", () => { + it("parses scalars, inline arrays and block lists", () => { + const { frontmatter, body } = splitFrontmatter( + [ + "---", + "type: feature", + "state: delivered", + "issues: [122, 213]", + "code_paths:", + " - src/lib/vault.ts", + " - src/pages/roadmap", + "empty: []", + "---", + "", + "# Title", + "", + "Body text.", + ].join("\n"), + ); + + expect(frontmatter.type).toBe("feature"); + expect(frontmatter.state).toBe("delivered"); + expect(frontmatter.issues).toEqual(["122", "213"]); + expect(frontmatter.code_paths).toEqual([ + "src/lib/vault.ts", + "src/pages/roadmap", + ]); + expect(frontmatter.empty).toEqual([]); + expect(body).toContain("# Title"); + }); + + it("strips matching quotes but leaves inner punctuation alone", () => { + const { frontmatter } = splitFrontmatter( + ['---', 'owner: "Quirin & Andre"', "area: 'Platform & UX'", "---", ""].join( + "\n", + ), + ); + expect(frontmatter.owner).toBe("Quirin & Andre"); + expect(frontmatter.area).toBe("Platform & UX"); + }); + + it("returns an empty result for a note with no frontmatter", () => { + const { frontmatter, body } = splitFrontmatter("# Just a heading\n\nText."); + expect(frontmatter).toEqual({}); + expect(body).toBe("# Just a heading\n\nText."); + }); + + it("does not treat an unterminated fence as frontmatter", () => { + const { frontmatter } = splitFrontmatter("---\ntype: feature\nno end fence"); + expect(frontmatter).toEqual({}); + }); + + it("handles CRLF line endings", () => { + const { frontmatter } = splitFrontmatter( + "---\r\ntype: feature\r\nstate: blocked\r\n---\r\n\r\nBody.", + ); + expect(frontmatter.state).toBe("blocked"); + }); +}); + +describe("extractWikilinks", () => { + it("collects links, de-duplicates them and drops aliases and anchors", () => { + expect( + extractWikilinks( + "See [[Alpha]] and [[Beta|the second]] and [[Alpha]] and [[Gamma#section]].", + ), + ).toEqual(["Alpha", "Beta", "Gamma"]); + }); + + it("returns nothing when there are no links", () => { + expect(extractWikilinks("Plain prose with [a link](https://x.dev).")).toEqual( + [], + ); + }); +}); + +describe("summarize", () => { + it("uses the first paragraph, dropping the heading and link syntax", () => { + expect( + summarize("# Title\n\nFirst para with [[A Link]] and `code`.\n\nSecond."), + ).toBe("First para with A Link and code."); + }); +}); + +describe("loadVaultGraph", () => { + const graph = loadVaultGraph(); + + it("loads features, areas and states from the vault", () => { + const kinds = graph.nodes.map((n) => n.kind); + expect(kinds.filter((k) => k === "feature").length).toBeGreaterThan(0); + expect(kinds.filter((k) => k === "area").length).toBeGreaterThan(0); + expect(kinds.filter((k) => k === "state")).toHaveLength( + FEATURE_STATES.length, + ); + }); + + it("gives every feature exactly one area edge and one state edge", () => { + const features = graph.nodes.filter((n) => n.kind === "feature"); + for (const feature of features) { + const area = graph.edges.filter( + (e) => e.source === feature.id && e.kind === "in-area", + ); + const state = graph.edges.filter( + (e) => e.source === feature.id && e.kind === "has-state", + ); + expect(area).toHaveLength(1); + expect(state).toHaveLength(1); + } + }); + + it("only emits edges between nodes that exist", () => { + const ids = new Set(graph.nodes.map((n) => n.id)); + for (const edge of graph.edges) { + expect(ids.has(edge.source)).toBe(true); + expect(ids.has(edge.target)).toBe(true); + } + }); + + it("de-duplicates mutual references into a single relates-to edge", () => { + const pairs = graph.edges + .filter((e) => e.kind === "relates-to") + .map((e) => [e.source, e.target].sort().join("|")); + expect(new Set(pairs).size).toBe(pairs.length); + }); + + it("never links a feature to itself", () => { + for (const edge of graph.edges) { + expect(edge.source).not.toBe(edge.target); + } + }); + + it("counts features by state consistently with the nodes", () => { + for (const state of FEATURE_STATES) { + const actual = graph.nodes.filter( + (n) => n.kind === "feature" && n.state === state, + ).length; + expect(graph.counts[state]).toBe(actual); + } + }); + + it("gives every feature a non-empty summary", () => { + for (const feature of graph.nodes.filter((n) => n.kind === "feature")) { + expect(feature.summary.length).toBeGreaterThan(0); + } + }); + + it("records degree matching the edges that touch each node", () => { + for (const node of graph.nodes) { + const touching = graph.edges.filter( + (e) => e.source === node.id || e.target === node.id, + ).length; + expect(node.degree).toBe(touching); + } + }); +}); diff --git a/src/components/common/overall-layout/site-footer.tsx b/src/components/common/overall-layout/site-footer.tsx index a85f28a5..a743588e 100644 --- a/src/components/common/overall-layout/site-footer.tsx +++ b/src/components/common/overall-layout/site-footer.tsx @@ -6,6 +6,8 @@ const productLinks = [ { label: "Governance", href: "/governance" }, { label: "DRep Explorer", href: "/governance/drep" }, { label: "Import a wallet", href: "/wallets/import-wallet" }, + { label: "Roadmap", href: "/roadmap" }, + { label: "Feature graph", href: "/roadmap/graph" }, { label: "Blog", href: "/blog" }, ]; diff --git a/src/components/pages/homepage/roadmap/.next/trace b/src/components/pages/homepage/roadmap/.next/trace new file mode 100644 index 00000000..70b74904 --- /dev/null +++ b/src/components/pages/homepage/roadmap/.next/trace @@ -0,0 +1 @@ +[{"name":"generate-buildid","duration":181,"timestamp":291208474266,"id":4,"parentId":1,"tags":{},"startTime":1785140968470,"traceId":"08b1d61d3f5984a0"},{"name":"load-custom-routes","duration":1051,"timestamp":291208474506,"id":5,"parentId":1,"tags":{},"startTime":1785140968470,"traceId":"08b1d61d3f5984a0"},{"name":"create-dist-dir","duration":5435,"timestamp":291208475573,"id":6,"parentId":1,"tags":{},"startTime":1785140968471,"traceId":"08b1d61d3f5984a0"},{"name":"clean","duration":504,"timestamp":291208481602,"id":7,"parentId":1,"tags":{},"startTime":1785140968477,"traceId":"08b1d61d3f5984a0"},{"name":"next-build","duration":33296,"timestamp":291208448914,"id":1,"tags":{"buildMode":"default","version":"16.2.6","bundler":"webpack","failed":true},"startTime":1785140968444,"traceId":"08b1d61d3f5984a0"}] diff --git a/src/components/pages/homepage/roadmap/.next/trace-build b/src/components/pages/homepage/roadmap/.next/trace-build new file mode 100644 index 00000000..deb3a832 --- /dev/null +++ b/src/components/pages/homepage/roadmap/.next/trace-build @@ -0,0 +1 @@ +[{"name":"next-build","duration":33296,"timestamp":291208448914,"id":1,"tags":{"buildMode":"default","version":"16.2.6","bundler":"webpack","failed":true},"startTime":1785140968444,"traceId":"08b1d61d3f5984a0"}] diff --git a/src/components/pages/homepage/roadmap/data.ts b/src/components/pages/homepage/roadmap/data.ts new file mode 100644 index 00000000..c2a975c9 --- /dev/null +++ b/src/components/pages/homepage/roadmap/data.ts @@ -0,0 +1,517 @@ +/** + * Roadmap content, kept apart from the view so the page stays declarative and + * the plan can be edited without touching layout code. + * + * The source of record is ROADMAP.md at the repo root — when a month lands or + * slips, update both. `status` here reflects the `preprod` branch, which is not + * the same as what is live in production (see RELEASE_GAP). + */ + +/** Twelve months of the plan, left to right. `index` is the grid column offset. */ +export const MONTHS = [ + { short: "May", year: "2026" }, + { short: "Jun", year: "2026" }, + { short: "Jul", year: "2026" }, + { short: "Aug", year: "2026" }, + { short: "Sep", year: "2026" }, + { short: "Oct", year: "2026" }, + { short: "Nov", year: "2026" }, + { short: "Dec", year: "2026" }, + { short: "Jan", year: "2027" }, + { short: "Feb", year: "2027" }, + { short: "Mar", year: "2027" }, + { short: "Apr", year: "2027" }, +] as const; + +/** 1-based month number the plan has reached; the "today" rule sits at its right edge. */ +export const CURRENT_MONTH = 3; // July 2026 + +export type Status = "done" | "risk" | "planned"; + +export type RoadmapItem = { + label: string; + /** 1-based month the bar starts in. */ + start: number; + /** Number of months the bar covers. */ + span: number; + status: Status; + /** "Q", "A", or "Q·A". Omitted where the month carries no single owner. */ + owner?: string; + detail: string; +}; + +export type Track = { + name: string; + sub: string; + items: RoadmapItem[]; +}; + +export const TRACKS: Track[] = [ + { + name: "Release & production health", + sub: "Blocking", + items: [ + { + label: "Gap opens", + start: 3, + span: 1, + status: "risk", + detail: + "Production stuck at the 2026-05-10 migration. The 17 June deploy run failed, and the path-filtered workflow never re-fired.", + }, + { + label: "Release", + start: 4, + span: 1, + status: "risk", + owner: "Q", + detail: + "August, first task: merge preprod into main, dispatch Deploy Database Migrations, confirm all four apply. Closes the RLS exposure and un-breaks governance tallies.", + }, + { + label: "Drift gate", + start: 5, + span: 1, + status: "planned", + owner: "Q", + detail: + "A post-deploy prisma migrate status gate with drift alerting, so merged and applied cannot silently diverge again.", + }, + ], + }, + { + name: "Document Sign-Off", + sub: "Flagship", + items: [ + { + label: "MVP — build & ship", + start: 4, + span: 2, + status: "planned", + owner: "Q·A", + detail: + "Finalize PRD-001 (still Draft), then a five-model schema, tRPC routes, CIP-8 enforcement, version-hash binding, Documents UI, six-state lifecycle and proof export. Ready = a pilot team runs all six user stories unaided.", + }, + { + label: "v1 history", + start: 6, + span: 1, + status: "planned", + owner: "Q", + detail: + "v1: revision history first-class, diff and rollback, richer audit export. Still off-chain.", + }, + { + label: "v2 anchor", + start: 9, + span: 1, + status: "planned", + owner: "Q", + detail: + "v2 Checkpoints: opt-in on-chain anchoring of a version hash plus its parent in Cardano transaction metadata.", + }, + { + label: "v3 research", + start: 11, + span: 1, + status: "planned", + owner: "A", + detail: + "v3 Collaboration and standards: CRDT co-authoring, a CIP candidate, an eIDAS/EUDI QES bridge. Scoped as research.", + }, + ], + }, + { + name: "Governance", + sub: "DReps · voting", + items: [ + { + label: "Voting", + start: 2, + span: 1, + status: "done", + owner: "Q", + detail: + "In-app voting for multisig DReps: Ekklesia/Hydra budget voting, DRep-registration detection, ballot UX, DB-cached tallies. Closed #122 five months early.", + }, + { + label: "DRep explorer", + start: 3, + span: 1, + status: "done", + owner: "Q", + detail: + "Public DRep vote-history explorer, no wallet required. CIP-100/136 rationales resolved from IPFS, nine-column CSV export, served through a Koios proxy.", + }, + { + label: "Proxy", + start: 8, + span: 1, + status: "planned", + owner: "Q", + detail: + "Proxy voting polish and documentation, plus the collateral service for proxy usage (#221) — the last backlog item without a slot.", + }, + ], + }, + { + name: "Bot & agent platform", + sub: "Early · M7 freed", + items: [ + { + label: "Bot platform", + start: 3, + span: 1, + status: "done", + owner: "Q", + detail: + "Arrived about four months early. Human-in-the-loop onboarding, five scopes plus per-wallet grants, 27 bot-accessible endpoints, rate limiting and ballot drafting. A bot still cannot move funds alone.", + }, + { + label: "Webhooks", + start: 7, + span: 1, + status: "planned", + owner: "A", + detail: + "All that remains of Bot platform v2 is webhooks — no webhook code exists yet. Plus a multisig MCP server, so an agent can act as observer or ballot drafter.", + }, + ], + }, + { + name: "Wallets & discovery", + sub: "Wallet V2 · devices", + items: [ + { + label: "Legacy", + start: 1, + span: 1, + status: "done", + owner: "Q", + detail: + "Legacy wallet compatibility fixed — DRep retirement and deregistration (#210, #225); issue #223 closed.", + }, + { + label: "Wallet V2", + start: 3, + span: 1, + status: "done", + owner: "A", + detail: + "On-chain wallet registration and discovery shipped in #340, on schedule.", + }, + { + label: "Discover", + start: 5, + span: 1, + status: "planned", + owner: "A", + detail: + "The Discover page moved up from M10 to ride the delivered Wallet V2 discovery work — plus lookup by signer and policy.", + }, + { + label: "Hardware", + start: 6, + span: 1, + status: "planned", + owner: "A", + detail: + "Ledger and Trezor. Their CIP-8 signData support is limited and Sign-Off approvals depend on it — scope that constraint during the Aug–Sep build, not after.", + }, + { + label: "Invites", + start: 10, + span: 1, + status: "planned", + owner: "Q", + detail: "Invite flow (PR #67).", + }, + ], + }, + { + name: "Reliability & CI", + sub: "Tests · pipeline", + items: [ + { + label: "Smoke CI & preprod", + start: 1, + span: 2, + status: "done", + owner: "A", + detail: + "Preprod environment, real-chain smoke system (#213 closed), deploy-migrations on Node 22, pg pool cap, dependency hardening.", + }, + { + label: "E2E suite", + start: 3, + span: 1, + status: "done", + owner: "A", + detail: + "Playwright E2E: 11 spec files, about 54 tests — wallet creation, real preprod ring transfers, staking, proxy, DRep and ballot UI, bot management, access control. Runs in Docker.", + }, + { + label: "Unblock CI", + start: 4, + span: 1, + status: "risk", + owner: "A", + detail: + "pr-multisig-v1-smoke.yml hard-fails when secrets are absent, and dependabot runs never receive them — so all seven open dependency PRs are red for systemic reasons. ci-smoke-preprod.yml already has the guard to copy.", + }, + { + label: "Test depth", + start: 5, + span: 1, + status: "planned", + owner: "Q", + detail: + "Extend Playwright to the Sign-Off flows; transaction-builder and tRPC integration tests (#255).", + }, + ], + }, + { + name: "Notifications", + sub: "Email · outbox", + items: [ + { + label: "Notification center", + start: 2, + span: 2, + status: "done", + owner: "A", + detail: + "Resend-backed email with a real outbox: idempotency keys, retry backoff, nine delivery statuses, per-wallet and per-signer settings, hashed-token verification.", + }, + { + label: "Drain", + start: 4, + span: 1, + status: "planned", + owner: "A", + detail: + "#327 follow-ups, Playwright coverage, and a scheduled drain — no cron currently runs drainNotificationOutbox.", + }, + { + label: "Digests", + start: 5, + span: 1, + status: "planned", + owner: "A", + detail: + "Ballot-deadline and threshold-reached reminders on the existing outbox — product work, since the infrastructure already exists.", + }, + ], + }, + { + name: "Platform & UX", + sub: "Mesh 2.0 · interface", + items: [ + { + label: "UX base", + start: 2, + span: 1, + status: "done", + owner: "Q", + detail: + "Signing and auth reliability, mobile foundations, skeleton and empty states, error toasts, pagination, landing, SEO and the glass theme.", + }, + { + label: "Mesh 2.0 — awaiting upstream 2.x", + start: 3, + span: 3, + status: "risk", + owner: "Q", + detail: + "Blocked upstream — npm latest for @meshsdk/core is still 1.9.1; no 2.x exists. Our side is ready. Demoted from a monthly task to a standing watch item.", + }, + { + label: "Papercuts", + start: 6, + span: 1, + status: "planned", + owner: "A", + detail: + "UX papercut batch: full-address verification (#196), transaction pagination (#30), a better 404 page (#22).", + }, + { + label: "dApp link", + start: 7, + span: 1, + status: "planned", + owner: "Q", + detail: + "dApp connector — external dApps request multi-sig transactions — paired with improved authentication (#135), since they are the same problem surface.", + }, + { + label: "Audit", + start: 11, + span: 1, + status: "planned", + owner: "Q", + detail: "Performance and UX audit, plus the final summary report.", + }, + ], + }, + { + name: "Research", + sub: "FROST · PQC", + items: [ + { + label: "FROST & PQC — kickoff to go/no-go", + start: 4, + span: 3, + status: "planned", + owner: "Q", + detail: + "FROST threshold Schnorr for Cardano (#220), plus Lemour post-quantum multi-sig. Kickoff slipped from July — starting in August keeps runway for the October go/no-go. Deliverables: a trade-off note, a PoC if libraries allow, and a recommendation.", + }, + ], + }, + { + name: "Growth & accounts", + sub: "Profiles · vesting", + items: [ + { + label: "Vesting", + start: 9, + span: 1, + status: "planned", + owner: "Q·A", + detail: + "Vesting — time-locked multi-sig contracts (#81). Alongside user profiles and contacts, which build on the existing Contact model rather than starting fresh.", + }, + { + label: "Buffer", + start: 12, + span: 1, + status: "planned", + detail: + "Buffer month — absorbs slippage, finalizes reporting, plans the next cycle. No fixed feature commitments.", + }, + ], + }, +]; + +/** Headline numbers for the summary strip. */ +export const STATS = [ + { + k: "Shipped", + v: "6", + n: "workstreams delivered in May–July", + tone: "good" as const, + }, + { + k: "Ahead of plan", + v: "4", + n: "capabilities landed early, freeing Nov–Dec", + tone: "good" as const, + }, + { + k: "Unreleased", + v: "75", + n: "commits on preprod, not on main", + tone: "bad" as const, + }, + { + k: "Migrations due", + v: "4", + n: "pending against production", + tone: "bad" as const, + }, + { + k: "Flagship", + v: "Aug", + n: "Document Sign-Off build begins", + tone: "neutral" as const, + }, +]; + +/** Capability that arrived before its planned month, and what that freed up. */ +export const AHEAD_OF_SCHEDULE = [ + { + capability: "Governance metadata fix (#122)", + planned: "Nov 26", + delivered: "Jun 26", + effect: "Closed five months early.", + }, + { + capability: "Bot platform — scoped auth, reference client, ballot API", + planned: "Nov 26", + delivered: "Jul 26", + effect: + "November reduces to webhooks only — the one piece with no code yet.", + }, + { + capability: "API documentation & developer portal", + planned: "Dec 26", + delivered: "Jun–Jul 26", + effect: + "Done. Swagger UI, a 1841-line OpenAPI spec, /llms.txt and a downloadable agent skill. December slot freed.", + }, + { + capability: "Pending transactions on homepage (#125)", + planned: "Nov 26", + delivered: "Shipped", + effect: + "Surfaced on the wallets dashboard. Issue still open — verify and close.", + }, + { + capability: "Playwright E2E suite", + planned: "Unscheduled", + delivered: "Jun–Jul 26", + effect: "Becomes the safety net Document Sign-Off ships against.", + }, +]; + +/** What the product can do today, verified against the codebase on 2026-07-26. */ +export const DELIVERED = [ + { + title: "Governance", + points: [ + "In-app voting for multisig DReps — budget voting, registration detection, ballot UX, DB-cached tallies.", + "Public DRep explorer — vote history with search and filters, rationales resolved from IPFS, CSV export. No wallet required.", + "Rationale drafting, IPFS reliability, ballot CSV import and export.", + ], + }, + { + title: "Bot platform", + points: [ + "Human-in-the-loop onboarding — a bot registers, its owner approves a 30-minute claim code, the secret is retrieved once.", + "Double opt-in authorization — five scopes on the key and a per-wallet grant.", + "27 endpoints reachable by bots, including signing with auto-submit at threshold. A bot can never move funds alone.", + "Rate limiting at 60/min by default, 5/min on secret rotation.", + ], + }, + { + title: "Developer & agent surface", + points: [ + "/api-docs — Swagger UI with a wallet-signature token generator.", + "/api/swagger — a 1841-line OpenAPI 3.0 spec.", + "/llms.txt and /api/skill — agent orientation and a downloadable skill.", + ], + }, + { + title: "Notifications", + points: [ + "A real outbox — idempotency keys, retry backoff, nine delivery statuses including four distinct skip reasons.", + "Per-wallet, per-signer settings and hashed-token email verification.", + "Gap: nothing drains it on a schedule yet.", + ], + }, + { + title: "Testing & CI", + points: [ + "11 Playwright specs, ~54 tests — wallet creation, real preprod ring transfers, staking, proxy, governance, bot management, access control.", + "Real-chain smoke system closed; migrations running on Node 22.", + ], + }, + { + title: "Platform", + points: [ + "Mesh 2.0 groundwork — Prisma 7.8, Next 16, wallet operations behind a single bridge.", + "Signing and auth reliability; byte-preserving witness merge, regression-tested.", + "Mobile foundations, empty states, landing, SEO, glass theme.", + ], + }, +]; diff --git a/src/components/pages/homepage/roadmap/graph.tsx b/src/components/pages/homepage/roadmap/graph.tsx new file mode 100644 index 00000000..c8b18205 --- /dev/null +++ b/src/components/pages/homepage/roadmap/graph.tsx @@ -0,0 +1,738 @@ +import React from "react"; +import Link from "next/link"; +import { ArrowLeft, Search, X } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { + FEATURE_STATES, + type FeatureState, + type VaultEdge, + type VaultGraph, + type VaultNode, +} from "@/lib/vault-types"; + +/** + * Interactive knowledge graph over the feature vault. + * + * The layout is a small force simulation run on mount — repulsion between every + * pair, springs along edges, and a pull toward the centre — rather than a graph + * dependency. At this size (tens of nodes) the O(n²) pass is cheap, and it keeps + * the wallet's dependency surface unchanged. + * + * The simulation is settled *before paint* rather than animated: it runs a fixed + * number of iterations in a `useMemo`, so there is no motion to sit through and + * nothing to reflow on every frame. Dragging a node re-runs a short relaxation + * from the current positions. + */ + +type Props = { graph: VaultGraph }; + +const STATE_LABEL: Record = { + delivered: "Delivered", + "in-progress": "In progress", + planned: "Planned", + blocked: "Blocked", +}; + +/** + * Emerald / blue / amber stay distinguishable under the common forms of colour + * blindness; every use is paired with a text label, and the legend doubles as the + * filter, so colour is never the only channel. + */ +const STATE_DOT: Record = { + delivered: "bg-emerald-500", + "in-progress": "bg-blue-500", + planned: "bg-muted-foreground/40", + blocked: "bg-amber-500", +}; + +const STATE_STROKE: Record = { + delivered: "stroke-emerald-600 dark:stroke-emerald-400", + "in-progress": "stroke-blue-600 dark:stroke-blue-400", + planned: "stroke-muted-foreground/50", + blocked: "stroke-amber-600 dark:stroke-amber-400", +}; + +const STATE_FILL: Record = { + delivered: "fill-emerald-500/25", + "in-progress": "fill-blue-500/25", + planned: "fill-muted", + blocked: "fill-amber-500/30", +}; + +const WIDTH = 1000; +const HEIGHT = 680; + +type Point = { x: number; y: number }; +type Positions = Record; + +/** Deterministic [0,1) hash, so the layout is identical on server and client. */ +function seededUnit(key: string, salt: number): number { + let h = 2166136261 ^ salt; + for (let i = 0; i < key.length; i++) { + h = Math.imul(h ^ key.charCodeAt(i), 16777619); + } + return ((h >>> 0) % 10000) / 10000; +} + +function radiusFor(node: VaultNode): number { + if (node.kind === "state") return 26; + if (node.kind === "area") return 20; + return 7 + Math.min(node.degree, 8) * 1.2; +} + +/** + * Relax the layout. Areas and states carry more mass so they anchor their + * clusters instead of being flung out by their many edges. + */ +function simulate( + nodes: VaultNode[], + edges: VaultEdge[], + start: Positions, + iterations: number, + /** Nodes the user has dragged: they exert force but never move. */ + pinned: ReadonlySet = new Set(), +): Positions { + const pos: Positions = {}; + for (const n of nodes) { + const p = start[n.id]; + pos[n.id] = p + ? { x: p.x, y: p.y } + : { + x: WIDTH / 2 + (seededUnit(n.id, 1) - 0.5) * WIDTH * 0.75, + y: HEIGHT / 2 + (seededUnit(n.id, 2) - 0.5) * HEIGHT * 0.75, + }; + } + + const mass = (n: VaultNode) => + n.kind === "state" ? 5 : n.kind === "area" ? 3 : 1; + const byId = new Map(nodes.map((n) => [n.id, n])); + + for (let step = 0; step < iterations; step++) { + const cool = 1 - step / (iterations + 1); + const force: Positions = {}; + for (const n of nodes) force[n.id] = { x: 0, y: 0 }; + + // Repulsion between every pair. + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i]!; + const b = nodes[j]!; + const pa = pos[a.id]!; + const pb = pos[b.id]!; + let dx = pa.x - pb.x; + let dy = pa.y - pb.y; + let dist = Math.hypot(dx, dy); + if (dist < 0.01) { + // Identical positions have no direction; nudge deterministically. + dx = seededUnit(a.id + b.id, 3) - 0.5; + dy = seededUnit(b.id + a.id, 4) - 0.5; + dist = 0.01; + } + // Hubs are the only labelled nodes, so push them apart harder than + // features — otherwise two area labels land on top of each other. + const bothHubs = a.kind !== "feature" && b.kind !== "feature"; + const push = + ((bothHubs ? 96000 : 38000) * (mass(a) + mass(b))) / + (dist * dist * 2); + const ux = (dx / dist) * push; + const uy = (dy / dist) * push; + force[a.id]!.x += ux / mass(a); + force[a.id]!.y += uy / mass(a); + force[b.id]!.x -= ux / mass(b); + force[b.id]!.y -= uy / mass(b); + } + } + + // Springs along edges. + for (const e of edges) { + const a = byId.get(e.source); + const b = byId.get(e.target); + if (!a || !b) continue; + const pa = pos[a.id]!; + const pb = pos[b.id]!; + const dx = pb.x - pa.x; + const dy = pb.y - pa.y; + const dist = Math.max(Math.hypot(dx, dy), 0.01); + // Area edges rest longer than state edges, so areas splay into their own + // neighbourhoods rather than piling onto the state hubs. + const rest = e.kind === "in-area" ? 118 : e.kind === "has-state" ? 190 : 96; + const pull = (dist - rest) * 0.045; + const ux = (dx / dist) * pull; + const uy = (dy / dist) * pull; + force[a.id]!.x += ux / mass(a); + force[a.id]!.y += uy / mass(a); + force[b.id]!.x -= ux / mass(b); + force[b.id]!.y -= uy / mass(b); + } + + // Gentle pull to centre so nothing drifts off-canvas. + for (const n of nodes) { + const p = pos[n.id]!; + force[n.id]!.x += (WIDTH / 2 - p.x) * 0.012; + force[n.id]!.y += (HEIGHT / 2 - p.y) * 0.012; + } + + for (const n of nodes) { + if (pinned.has(n.id)) continue; + const p = pos[n.id]!; + const f = force[n.id]!; + const limit = 28 * cool; + p.x += Math.max(-limit, Math.min(limit, f.x * cool)); + p.y += Math.max(-limit, Math.min(limit, f.y * cool)); + // Hubs are always labelled, so keep them far enough from the bottom and top + // edges that the label has somewhere to sit. + const pad = radiusFor(n) + (n.kind === "feature" ? 6 : 30); + p.x = Math.max(pad, Math.min(WIDTH - pad, p.x)); + p.y = Math.max(pad, Math.min(HEIGHT - pad, p.y)); + } + } + + return pos; +} + +export function VaultGraphView({ graph }: Props) { + const [activeStates, setActiveStates] = React.useState>( + () => new Set(FEATURE_STATES), + ); + const [query, setQuery] = React.useState(""); + const [showStates, setShowStates] = React.useState(true); + const [selected, setSelected] = React.useState(null); + const [hovered, setHovered] = React.useState(null); + /** Where the next relaxation starts from, so a drag nudges rather than reshuffles. */ + const [seed, setSeed] = React.useState({}); + /** Only the nodes the user actually dragged stay put. */ + const [pinnedIds, setPinnedIds] = React.useState>( + () => new Set(), + ); + /** The node under an in-flight drag, tracked outside the layout memo. */ + const [live, setLive] = React.useState<(Point & { id: string }) | null>(null); + const svgRef = React.useRef(null); + const gesture = React.useRef<{ + id: string; + x: number; + y: number; + moved: boolean; + } | null>(null); + + const visibleNodes = React.useMemo(() => { + return graph.nodes.filter((n) => { + if (n.kind === "feature") return activeStates.has(n.state!); + // Every feature links to both its area and its state, so two hub systems + // pull on the same nodes. Dropping the state hubs untangles the layout into + // pure area structure, which is the more useful view of the product. + if (n.kind === "state") return showStates; + return true; + }); + }, [graph.nodes, activeStates, showStates]); + + const visibleIds = React.useMemo( + () => new Set(visibleNodes.map((n) => n.id)), + [visibleNodes], + ); + + const visibleEdges = React.useMemo( + () => + graph.edges.filter( + (e) => visibleIds.has(e.source) && visibleIds.has(e.target), + ), + [graph.edges, visibleIds], + ); + + const layout = React.useMemo( + () => simulate(visibleNodes, visibleEdges, seed, 320, pinnedIds), + [visibleNodes, visibleEdges, seed, pinnedIds], + ); + + /** + * Render positions. A drag in flight moves only the dragged node — re-running the + * simulation on every pointer move would be both janky and disorienting, so the + * relaxation happens once, on release. + */ + const positions = React.useMemo( + () => (live ? { ...layout, [live.id]: { x: live.x, y: live.y } } : layout), + [layout, live], + ); + + /** + * Hubs are always labelled, so two that settle near each other collide. Walk them + * in a stable order and flip a label above its node when the slot below is taken. + * Width is approximated from the character count — good enough to separate them, + * and it avoids measuring text during render. + */ + const labelDy = React.useMemo(() => { + const placed: { x1: number; x2: number; y1: number; y2: number }[] = []; + const offsets: Record = {}; + const hubs = visibleNodes + .filter((n) => n.kind !== "feature") + .sort((a, b) => a.id.localeCompare(b.id)); + + for (const hub of hubs) { + const p = layout[hub.id]; + if (!p) continue; + const r = radiusFor(hub); + // Slightly over-estimate the rendered width (semibold 11px) plus padding — + // under-estimating is what lets two labels sit on top of each other. + const halfWidth = (hub.id.length * 6.6 + 10) / 2; + // Below, above, then progressively further out in each direction. + const candidates = [ + r + 12, + -(r + 6), + r + 26, + -(r + 20), + r + 40, + -(r + 34), + ]; + + const boxFor = (dy: number) => ({ + x1: p.x - halfWidth, + x2: p.x + halfWidth, + y1: p.y + dy - 10, + y2: p.y + dy + 3, + }); + const usable = (b: ReturnType) => + b.y1 > 2 && + b.y2 < HEIGHT - 2 && + !placed.some( + (o) => b.x1 < o.x2 && o.x1 < b.x2 && b.y1 < o.y2 && o.y1 < b.y2, + ); + + const chosen = candidates.find((dy) => usable(boxFor(dy))); + const dy = chosen ?? candidates[0]!; + offsets[hub.id] = dy; + placed.push(boxFor(dy)); + } + return offsets; + }, [visibleNodes, layout]); + + /** Neighbours of the focused node, used to dim everything else. */ + const focusId = hovered ?? selected; + const neighbours = React.useMemo(() => { + if (!focusId) return null; + const set = new Set([focusId]); + for (const e of visibleEdges) { + if (e.source === focusId) set.add(e.target); + if (e.target === focusId) set.add(e.source); + } + return set; + }, [focusId, visibleEdges]); + + const matches = React.useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return null; + return new Set( + visibleNodes + .filter( + (n) => + n.id.toLowerCase().includes(q) || + n.summary.toLowerCase().includes(q) || + (n.area ?? "").toLowerCase().includes(q) || + (n.owner ?? "").toLowerCase().includes(q), + ) + .map((n) => n.id), + ); + }, [query, visibleNodes]); + + const nodeById = React.useMemo( + () => new Map(graph.nodes.map((n) => [n.id, n])), + [graph.nodes], + ); + const detail = selected ? nodeById.get(selected) : undefined; + + const toggleState = (state: FeatureState) => { + setActiveStates((prev) => { + const next = new Set(prev); + if (next.has(state)) { + // Never let the last filter be switched off — an empty graph reads as broken. + if (next.size > 1) next.delete(state); + } else { + next.add(state); + } + return next; + }); + }; + + /** Convert a pointer event to SVG user units, so drag tracks the cursor at any scale. */ + const toSvgPoint = (event: React.PointerEvent): Point | null => { + const svg = svgRef.current; + if (!svg) return null; + const rect = svg.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + return { + x: ((event.clientX - rect.left) / rect.width) * WIDTH, + y: ((event.clientY - rect.top) / rect.height) * HEIGHT, + }; + }; + + const onPointerDown = (id: string) => (event: React.PointerEvent) => { + const point = toSvgPoint(event); + if (!point) return; + // Record the gesture but change nothing yet: touching state here would move the + // node out from under the cursor and the click would never complete. + gesture.current = { id, x: point.x, y: point.y, moved: false }; + }; + + const onPointerMove = (event: React.PointerEvent) => { + const g = gesture.current; + if (!g) return; + const point = toSvgPoint(event); + if (!point) return; + if (!g.moved && Math.hypot(point.x - g.x, point.y - g.y) < 5) return; + g.moved = true; + setLive({ id: g.id, x: point.x, y: point.y }); + }; + + const endDrag = () => { + const g = gesture.current; + gesture.current = null; + if (!g?.moved || !live) { + setLive(null); + return; + } + // Commit: everything restarts from where it currently sits, and only the + // dropped node is pinned so the rest can relax around it. + setSeed({ ...layout, [live.id]: { x: live.x, y: live.y } }); + setPinnedIds((prev) => new Set(prev).add(live.id)); + setLive(null); + }; + + // Dimmed rather than hidden, so the surrounding shape stays readable — on a dark + // ground anything below about a quarter opacity disappears entirely. + const opacityFor = (id: string) => { + if (matches && !matches.has(id)) return 0.2; + if (neighbours && !neighbours.has(id)) return 0.25; + return 1; + }; + + return ( +
+ {/* controls */} +
+
+ {FEATURE_STATES.map((state) => { + const on = activeStates.has(state); + return ( + + ); + })} +
+ +
+ +
+ + setQuery(e.target.value)} + placeholder="Search features…" + aria-label="Search features" + className="h-9 pl-9 text-sm" + /> +
+
+
+ +
+ {/* graph */} +
+ + + {visibleEdges.map((e, i) => { + const a = positions[e.source]; + const b = positions[e.target]; + if (!a || !b) return null; + const lit = + !neighbours || + (neighbours.has(e.source) && neighbours.has(e.target)); + return ( + + ); + })} + + + + {visibleNodes.map((node) => { + const p = positions[node.id]; + if (!p) return null; + const r = radiusFor(node); + const isFeature = node.kind === "feature"; + const stroke = isFeature + ? STATE_STROKE[node.state!] + : "stroke-foreground/60"; + const fill = isFeature + ? STATE_FILL[node.state!] + : node.kind === "state" + ? "fill-background" + : "fill-muted"; + const isSelected = selected === node.id; + // Hubs stay labelled; feature labels appear on demand, otherwise + // 52 of them collide into an unreadable mat of text. + const showLabel = + !isFeature || + isSelected || + hovered === node.id || + (matches?.has(node.id) ?? false); + + return ( + setHovered(node.id)} + onPointerLeave={() => setHovered(null)} + onFocus={() => setHovered(node.id)} + onBlur={() => setHovered(null)} + onClick={() => + setSelected((cur) => (cur === node.id ? null : node.id)) + } + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelected((cur) => + cur === node.id ? null : node.id, + ); + } + }} + > + {isSelected && ( + + )} + + {showLabel && ( + // Anchor labels inward near the edges, or a long hub name + // runs outside the viewBox and gets clipped. + WIDTH - 130 + ? p.x + r + : p.x + } + y={p.y + (labelDy[node.id] ?? r + 12)} + textAnchor={ + p.x < 130 + ? "start" + : p.x > WIDTH - 130 + ? "end" + : "middle" + } + className={`pointer-events-none select-none ${ + isFeature + ? "fill-foreground/85 text-[10px]" + : "fill-foreground text-[11px] font-semibold" + }`} + > + {node.id} + + )} + + ); + })} + + +
+ + {/* detail panel */} + +
+ +

+ Generated from{" "} + {graph.generatedFrom} at build time — + edit a note there and the graph follows.{" "} + + + Back to the roadmap + +

+
+ ); +} + +export default VaultGraphView; diff --git a/src/components/pages/homepage/roadmap/index.tsx b/src/components/pages/homepage/roadmap/index.tsx new file mode 100644 index 00000000..632e908d --- /dev/null +++ b/src/components/pages/homepage/roadmap/index.tsx @@ -0,0 +1,435 @@ +import React from "react"; +import Link from "next/link"; +import { AlertTriangle, Check, Clock, Network } from "lucide-react"; + +import { Reveal } from "@/components/ui/reveal"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { + AHEAD_OF_SCHEDULE, + CURRENT_MONTH, + DELIVERED, + MONTHS, + STATS, + TRACKS, + type RoadmapItem, + type Status, +} from "./data"; + +/** + * Status is encoded by fill *and* by icon and label, never by colour alone — + * emerald/amber separate under the common forms of colour blindness in a way + * emerald/red does not, so red stays reserved for the single critical callout + * rather than being reused as a chart colour. "Planned" is deliberately not a + * fourth hue: absence of status reads better as an outline than as grey fill. + */ +const STATUS_BAR: Record = { + done: "border-emerald-600/50 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/50 dark:text-emerald-300", + risk: "border-amber-600/50 bg-amber-500/10 text-amber-700 dark:border-amber-500/50 dark:text-amber-300", + planned: + "border-dashed border-muted-foreground/40 bg-muted/40 text-muted-foreground hover:border-foreground/40", +}; + +const STATUS_LABEL: Record = { + done: "Delivered", + risk: "Blocked / at risk", + planned: "Planned", +}; + +function StatusIcon({ status }: { status: Status }) { + if (status === "done") return ; + if (status === "risk") + return ; + return null; +} + +/** One bar in the timeline: a month range on a track, with its detail on hover. */ +function Bar({ item, row }: { item: RoadmapItem; row: number }) { + return ( + + +
+ + {/* min-w-0 lets the flex child shrink so a long single word wraps + instead of spilling past the bar into the next month. */} + {item.label} + {item.owner && ( + + {item.owner} + + )} +
+
+ +

{item.label}

+

+ {item.detail} +

+
+
+ ); +} + +function Legend() { + return ( +
+ {(["done", "risk", "planned"] as Status[]).map((s) => ( + + + {STATUS_LABEL[s]} + + ))} + + Q Quirin + A Andre + +
+ ); +} + +/** + * The month grid. Every cell is placed explicitly — the "today" rule spans all + * rows, and a grid item with a definite position is packed before auto-placed + * siblings, which would otherwise shove the month headers a column to the right. + * + * Stacking order matters because the first column is sticky: cells (auto) sit + * under bars and the today rule (z-10), which slide under the frozen workstream + * column (z-20) as the grid scrolls, which in turn sits under the frozen corner + * (z-30). Every sticky cell needs an opaque background, or scrolled bars show + * through it. + */ +function Timeline() { + const columns = `minmax(168px, 184px) repeat(${MONTHS.length}, minmax(84px, 1fr))`; + + return ( +
+
+ {/* header row */} +
+ Workstream +
+ {MONTHS.map((m, i) => { + const n = i + 1; + const isNow = n === CURRENT_MONTH; + const isPast = n < CURRENT_MONTH; + return ( +
+ {m.short} + {m.year} + {/* the marker lives inside the cell, so it cannot collide with a neighbouring header */} + {isNow && ( + + today + + )} +
+ ); + })} + + {/* today rule, on the right edge of the current month. Kept at the bar + layer (z-10) so the frozen first column covers it once the grid is + scrolled horizontally, same as the bars. */} +
+ + {TRACKS.map((track, t) => { + const row = t + 2; + return ( + +
+ + {track.name} + + + {track.sub} + +
+ + {MONTHS.map((m, i) => ( +
+ ))} + + {track.items.map((item) => ( + + ))} + + ); + })} +
+
+ ); +} + +export function PageRoadmap() { + return ( + + {/* w-full + min-w-0 keep this flex item at the width of its container: + `mx-auto` suppresses flex stretch, which would otherwise leave the box + sized to the timeline grid's 1180px min-content and push the whole page + wider than the viewport instead of letting the grid scroll on its own. */} +
+
+

+ Roadmap +

+

+ Twelve months of Mesh Multisig, May 2026 to April 2027 — what has + shipped, what is blocked, and what comes next. +

+

+ Quirin + Andre · ~25 h/wk · revised 2026-07-26 +

+
+ +
+ {/* release gap — the one genuinely critical item, so red is reserved for it */} + +
+ +
+

+ Production is three months behind the work +

+

+ The production database has applied no migration since{" "} + 2026-05-10 + . Four are outstanding, and{" "} + + preprod + {" "} + is{" "} + + 75 commits ahead of{" "} + + main + + {" "} + — so June and July are built but unreleased.{" "} + + deploy-migrations.yml + {" "} + only fires on pushes to{" "} + + main + {" "} + touching{" "} + + prisma/migrations/** + + , so repairing the workflow never re-triggered the run that + failed on 17 June. +

+

+ Live consequences: governance tallies error, the notification + center has no tables, address-less bot registration cannot + work — and seven tables still have row-level security + disabled, reachable by the anon PostgREST role. The fix is + written and merged; it is only undeployed. +

+
+
+
+ + {/* summary strip */} +
+ {STATS.map((s, i) => ( + +
+ + {s.k} + + + {s.v} + + + {s.n} + +
+
+ ))} +
+ + {/* timeline */} +
+
+

+ Workstream timeline +

+

+ Grouped by workstream rather than by owner, so continuity from + delivered to planned stays visible. Hover or focus any bar for + the detail behind it. +

+
+ + +
+

+ + Status reflects the preprod{" "} + branch. Delivered does not mean live. +

+ + + Explore the same work as a feature graph + +
+
+ + {/* ahead of schedule */} +
+
+

+ Landed ahead of schedule +

+

+ July over-delivered against the plan. Four capabilities arrived + before their slot, which is what freed November and December to + absorb new work. +

+
+
+ + + + Capability + Planned + Delivered + Effect on the plan + + + + {AHEAD_OF_SCHEDULE.map((r) => ( + + + {r.capability} + + + {r.planned} + + + {r.delivered} + + + {r.effect} + + + ))} + +
+
+
+ + {/* delivered inventory */} +
+
+

+ Delivered to date +

+

+ What the product can do today, verified against the codebase on + 2026-07-26 rather than inferred from pull-request titles. +

+
+
+ {DELIVERED.map((group, i) => ( + +
+

+ {group.title} +

+
    + {group.points.map((p) => ( +
  • + + {p} +
  • + ))} +
+
+
+ ))} +
+
+ +

+ Source of record:{" "} + + ROADMAP.md + +

+
+
+
+ ); +} + +export default PageRoadmap; diff --git a/src/components/ui/seo-fallback.tsx b/src/components/ui/seo-fallback.tsx index 3f10d9b4..e08d6087 100644 --- a/src/components/ui/seo-fallback.tsx +++ b/src/components/ui/seo-fallback.tsx @@ -25,6 +25,8 @@ import { // the longer per-link blurb is pulled from routeSeo at render time. const FALLBACK_LINKS = [ { path: "/features", label: "Features" }, + { path: "/roadmap", label: "Roadmap" }, + { path: "/roadmap/graph", label: "Feature Graph" }, { path: "/governance", label: "Cardano Governance" }, { path: "/governance/drep", label: "DRep Explorer" }, { path: "/api-docs", label: "API & Bot Documentation" }, diff --git a/src/data/public-routes.ts b/src/data/public-routes.ts index af5bcd88..c3d9b3f0 100644 --- a/src/data/public-routes.ts +++ b/src/data/public-routes.ts @@ -4,6 +4,8 @@ export const publicRoutes = [ "/governance/drep", "/governance/drep/[id]", "/features", + "/roadmap", + "/roadmap/graph", "/api-docs", "/dapps", "/blog", diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 473c9d36..ed57fe69 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -83,6 +83,16 @@ export const routeSeo: Record = { description: "Use Mesh Multisig with your favourite Cardano DApps in multi-signature mode.", }, + "/roadmap": { + title: "Roadmap", + description: + "The twelve-month Mesh Multisig roadmap: what has shipped, what is in progress and what is planned for the Cardano multi-signature wallet, from May 2026 to April 2027.", + }, + "/roadmap/graph": { + title: "Feature Graph", + description: + "An interactive knowledge graph of every Mesh Multisig feature and the state it is in — delivered, planned or blocked — linked to its area and the features it touches.", + }, "/blog": { title: "Blog", description: @@ -152,6 +162,8 @@ export type SitemapRoute = { export const INDEXABLE_ROUTES: SitemapRoute[] = [ { path: "/", changefreq: "weekly", priority: 1.0 }, { path: "/features", changefreq: "monthly", priority: 0.8 }, + { path: "/roadmap", changefreq: "monthly", priority: 0.6 }, + { path: "/roadmap/graph", changefreq: "monthly", priority: 0.5 }, { path: "/blog", changefreq: "weekly", priority: 0.7 }, { path: "/governance", changefreq: "daily", priority: 0.8 }, { path: "/governance/drep", changefreq: "daily", priority: 0.7 }, diff --git a/src/lib/vault-types.ts b/src/lib/vault-types.ts new file mode 100644 index 00000000..a5e4f10c --- /dev/null +++ b/src/lib/vault-types.ts @@ -0,0 +1,52 @@ +/** + * The shape of the feature-vault graph, shared by the build-time loader and the + * browser. + * + * Kept apart from `@/lib/vault` on purpose: that module reads the filesystem, so + * importing any *value* from it — not just a type — would pull `fs` into the client + * bundle and fail the build. Anything the graph UI needs at runtime belongs here. + */ + +export const FEATURE_STATES = [ + "delivered", + "in-progress", + "planned", + "blocked", +] as const; + +export type FeatureState = (typeof FEATURE_STATES)[number]; + +export type NodeKind = "feature" | "area" | "state"; + +export type VaultNode = { + /** Note title, which is also its filename and the target of `[[wikilinks]]`. */ + id: string; + kind: NodeKind; + /** Prose body with the leading `# Heading` stripped. */ + summary: string; + state?: FeatureState; + area?: string; + owner?: string; + /** `YYYY-MM` the work landed or is scheduled for. */ + milestone?: string; + issues: number[]; + prs: number[]; + /** Count of edges touching this node, used to size it in the graph. */ + degree: number; +}; + +export type EdgeKind = "in-area" | "has-state" | "relates-to"; + +export type VaultEdge = { + source: string; + target: string; + kind: EdgeKind; +}; + +export type VaultGraph = { + nodes: VaultNode[]; + edges: VaultEdge[]; + /** Feature counts keyed by state, for the summary strip. */ + counts: Record; + generatedFrom: string; +}; diff --git a/src/lib/vault.ts b/src/lib/vault.ts new file mode 100644 index 00000000..38afb37a --- /dev/null +++ b/src/lib/vault.ts @@ -0,0 +1,282 @@ +/** + * Reads the feature vault at the repository root and turns it into a graph. + * + * The vault (`/vault`) is an Obsidian-compatible folder of markdown notes: one per + * feature, area and state. This module is the only thing that knows the file + * layout — everything downstream consumes {@link VaultGraph}. + * + * **Server-only.** It touches `fs`, so it must be called from `getStaticProps` (or + * another server context), never from a component. Parsing at build time means the + * deployed app never reads the vault at runtime, and a malformed note fails the + * build rather than a request. + * + * The frontmatter parser deliberately supports a small YAML subset rather than + * pulling in a dependency: scalars, inline arrays (`[1, 2]`) and block lists. That + * is all the vault uses, and it is documented in `vault/README.md`. Anything richer + * should add a real YAML parser rather than growing this one. + */ +import fs from "fs"; +import path from "path"; + +import { + FEATURE_STATES, + type FeatureState, + type NodeKind, + type VaultEdge, + type VaultGraph, + type VaultNode, +} from "@/lib/vault-types"; + +// Re-exported so server-side callers need only one import. The browser must import +// from `@/lib/vault-types` directly — anything importing this module gets `fs`. +export { + FEATURE_STATES, + type FeatureState, + type NodeKind, + type EdgeKind, + type VaultEdge, + type VaultGraph, + type VaultNode, +} from "@/lib/vault-types"; + +type Frontmatter = Record; + +const VAULT_DIR = path.join(process.cwd(), "vault"); + +/** Strip one layer of matching quotes. */ +function unquote(value: string): string { + const trimmed = value.trim(); + if (trimmed.length >= 2) { + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === '"' || first === "'") && first === last) { + return trimmed.slice(1, -1); + } + } + return trimmed; +} + +/** + * Split the `---` fenced frontmatter from the body. Returns empty frontmatter when + * a note has none, so a stray file never throws. + */ +export function splitFrontmatter(raw: string): { + frontmatter: Frontmatter; + body: string; +} { + const normalized = raw.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) { + return { frontmatter: {}, body: normalized }; + } + const end = normalized.indexOf("\n---", 3); + if (end === -1) return { frontmatter: {}, body: normalized }; + + const block = normalized.slice(4, end); + const body = normalized.slice(end + 4).replace(/^\n+/, ""); + + const frontmatter: Frontmatter = {}; + let currentKey: string | null = null; + + for (const line of block.split("\n")) { + if (!line.trim() || line.trim().startsWith("#")) continue; + + // Block-list item belonging to the previous key: " - value" + const listItem = line.match(/^\s+-\s+(.*)$/); + if (listItem && currentKey) { + const existing = frontmatter[currentKey]; + const next = Array.isArray(existing) ? existing : []; + next.push(unquote(listItem[1] ?? "")); + frontmatter[currentKey] = next; + continue; + } + + const pair = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!pair) continue; + + const key = pair[1] ?? ""; + const rawValue = (pair[2] ?? "").trim(); + currentKey = key; + + if (rawValue === "") { + // Either an empty scalar or the head of a block list; the next line decides. + frontmatter[key] = []; + continue; + } + + if (rawValue.startsWith("[") && rawValue.endsWith("]")) { + const inner = rawValue.slice(1, -1).trim(); + frontmatter[key] = inner + ? inner.split(",").map((part) => unquote(part)) + : []; + continue; + } + + frontmatter[key] = unquote(rawValue); + } + + return { frontmatter, body }; +} + +function readScalar(fm: Frontmatter, key: string): string | undefined { + const value = fm[key]; + if (typeof value === "string" && value !== "") return value; + return undefined; +} + +function readNumbers(fm: Frontmatter, key: string): number[] { + const value = fm[key]; + const items = Array.isArray(value) ? value : value ? [value] : []; + return items + .map((item) => Number.parseInt(String(item).replace(/^#/, ""), 10)) + .filter((n) => Number.isFinite(n)); +} + +function isFeatureState(value: string | undefined): value is FeatureState { + return ( + value !== undefined && (FEATURE_STATES as readonly string[]).includes(value) + ); +} + +/** Every `[[wikilink]]` in the body, de-duplicated, ignoring `|` display aliases. */ +export function extractWikilinks(body: string): string[] { + const found = new Set(); + for (const match of body.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)) { + const title = (match[1] ?? "").trim(); + if (title) found.add(title); + } + return [...found]; +} + +/** First prose paragraph of a note, with the `# Title` heading removed. */ +export function summarize(body: string): string { + const withoutHeading = body.replace(/^#\s+.*$/m, "").trim(); + const paragraph = withoutHeading.split(/\n\s*\n/)[0] ?? ""; + return paragraph + .replace(/^##\s+.*$/gm, "") + .replace(/`/g, "") + .replace(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +function readNotes(dir: string): { title: string; raw: string }[] { + const full = path.join(VAULT_DIR, dir); + if (!fs.existsSync(full)) return []; + return fs + .readdirSync(full) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => ({ + title: name.replace(/\.md$/, ""), + raw: fs.readFileSync(path.join(full, name), "utf8"), + })); +} + +/** + * Build the graph. Throws when a feature names an area or state that has no note, + * so a typo surfaces at build time instead of silently dropping an edge. + */ +export function loadVaultGraph(): VaultGraph { + const nodes: VaultNode[] = []; + const edges: VaultEdge[] = []; + + const base = (title: string, kind: NodeKind, body: string): VaultNode => ({ + id: title, + kind, + summary: summarize(body), + issues: [], + prs: [], + degree: 0, + }); + + for (const { title, raw } of readNotes("states")) { + nodes.push(base(title, "state", splitFrontmatter(raw).body)); + } + for (const { title, raw } of readNotes("areas")) { + nodes.push(base(title, "area", splitFrontmatter(raw).body)); + } + + const areaTitles = new Set( + nodes.filter((n) => n.kind === "area").map((n) => n.id), + ); + // States are titled for display ("In Progress") but referenced by slug + // ("in-progress"), so map one to the other rather than duplicating the note. + const stateBySlug = new Map( + nodes + .filter((n) => n.kind === "state") + .map((n) => [n.id.toLowerCase().replace(/\s+/g, "-"), n.id]), + ); + + const featureLinks: { from: string; to: string[] }[] = []; + + for (const { title, raw } of readNotes("features")) { + const { frontmatter, body } = splitFrontmatter(raw); + const state = readScalar(frontmatter, "state"); + const area = readScalar(frontmatter, "area"); + + if (!isFeatureState(state)) { + throw new Error( + `vault: feature "${title}" has state "${state ?? ""}", expected one of ${FEATURE_STATES.join(", ")}`, + ); + } + if (!area || !areaTitles.has(area)) { + throw new Error( + `vault: feature "${title}" names area "${area ?? ""}", which has no note in vault/areas`, + ); + } + const stateTitle = stateBySlug.get(state); + if (!stateTitle) { + throw new Error(`vault: no note in vault/states for state "${state}"`); + } + + nodes.push({ + ...base(title, "feature", body), + state, + area, + owner: readScalar(frontmatter, "owner"), + milestone: readScalar(frontmatter, "milestone"), + issues: readNumbers(frontmatter, "issues"), + prs: readNumbers(frontmatter, "prs"), + }); + + edges.push({ source: title, target: area, kind: "in-area" }); + edges.push({ source: title, target: stateTitle, kind: "has-state" }); + featureLinks.push({ from: title, to: extractWikilinks(body) }); + } + + const featureTitles = new Set( + nodes.filter((n) => n.kind === "feature").map((n) => n.id), + ); + + // Feature-to-feature links, de-duplicated so a mutual reference is one edge. + const seen = new Set(); + for (const { from, to } of featureLinks) { + for (const target of to) { + if (!featureTitles.has(target) || target === from) continue; + const key = [from, target].sort().join(" "); + if (seen.has(key)) continue; + seen.add(key); + edges.push({ source: from, target, kind: "relates-to" }); + } + } + + const byId = new Map(nodes.map((n) => [n.id, n])); + for (const edge of edges) { + const a = byId.get(edge.source); + const b = byId.get(edge.target); + if (a) a.degree += 1; + if (b) b.degree += 1; + } + + const counts = FEATURE_STATES.reduce( + (acc, state) => { + acc[state] = nodes.filter( + (n) => n.kind === "feature" && n.state === state, + ).length; + return acc; + }, + {} as Record, + ); + + return { nodes, edges, counts, generatedFrom: "vault/" }; +} diff --git a/src/pages/roadmap/graph.tsx b/src/pages/roadmap/graph.tsx new file mode 100644 index 00000000..59a68a10 --- /dev/null +++ b/src/pages/roadmap/graph.tsx @@ -0,0 +1,35 @@ +import type { InferGetStaticPropsType } from "next"; + +import { VaultGraphView } from "@/components/pages/homepage/roadmap/graph"; +import { loadVaultGraph } from "@/lib/vault"; + +/** + * The vault is a folder of files that only changes when the repo does, so it is + * read once at build time rather than on every request. This is the one page in + * the app using `getStaticProps` instead of a no-op `getServerSideProps`, and the + * reason is that: no runtime filesystem access, and a malformed note fails the + * build rather than a visitor's request. + */ +export const getStaticProps = () => ({ props: { graph: loadVaultGraph() } }); + +export default function Page({ + graph, +}: InferGetStaticPropsType) { + return ( +
+
+

+ Feature graph +

+

+ Every feature of Mesh Multisig and the state it is in, linked to the + area it belongs to and the features it touches. +

+
+ +
+ +
+
+ ); +} diff --git a/src/pages/roadmap/index.tsx b/src/pages/roadmap/index.tsx new file mode 100644 index 00000000..a37d1e58 --- /dev/null +++ b/src/pages/roadmap/index.tsx @@ -0,0 +1,7 @@ +import { PageRoadmap } from "@/components/pages/homepage/roadmap"; + +export const getServerSideProps = () => ({ props: {} }); + +export default function Page() { + return ; +} diff --git a/vault/README.md b/vault/README.md new file mode 100644 index 00000000..de46f132 --- /dev/null +++ b/vault/README.md @@ -0,0 +1,47 @@ +# Feature Vault + +An Obsidian-compatible vault describing **every feature of Mesh Multisig and the +state it is in**. It is the source of data for the interactive knowledge graph at +[`/roadmap/graph`](../src/pages/roadmap/graph.tsx), parsed at build time by +[`src/lib/vault.ts`](../src/lib/vault.ts). + +Open this folder directly in Obsidian — it uses plain markdown, YAML frontmatter +and `[[wikilinks]]`, with no plugins required. + +## Layout + +| Folder | Note type | What it is | +|--------|-----------|------------| +| `features/` | `feature` | One note per feature, carrying the state it is in | +| `areas/` | `area` | The workstreams features belong to | +| `states/` | `state` | The four states a feature can be in | + +## Frontmatter + +Every feature note carries: + +```yaml +--- +type: feature +area: Governance # must match an areas/ note title +state: delivered # delivered | in-progress | planned | blocked +owner: Quirin # Quirin | Andre | Quirin & Andre | (omitted) +milestone: 2026-06 # YYYY-MM the work landed or is scheduled for +issues: [122] # GitHub issue numbers +prs: [272, 296] # GitHub PR numbers +updated: 2026-07-27 +--- +``` + +`area` and `state` become edges to the corresponding note, and every `[[wikilink]]` +in the body becomes an edge between features. That is the whole graph model — there +is no separate index to keep in sync. + +## Editing + +Add a feature by copying any note in `features/`, or change a state by editing one +`state:` line. The graph picks it up on the next build. Keep titles stable: a note's +filename is its node id, and wikilinks resolve by title. + +The narrative roadmap lives in [`ROADMAP.md`](../ROADMAP.md); this vault is the +structured view of the same work. When a feature ships, update both. diff --git a/vault/areas/Bot & Agent Platform.md b/vault/areas/Bot & Agent Platform.md new file mode 100644 index 00000000..aacf74d7 --- /dev/null +++ b/vault/areas/Bot & Agent Platform.md @@ -0,0 +1,15 @@ +--- +type: area +order: 4 +updated: 2026-07-27 +--- + +# Bot & Agent Platform + +Programmatic access for bots and AI agents, under an authorization model where a +bot can act but can never move funds alone. Arrived roughly four months ahead of +its planned month. + +## Related + +[[Bot Registration & Claim Flow]] · [[Bot Scoped Authorization]] · [[Bot Ballot Drafting]] · [[API Documentation Portal]] · [[Multisig MCP Server]] diff --git a/vault/areas/Document Sign-Off.md b/vault/areas/Document Sign-Off.md new file mode 100644 index 00000000..83ea5282 --- /dev/null +++ b/vault/areas/Document Sign-Off.md @@ -0,0 +1,15 @@ +--- +type: area +order: 2 +updated: 2026-07-27 +--- + +# Document Sign-Off + +The flagship feature line: a wallet-native, off-chain document approval layer that +binds approval to an exact version hash and inherits the wallet's signer set and +threshold. Specified as PRD-001 in the document-driven development vault. + +## Related + +[[Document Sign-Off MVP]] · [[Revision Provenance]] · [[On-Chain Checkpoints]] · [[Collaboration & Standards]] diff --git a/vault/areas/Governance.md b/vault/areas/Governance.md new file mode 100644 index 00000000..742f8e9d --- /dev/null +++ b/vault/areas/Governance.md @@ -0,0 +1,14 @@ +--- +type: area +order: 3 +updated: 2026-07-27 +--- + +# Governance + +Cardano on-chain governance for teams: voting as a multisig DRep, browsing +proposals, publishing rationales, and exploring how other DReps have voted. + +## Related + +[[In-App Governance Voting]] · [[DRep Vote History Explorer]] · [[Ballot Rationale & IPFS]] · [[Proxy Voting]] diff --git a/vault/areas/Growth & Accounts.md b/vault/areas/Growth & Accounts.md new file mode 100644 index 00000000..52b2d2a8 --- /dev/null +++ b/vault/areas/Growth & Accounts.md @@ -0,0 +1,13 @@ +--- +type: area +order: 10 +updated: 2026-07-27 +--- + +# Growth & Accounts + +Identity, onboarding and the social layer around a wallet. + +## Related + +[[Invite Flow]] · [[User Profiles & Contacts]] · [[Discover Page]] diff --git a/vault/areas/Notifications.md b/vault/areas/Notifications.md new file mode 100644 index 00000000..a2a0b729 --- /dev/null +++ b/vault/areas/Notifications.md @@ -0,0 +1,14 @@ +--- +type: area +order: 7 +updated: 2026-07-27 +--- + +# Notifications + +Telling signers that something needs them — reliably, once, and only if they asked +for it. + +## Related + +[[Email Notification Center]] · [[Notification Outbox & Worker]] · [[Scheduled Outbox Drain]] · [[Notification Digests & Reminders]] diff --git a/vault/areas/Platform & UX.md b/vault/areas/Platform & UX.md new file mode 100644 index 00000000..edfde94e --- /dev/null +++ b/vault/areas/Platform & UX.md @@ -0,0 +1,14 @@ +--- +type: area +order: 8 +updated: 2026-07-27 +--- + +# Platform & UX + +The foundations everything else sits on: the SDK runtime, signing correctness, the +interface, and how the product presents itself to people and to crawlers. + +## Related + +[[Signing & Auth Reliability]] · [[Mesh 2.0 Migration]] · [[Mobile & Responsive Foundations]] · [[dApp Connector]] diff --git a/vault/areas/Release & Production Health.md b/vault/areas/Release & Production Health.md new file mode 100644 index 00000000..f951d9b2 --- /dev/null +++ b/vault/areas/Release & Production Health.md @@ -0,0 +1,15 @@ +--- +type: area +order: 1 +updated: 2026-07-27 +--- + +# Release & Production Health + +Getting merged work actually into production, and keeping it healthy once there. +The gap between "merged on `preprod`" and "running in production" is the single +largest risk on the roadmap today. + +## Related + +[[Production Release Gap]] · [[Row-Level Security Hardening]] · [[Migration Deploy Pipeline]] diff --git a/vault/areas/Reliability & CI.md b/vault/areas/Reliability & CI.md new file mode 100644 index 00000000..ec10c7eb --- /dev/null +++ b/vault/areas/Reliability & CI.md @@ -0,0 +1,14 @@ +--- +type: area +order: 6 +updated: 2026-07-27 +--- + +# Reliability & CI + +The safety net: real-chain smoke tests, browser end-to-end coverage, unit and tRPC +suites, and the pipelines that run them. + +## Related + +[[Playwright E2E Suite]] · [[Real-Chain Smoke Tests]] · [[Dependabot CI Unblock]] · [[Transaction Builder & tRPC Tests]] diff --git a/vault/areas/Research.md b/vault/areas/Research.md new file mode 100644 index 00000000..15840a4b --- /dev/null +++ b/vault/areas/Research.md @@ -0,0 +1,13 @@ +--- +type: area +order: 9 +updated: 2026-07-27 +--- + +# Research + +Forward-looking work with a go/no-go at the end rather than a ship date. + +## Related + +[[FROST Threshold Signatures]] · [[Post-Quantum Multi-Sig]] diff --git a/vault/areas/Wallets & Discovery.md b/vault/areas/Wallets & Discovery.md new file mode 100644 index 00000000..3a6e7e84 --- /dev/null +++ b/vault/areas/Wallets & Discovery.md @@ -0,0 +1,14 @@ +--- +type: area +order: 5 +updated: 2026-07-27 +--- + +# Wallets & Discovery + +Creating, importing, finding and signing with multi-signature wallets, plus the +devices and contracts that hold them. + +## Related + +[[Multi-Signature Wallet Core]] · [[Wallet V2 Registration & Discovery]] · [[Hardware Wallet Support]] · [[Vesting Contracts]] diff --git a/vault/features/API Documentation Portal.md b/vault/features/API Documentation Portal.md new file mode 100644 index 00000000..f6af8573 --- /dev/null +++ b/vault/features/API Documentation Portal.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Bot & Agent Platform +state: delivered +owner: Quirin +milestone: 2026-07 +prs: [328, 346] +updated: 2026-07-27 +--- + +# API Documentation Portal + +Swagger UI at `/api-docs` with a wallet-signature bearer-token generator, an +OpenAPI 3.0 spec at `/api/swagger`, an authoritative endpoint reference in the repo, +and a reference bot client. Delivered five months ahead of its planned month, which +freed the December slot on the roadmap. + +## Related + +[[Agent & Crawler Legibility]] · [[Bot Scoped Authorization]] · [[Multisig MCP Server]] diff --git a/vault/features/Agent & Crawler Legibility.md b/vault/features/Agent & Crawler Legibility.md new file mode 100644 index 00000000..74a9d596 --- /dev/null +++ b/vault/features/Agent & Crawler Legibility.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Bot & Agent Platform +state: delivered +owner: Quirin +milestone: 2026-07 +prs: [346] +updated: 2026-07-27 +--- + +# Agent & Crawler Legibility + +Making the site readable to AI agents and crawlers that do not run JavaScript: +`/llms.txt` with a self-contained bot quickstart, a downloadable agent skill at +`/api/skill`, a no-JS crawler fallback listing every public surface, and the +sitemap entry that ties them together. + +## Related + +[[API Documentation Portal]] · [[Landing, SEO & Theme]] · [[Multisig MCP Server]] diff --git a/vault/features/Ballot Rationale & IPFS.md b/vault/features/Ballot Rationale & IPFS.md new file mode 100644 index 00000000..7233c985 --- /dev/null +++ b/vault/features/Ballot Rationale & IPFS.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Governance +state: delivered +owner: Quirin +milestone: 2026-06 +prs: [300, 315] +updated: 2026-07-27 +--- + +# Ballot Rationale & IPFS + +Rationale drafting and caching, a guarded multi-gateway IPFS resolve proxy, and +ballot CSV import and export. Includes ReDoS hardening of the CID path extractor +after a CodeQL finding. + +## Related + +[[In-App Governance Voting]] · [[DRep Vote History Explorer]] diff --git a/vault/features/Better 404 Page.md b/vault/features/Better 404 Page.md new file mode 100644 index 00000000..5e92e269 --- /dev/null +++ b/vault/features/Better 404 Page.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Platform & UX +state: planned +owner: Andre +milestone: 2026-10 +issues: [22] +updated: 2026-07-27 +--- + +# Better 404 Page + +A useful not-found page rather than the default. Batched with the other papercuts. + +## Related + +[[Transaction Pagination]] · [[Full Address Verification]] · [[Landing, SEO & Theme]] diff --git a/vault/features/Bot Ballot Drafting.md b/vault/features/Bot Ballot Drafting.md new file mode 100644 index 00000000..6aaa0e1e --- /dev/null +++ b/vault/features/Bot Ballot Drafting.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Bot & Agent Platform +state: delivered +owner: Quirin +milestone: 2026-07 +prs: [342, 343] +updated: 2026-07-27 +--- + +# Bot Ballot Drafting + +Bots can write vote decisions and draft rationales through the ballot API, with +observer access being sufficient to draft. Includes ballot lifecycle handling and +proposal-id validation. + +## Related + +[[Bot Scoped Authorization]] · [[In-App Governance Voting]] · [[Ballot Rationale & IPFS]] diff --git a/vault/features/Bot Management UI.md b/vault/features/Bot Management UI.md new file mode 100644 index 00000000..4f01a4ae --- /dev/null +++ b/vault/features/Bot Management UI.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Bot & Agent Platform +state: delivered +owner: Quirin +milestone: 2026-07 +prs: [344, 345] +updated: 2026-07-27 +--- + +# Bot Management UI + +A Bot accounts card on the profile page and the wallets dashboard: claim a bot, +inspect it, edit its scopes, see its per-wallet grants at a glance, and revoke it. +Grants are recorded in an append-only audit log. + +## Related + +[[Bot Registration & Claim Flow]] · [[Bot Scoped Authorization]] diff --git a/vault/features/Bot Rate Limiting.md b/vault/features/Bot Rate Limiting.md new file mode 100644 index 00000000..fa924919 --- /dev/null +++ b/vault/features/Bot Rate Limiting.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Bot & Agent Platform +state: delivered +owner: Quirin +milestone: 2026-07 +updated: 2026-07-27 +--- + +# Bot Rate Limiting + +Three tiers of request guard plus body-size caps: 60/min by default, 15/min on +register, pickup and auth, 5/min on secret rotation, and 40/min per bot id. + +## Related + +[[Bot Registration & Claim Flow]] · [[Bot Scoped Authorization]] diff --git a/vault/features/Bot Registration & Claim Flow.md b/vault/features/Bot Registration & Claim Flow.md new file mode 100644 index 00000000..5265f675 --- /dev/null +++ b/vault/features/Bot Registration & Claim Flow.md @@ -0,0 +1,21 @@ +--- +type: feature +area: Bot & Agent Platform +state: delivered +owner: Quirin +milestone: 2026-07 +prs: [341] +updated: 2026-07-27 +--- + +# Bot Registration & Claim Flow + +Human-in-the-loop onboarding: a bot self-registers with the scopes it wants and +receives a claim code valid for 30 minutes; its human owner approves that code with +their own session; the secret is then retrievable exactly once. Secrets are stored +as HMAC-SHA256 peppered with the app's JWT secret, and a bot is bound to one payment +address at first authentication. Bots can also register without an address. + +## Related + +[[Bot Scoped Authorization]] · [[Bot Management UI]] · [[Bot Rate Limiting]] diff --git a/vault/features/Bot Scoped Authorization.md b/vault/features/Bot Scoped Authorization.md new file mode 100644 index 00000000..16fefb94 --- /dev/null +++ b/vault/features/Bot Scoped Authorization.md @@ -0,0 +1,21 @@ +--- +type: feature +area: Bot & Agent Platform +state: delivered +owner: Quirin +milestone: 2026-07 +updated: 2026-07-27 +--- + +# Bot Scoped Authorization + +Double opt-in: a scope on the key **and** a per-wallet grant. Five scopes cover +wallet creation, reads, signing, governance reads and ballot writes; each wallet +separately grants a bot either cosigner or observer access. 27 endpoints accept bot +tokens, including signing with auto-submit once the threshold is met — but the +wallet's M-of-N threshold still gates submission, so a bot can never move funds +alone. + +## Related + +[[Bot Registration & Claim Flow]] · [[Multi-Signature Wallet Core]] · [[Improved Authentication]] diff --git a/vault/features/Bot Webhooks.md b/vault/features/Bot Webhooks.md new file mode 100644 index 00000000..c9f8f3a1 --- /dev/null +++ b/vault/features/Bot Webhooks.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Bot & Agent Platform +state: planned +owner: Andre +milestone: 2026-11 +updated: 2026-07-27 +--- + +# Bot Webhooks + +Outbound events so a bot learns that a transaction needs its signature without +polling. The only unbuilt piece of what the roadmap called Bot platform v2 — the +rest arrived in July. + +## Related + +[[Bot Scoped Authorization]] · [[Multisig MCP Server]] · [[Notification Outbox & Worker]] diff --git a/vault/features/Collaboration & Standards.md b/vault/features/Collaboration & Standards.md new file mode 100644 index 00000000..a35e47be --- /dev/null +++ b/vault/features/Collaboration & Standards.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Document Sign-Off +state: planned +owner: Andre +milestone: 2027-03 +updated: 2026-07-27 +--- + +# Collaboration & Standards + +Version 3, scoped as research: real-time co-authoring via CRDTs, a metadata standard +put forward as a CIP candidate, and an eIDAS/EUDI qualified-signature bridge. + +## Related + +[[On-Chain Checkpoints]] · [[Document Sign-Off MVP]] diff --git a/vault/features/Collateral Service for Proxy.md b/vault/features/Collateral Service for Proxy.md new file mode 100644 index 00000000..0ed4012b --- /dev/null +++ b/vault/features/Collateral Service for Proxy.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Governance +state: planned +owner: Quirin +milestone: 2026-12 +issues: [221] +updated: 2026-07-27 +--- + +# Collateral Service for Proxy + +A collateral service for proxy usage — the last backlog item that had no roadmap +slot until the December replan. + +## Related + +[[Proxy Voting]] diff --git a/vault/features/DRep Vote History Explorer.md b/vault/features/DRep Vote History Explorer.md new file mode 100644 index 00000000..13b28ce4 --- /dev/null +++ b/vault/features/DRep Vote History Explorer.md @@ -0,0 +1,21 @@ +--- +type: feature +area: Governance +state: delivered +owner: Quirin +milestone: 2026-07 +prs: [337, 338, 339] +updated: 2026-07-27 +--- + +# DRep Vote History Explorer + +A public explorer at `/governance/drep` — no connected wallet required — showing +every governance action a DRep voted on, with search, a vote filter, CIP-100 and +CIP-136 rationales resolved lazily from IPFS, and a nine-column CSV export that +resolves all rationales before writing. Served through a Koios proxy because +Blockfrost omits the proposal and rationale anchor. + +## Related + +[[In-App Governance Voting]] · [[Ballot Rationale & IPFS]] · [[Landing, SEO & Theme]] diff --git a/vault/features/Dependabot CI Unblock.md b/vault/features/Dependabot CI Unblock.md new file mode 100644 index 00000000..6e6d3393 --- /dev/null +++ b/vault/features/Dependabot CI Unblock.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Reliability & CI +state: blocked +owner: Andre +milestone: 2026-08 +updated: 2026-07-27 +--- + +# Dependabot CI Unblock + +The v1 smoke workflow hard-fails when its secrets are absent, and dependabot-triggered +runs never receive repository secrets — so every open dependency PR is red for +systemic reasons rather than because of the bump. Seven are open, the oldest since +2026-06-15. The sibling preprod smoke workflow already has the skip-when-unconfigured +guard to copy. + +## Related + +[[Real-Chain Smoke Tests]] · [[Playwright E2E Suite]] diff --git a/vault/features/Discover Page.md b/vault/features/Discover Page.md new file mode 100644 index 00000000..0ddb358c --- /dev/null +++ b/vault/features/Discover Page.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Growth & Accounts +state: planned +owner: Andre +milestone: 2026-09 +issues: [52] +updated: 2026-07-27 +--- + +# Discover Page + +Browse wallets, DAOs and governance activity, plus lookup by signer and policy. +Moved up from February to ride the delivered Wallet V2 discovery work rather than +being built standalone. + +## Related + +[[Wallet V2 Registration & Discovery]] · [[User Profiles & Contacts]] diff --git a/vault/features/Document Sign-Off MVP.md b/vault/features/Document Sign-Off MVP.md new file mode 100644 index 00000000..9dc14f8e --- /dev/null +++ b/vault/features/Document Sign-Off MVP.md @@ -0,0 +1,22 @@ +--- +type: feature +area: Document Sign-Off +state: planned +owner: Quirin & Andre +milestone: 2026-08 +updated: 2026-07-27 +--- + +# Document Sign-Off MVP + +The four primitives: document creation, hash-bound versioning, signer review against +the wallet's inherited threshold, and an exportable audit proof as JSON and PDF. +Approval belongs to a version, never a mutable container — a new version starts a +fresh round at zero approvals. Specified as PRD-001, which is still in Draft, so +finalizing it is the first sub-task. + +Ready means a pilot team runs all six user stories end to end without developer help. + +## Related + +[[Revision Provenance]] · [[Multi-Signature Wallet Core]] · [[Hardware Wallet Support]] · [[Playwright E2E Suite]] diff --git a/vault/features/Email Notification Center.md b/vault/features/Email Notification Center.md new file mode 100644 index 00000000..6d6c9bdf --- /dev/null +++ b/vault/features/Email Notification Center.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Notifications +state: delivered +owner: Andre +milestone: 2026-06 +prs: [322, 326] +updated: 2026-07-27 +--- + +# Email Notification Center + +Signature-required emails through Resend, with per-wallet and per-signer settings on +the wallet Info page: a master opt-in plus separate switches for transaction +signatures and signable payloads. + +## Related + +[[Notification Outbox & Worker]] · [[Email Verification]] · [[Scheduled Outbox Drain]] diff --git a/vault/features/Email Verification.md b/vault/features/Email Verification.md new file mode 100644 index 00000000..2e0b0de0 --- /dev/null +++ b/vault/features/Email Verification.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Notifications +state: delivered +owner: Andre +milestone: 2026-06 +updated: 2026-07-27 +--- + +# Email Verification + +Hashed-token email verification with expiry and single use, so notifications only +ever reach an address its owner confirmed. + +## Related + +[[Email Notification Center]] · [[Notification Outbox & Worker]] diff --git a/vault/features/FROST Threshold Signatures.md b/vault/features/FROST Threshold Signatures.md new file mode 100644 index 00000000..d7260b4c --- /dev/null +++ b/vault/features/FROST Threshold Signatures.md @@ -0,0 +1,24 @@ +--- +type: feature +area: Research +state: planned +owner: Quirin +milestone: 2026-08 +issues: [220] +updated: 2026-07-27 +--- + +# FROST Threshold Signatures + +Evaluate FROST — flexible round-optimized Schnorr threshold signatures — as a +replacement for or complement to native-script multisig: a smaller on-chain +footprint, better privacy from a single on-chain signature, and flexible thresholds. +Deliverables are a trade-off note against native scripts, a proof of concept if the +libraries allow, and a go/no-go. + +Kickoff slipped from July; starting in August is what keeps runway before the +October decision. + +## Related + +[[Post-Quantum Multi-Sig]] · [[Multi-Signature Wallet Core]] diff --git a/vault/features/Feature Knowledge Graph.md b/vault/features/Feature Knowledge Graph.md new file mode 100644 index 00000000..164d3d5d --- /dev/null +++ b/vault/features/Feature Knowledge Graph.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Platform & UX +state: delivered +owner: Quirin +milestone: 2026-07 +updated: 2026-07-27 +--- + +# Feature Knowledge Graph + +An interactive force-directed graph at `/roadmap/graph`, driven by this vault. Every +feature note becomes a node linked to its area, its state and the other features it +references, so the shape of the product — and where the blocked work clusters — is +visible at a glance. The vault is parsed at build time, so there is no index to keep +in sync: editing a `state:` line is what moves a node. + +## Related + +[[Roadmap Page]] · [[Agent & Crawler Legibility]] diff --git a/vault/features/Full Address Verification.md b/vault/features/Full Address Verification.md new file mode 100644 index 00000000..95bb043c --- /dev/null +++ b/vault/features/Full Address Verification.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Platform & UX +state: planned +owner: Andre +milestone: 2026-10 +issues: [196] +updated: 2026-07-27 +--- + +# Full Address Verification + +Let a signer check a full address rather than a truncated one before approving — +a small change that removes a real class of mistake. + +## Related + +[[Transaction Pagination]] · [[Better 404 Page]] · [[Multi-Signature Wallet Core]] diff --git a/vault/features/Hardware Wallet Support.md b/vault/features/Hardware Wallet Support.md new file mode 100644 index 00000000..d6ef8716 --- /dev/null +++ b/vault/features/Hardware Wallet Support.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Wallets & Discovery +state: planned +owner: Andre +milestone: 2026-10 +issues: [44] +updated: 2026-07-27 +--- + +# Hardware Wallet Support + +Ledger and Trezor. Their CIP-8 `signData` support is limited, and Document Sign-Off +approvals depend on exactly that call — so the constraint needs scoping during the +Sign-Off build rather than discovered afterwards. + +## Related + +[[Document Sign-Off MVP]] · [[Multi-Signature Wallet Core]] · [[Signing & Auth Reliability]] diff --git a/vault/features/Improved Authentication.md b/vault/features/Improved Authentication.md new file mode 100644 index 00000000..72993a8f --- /dev/null +++ b/vault/features/Improved Authentication.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Platform & UX +state: planned +owner: Quirin +milestone: 2026-11 +issues: [135] +updated: 2026-07-27 +--- + +# Improved Authentication + +Paired with the connector, since external dApp access and authentication are the +same problem surface. + +## Related + +[[dApp Connector]] · [[Bot Scoped Authorization]] diff --git a/vault/features/In-App Governance Voting.md b/vault/features/In-App Governance Voting.md new file mode 100644 index 00000000..c9cca5d6 --- /dev/null +++ b/vault/features/In-App Governance Voting.md @@ -0,0 +1,21 @@ +--- +type: feature +area: Governance +state: delivered +owner: Quirin +milestone: 2026-06 +issues: [122] +prs: [272, 279, 296, 297, 302] +updated: 2026-07-27 +--- + +# In-App Governance Voting + +Ekklesia and Hydra budget voting for multisig DReps, with DRep-registration +detection, a segmented ballot UI with type chips, proposal cards and DB-cached +tallies. Closed the long-standing governance metadata hash mismatch five months +ahead of its planned month. + +## Related + +[[Ballot Rationale & IPFS]] · [[DRep Vote History Explorer]] · [[Proxy Voting]] · [[Bot Ballot Drafting]] diff --git a/vault/features/Invite Flow.md b/vault/features/Invite Flow.md new file mode 100644 index 00000000..215e358d --- /dev/null +++ b/vault/features/Invite Flow.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Growth & Accounts +state: planned +owner: Quirin +milestone: 2027-02 +prs: [67] +updated: 2026-07-27 +--- + +# Invite Flow + +A first-class flow for bringing signers into a wallet, replacing link-sharing. + +## Related + +[[User Profiles & Contacts]] · [[Multi-Signature Wallet Core]] · [[Discover Page]] diff --git a/vault/features/Landing, SEO & Theme.md b/vault/features/Landing, SEO & Theme.md new file mode 100644 index 00000000..bd257c4c --- /dev/null +++ b/vault/features/Landing, SEO & Theme.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Platform & UX +state: delivered +owner: Quirin +milestone: 2026-06 +prs: [298, 299, 308, 313, 314, 316, 317, 318] +updated: 2026-07-27 +--- + +# Landing, SEO & Theme + +A landing page driven by live mock-data previews rather than screenshots, a central +route-aware SEO surface with sitemap and robots, a static OG image, and a +glass-morphism theme extended across all cards — with the background animation cost +cut down so scrolling stays smooth. + +## Related + +[[Mobile & Responsive Foundations]] · [[Agent & Crawler Legibility]] · [[Roadmap Page]] diff --git a/vault/features/Legacy Wallet Compatibility.md b/vault/features/Legacy Wallet Compatibility.md new file mode 100644 index 00000000..49c7583e --- /dev/null +++ b/vault/features/Legacy Wallet Compatibility.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Wallets & Discovery +state: delivered +owner: Quirin +milestone: 2026-05 +issues: [223] +prs: [210, 225] +updated: 2026-07-27 +--- + +# Legacy Wallet Compatibility + +Keeping wallets created by earlier versions working, including DRep retirement and +deregistration. Legacy wallets carry raw imported bodies, so their bytes must be +preserved exactly or co-signers end up signing different transactions. + +## Related + +[[Multi-Signature Wallet Core]] · [[Signing & Auth Reliability]] · [[Wallet Import]] diff --git a/vault/features/Mesh 2.0 Migration.md b/vault/features/Mesh 2.0 Migration.md new file mode 100644 index 00000000..e604ebc4 --- /dev/null +++ b/vault/features/Mesh 2.0 Migration.md @@ -0,0 +1,21 @@ +--- +type: feature +area: Platform & UX +state: blocked +owner: Quirin +milestone: 2026-07 +prs: [268, 269, 278] +updated: 2026-07-27 +--- + +# Mesh 2.0 Migration + +Groundwork is done — Prisma 7.8 and Next 16, a hardfork-ready transaction builder, +and every wallet operation funnelled through a single bridge with an ESLint +guardrail. The cutover itself is blocked upstream: npm's latest `@meshsdk/core` is +still 1.9.1 and no 2.x has been published. Demoted from a monthly task to a standing +watch item, since it cannot be scheduled against an unpublished dependency. + +## Related + +[[Signing & Auth Reliability]] · [[Multi-Signature Wallet Core]] diff --git a/vault/features/Migration Deploy Pipeline.md b/vault/features/Migration Deploy Pipeline.md new file mode 100644 index 00000000..c9b67d8c --- /dev/null +++ b/vault/features/Migration Deploy Pipeline.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Release & Production Health +state: planned +owner: Quirin +milestone: 2026-09 +prs: [319] +updated: 2026-07-27 +--- + +# Migration Deploy Pipeline + +Make the release path self-verifying instead of dependent on a path filter: run +`prisma migrate status` as a post-deploy gate and alert on drift, so "merged" and +"applied" cannot silently diverge again. + +## Related + +[[Production Release Gap]] · [[Row-Level Security Hardening]] · [[Real-Chain Smoke Tests]] diff --git a/vault/features/Mobile & Responsive Foundations.md b/vault/features/Mobile & Responsive Foundations.md new file mode 100644 index 00000000..dd0d98b5 --- /dev/null +++ b/vault/features/Mobile & Responsive Foundations.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Platform & UX +state: delivered +owner: Quirin +milestone: 2026-06 +prs: [287, 288, 289, 290, 291, 292, 293, 294, 295] +updated: 2026-07-27 +--- + +# Mobile & Responsive Foundations + +Viewport, touch targets, dialogs and inputs on small screens, plus skeleton and +empty states, error toasts, pagination, labels and asset display. + +## Related + +[[Landing, SEO & Theme]] · [[Wallet Import]] · [[Transaction Pagination]] diff --git a/vault/features/Multi-Signature Wallet Core.md b/vault/features/Multi-Signature Wallet Core.md new file mode 100644 index 00000000..835c8464 --- /dev/null +++ b/vault/features/Multi-Signature Wallet Core.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Wallets & Discovery +state: delivered +owner: Quirin +milestone: 2026-05 +updated: 2026-07-27 +--- + +# Multi-Signature Wallet Core + +M-of-N native-script wallets: create a wallet, invite and cryptographically verify +signers, build transactions, collect witnesses and submit once the threshold is met. +Everything else in the product inherits this signer set and threshold. + +## Related + +[[Signing & Auth Reliability]] · [[Legacy Wallet Compatibility]] · [[Wallet V2 Registration & Discovery]] · [[Document Sign-Off MVP]] diff --git a/vault/features/Multisig MCP Server.md b/vault/features/Multisig MCP Server.md new file mode 100644 index 00000000..20e8c4c2 --- /dev/null +++ b/vault/features/Multisig MCP Server.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Bot & Agent Platform +state: planned +owner: Andre +milestone: 2026-11 +updated: 2026-07-27 +--- + +# Multisig MCP Server + +Expose the existing bot API as an MCP server, so an AI agent can act as a wallet +observer or ballot drafter directly. A short step from the delivered agent surface, +and a genuine differentiator. + +## Related + +[[Agent & Crawler Legibility]] · [[API Documentation Portal]] · [[Bot Scoped Authorization]] diff --git a/vault/features/Notification Digests & Reminders.md b/vault/features/Notification Digests & Reminders.md new file mode 100644 index 00000000..b01347ee --- /dev/null +++ b/vault/features/Notification Digests & Reminders.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Notifications +state: planned +owner: Andre +milestone: 2026-09 +updated: 2026-07-27 +--- + +# Notification Digests & Reminders + +Ballot-deadline and threshold-reached reminders riding on the existing outbox. This +is product work rather than infrastructure — the delivery machinery is already built. + +## Related + +[[Notification Outbox & Worker]] · [[Scheduled Outbox Drain]] · [[In-App Governance Voting]] diff --git a/vault/features/Notification Outbox & Worker.md b/vault/features/Notification Outbox & Worker.md new file mode 100644 index 00000000..3f8d09dd --- /dev/null +++ b/vault/features/Notification Outbox & Worker.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Notifications +state: delivered +owner: Andre +milestone: 2026-06 +updated: 2026-07-27 +--- + +# Notification Outbox & Worker + +A real outbox rather than fire-and-forget sending: every delivery carries an +idempotency key, an attempt counter and a retry backoff, across nine statuses that +include four distinct skip reasons — no email, not verified, opted out, disabled. +Drained by a token-authenticated endpoint. + +## Related + +[[Email Notification Center]] · [[Scheduled Outbox Drain]] · [[Notification Digests & Reminders]] diff --git a/vault/features/On-Chain Checkpoints.md b/vault/features/On-Chain Checkpoints.md new file mode 100644 index 00000000..d204a9c6 --- /dev/null +++ b/vault/features/On-Chain Checkpoints.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Document Sign-Off +state: planned +owner: Quirin +milestone: 2027-01 +updated: 2026-07-27 +--- + +# On-Chain Checkpoints + +Version 2: opt-in anchoring of a version's hash and its parent in Cardano +transaction metadata, giving a chain of custody without putting documents on chain. + +## Related + +[[Revision Provenance]] · [[Document Sign-Off MVP]] diff --git a/vault/features/Playwright E2E Suite.md b/vault/features/Playwright E2E Suite.md new file mode 100644 index 00000000..20afa4b9 --- /dev/null +++ b/vault/features/Playwright E2E Suite.md @@ -0,0 +1,22 @@ +--- +type: feature +area: Reliability & CI +state: delivered +owner: Andre +milestone: 2026-07 +prs: [323, 335, 336] +updated: 2026-07-27 +--- + +# Playwright E2E Suite + +Eleven spec files and roughly 54 browser tests covering wallet creation for legacy +and SDK wallets, real preprod ring transfers, staking, proxy, DRep and ballot UI, +bot management, notification settings, wallet access control, signing rejection and +responsive overflow. Runs in Docker, serialized against the smoke job through a +shared concurrency group. Unscheduled work that became the safety net Document +Sign-Off will ship against. + +## Related + +[[Real-Chain Smoke Tests]] · [[Transaction Builder & tRPC Tests]] · [[Document Sign-Off MVP]] diff --git a/vault/features/Post-Quantum Multi-Sig.md b/vault/features/Post-Quantum Multi-Sig.md new file mode 100644 index 00000000..ef09430e --- /dev/null +++ b/vault/features/Post-Quantum Multi-Sig.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Research +state: planned +owner: Quirin +milestone: 2026-10 +issues: [220] +updated: 2026-07-27 +--- + +# Post-Quantum Multi-Sig + +Assess Lemour lattice-based threshold signatures for long-term quantum resistance, +as a forward-looking alternative or complement to FROST. Maturity and available +libraries are the open questions. + +## Related + +[[FROST Threshold Signatures]] diff --git a/vault/features/Production Release Gap.md b/vault/features/Production Release Gap.md new file mode 100644 index 00000000..75657efe --- /dev/null +++ b/vault/features/Production Release Gap.md @@ -0,0 +1,24 @@ +--- +type: feature +area: Release & Production Health +state: blocked +owner: Quirin +milestone: 2026-08 +prs: [319, 321] +updated: 2026-07-27 +--- + +# Production Release Gap + +Production has applied no migration since 2026-05-10, and `preprod` sits 75 commits +ahead of `main` — so June and July are built but unreleased. The migration workflow +only fires on pushes to `main` touching `prisma/migrations/**`, so fixing it never +re-triggered the run that failed on 17 June. Governance tallies error, the +notification center has no tables, and address-less bot registration cannot work. + +Unblocking is one release plus one manual workflow dispatch, and it is the first +task of August. + +## Related + +[[Migration Deploy Pipeline]] · [[Row-Level Security Hardening]] · [[Email Notification Center]] · [[Bot Registration & Claim Flow]] diff --git a/vault/features/Proxy Voting.md b/vault/features/Proxy Voting.md new file mode 100644 index 00000000..639e5b94 --- /dev/null +++ b/vault/features/Proxy Voting.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Governance +state: planned +owner: Quirin +milestone: 2026-12 +updated: 2026-07-27 +--- + +# Proxy Voting + +Polish and documentation for voting through a proxy, so a wallet can delegate the +mechanics of casting a vote without delegating the decision. + +## Related + +[[Collateral Service for Proxy]] · [[In-App Governance Voting]] diff --git a/vault/features/Real-Chain Smoke Tests.md b/vault/features/Real-Chain Smoke Tests.md new file mode 100644 index 00000000..f0b52ba5 --- /dev/null +++ b/vault/features/Real-Chain Smoke Tests.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Reliability & CI +state: delivered +owner: Andre +milestone: 2026-06 +issues: [213] +prs: [217, 218] +updated: 2026-07-27 +--- + +# Real-Chain Smoke Tests + +A preprod environment plus a smoke system that exercises real route chains against +the actual chain, rather than mocks. + +## Related + +[[Playwright E2E Suite]] · [[Dependabot CI Unblock]] · [[Migration Deploy Pipeline]] diff --git a/vault/features/Revision Provenance.md b/vault/features/Revision Provenance.md new file mode 100644 index 00000000..92f9668f --- /dev/null +++ b/vault/features/Revision Provenance.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Document Sign-Off +state: planned +owner: Quirin +milestone: 2026-10 +updated: 2026-07-27 +--- + +# Revision Provenance + +Version 1 of Sign-Off: revision history as a first-class object, diff and rollback, +and a richer audit export. Still entirely off-chain. + +## Related + +[[Document Sign-Off MVP]] · [[On-Chain Checkpoints]] diff --git a/vault/features/Roadmap Page.md b/vault/features/Roadmap Page.md new file mode 100644 index 00000000..593a929e --- /dev/null +++ b/vault/features/Roadmap Page.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Platform & UX +state: delivered +owner: Quirin +milestone: 2026-07 +prs: [350] +updated: 2026-07-27 +--- + +# Roadmap Page + +A public `/roadmap` page rendering the twelve-month plan as a workstream timeline, +grouped by workstream rather than owner so the continuity from delivered to planned +stays visible. Status is encoded by fill, icon and label together, never colour +alone. + +## Related + +[[Feature Knowledge Graph]] · [[Landing, SEO & Theme]] · [[Production Release Gap]] diff --git a/vault/features/Row-Level Security Hardening.md b/vault/features/Row-Level Security Hardening.md new file mode 100644 index 00000000..acfd34fb --- /dev/null +++ b/vault/features/Row-Level Security Hardening.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Release & Production Health +state: blocked +owner: Quirin +milestone: 2026-08 +prs: [332] +updated: 2026-07-27 +--- + +# Row-Level Security Hardening + +Seven production tables — including the bot key and claim-token tables and the audit +log — still have row-level security disabled and are reachable by the anon PostgREST +role. The migration that enables RLS with deny-all policies is written and merged; +it is purely undeployed, so this unblocks the moment the release lands. + +## Related + +[[Production Release Gap]] · [[Migration Deploy Pipeline]] · [[Bot Registration & Claim Flow]] diff --git a/vault/features/Scheduled Outbox Drain.md b/vault/features/Scheduled Outbox Drain.md new file mode 100644 index 00000000..20e81d23 --- /dev/null +++ b/vault/features/Scheduled Outbox Drain.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Notifications +state: planned +owner: Andre +milestone: 2026-08 +issues: [327] +updated: 2026-07-27 +--- + +# Scheduled Outbox Drain + +The outbox exists and the drain endpoint exists, but nothing calls it on a schedule — +the daily balance snapshot is the only cron in the repository. Until this lands, +queued notifications sit undelivered. + +## Related + +[[Notification Outbox & Worker]] · [[Email Notification Center]] · [[Notification Digests & Reminders]] diff --git a/vault/features/Signing & Auth Reliability.md b/vault/features/Signing & Auth Reliability.md new file mode 100644 index 00000000..15fee328 --- /dev/null +++ b/vault/features/Signing & Auth Reliability.md @@ -0,0 +1,21 @@ +--- +type: feature +area: Platform & UX +state: delivered +owner: Quirin +milestone: 2026-06 +prs: [273, 277, 281, 282, 286, 324, 325] +updated: 2026-07-27 +--- + +# Signing & Auth Reliability + +The correctness work under signing: bech32 normalization, the Mesh 1.9 `signData` +argument order, a byte-preserving core-cst witness and body-hash merge with a +regression test, recovery from a stuck "Loading…" state, and login no longer +hanging on "Authorize". Byte preservation is the load-bearing part — co-signers must +sign identical bytes. + +## Related + +[[Multi-Signature Wallet Core]] · [[Mesh 2.0 Migration]] · [[Legacy Wallet Compatibility]] diff --git a/vault/features/Transaction Builder & tRPC Tests.md b/vault/features/Transaction Builder & tRPC Tests.md new file mode 100644 index 00000000..223fbcb1 --- /dev/null +++ b/vault/features/Transaction Builder & tRPC Tests.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Reliability & CI +state: planned +owner: Quirin +milestone: 2026-09 +issues: [255] +updated: 2026-07-27 +--- + +# Transaction Builder & tRPC Tests + +Integration coverage for the transaction builder and the tRPC routers, plus +extending the browser suite to the Sign-Off flows before they ship. + +## Related + +[[Playwright E2E Suite]] · [[Document Sign-Off MVP]] diff --git a/vault/features/Transaction Pagination.md b/vault/features/Transaction Pagination.md new file mode 100644 index 00000000..50d67735 --- /dev/null +++ b/vault/features/Transaction Pagination.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Platform & UX +state: planned +owner: Andre +milestone: 2026-10 +issues: [30] +updated: 2026-07-27 +--- + +# Transaction Pagination + +Pagination for wallets with long transaction histories. + +## Related + +[[Mobile & Responsive Foundations]] · [[Full Address Verification]] · [[Better 404 Page]] diff --git a/vault/features/User Profiles & Contacts.md b/vault/features/User Profiles & Contacts.md new file mode 100644 index 00000000..fda2612c --- /dev/null +++ b/vault/features/User Profiles & Contacts.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Growth & Accounts +state: planned +owner: Andre +milestone: 2027-01 +updated: 2026-07-27 +--- + +# User Profiles & Contacts + +The social layer: profiles and a contact book. The Contact model and profile-image +storage already exist, so this is the interface on top rather than a build from +scratch. + +## Related + +[[Invite Flow]] · [[Discover Page]] diff --git a/vault/features/Vesting Contracts.md b/vault/features/Vesting Contracts.md new file mode 100644 index 00000000..eeb0fe56 --- /dev/null +++ b/vault/features/Vesting Contracts.md @@ -0,0 +1,18 @@ +--- +type: feature +area: Wallets & Discovery +state: planned +owner: Quirin +milestone: 2027-01 +issues: [81] +updated: 2026-07-27 +--- + +# Vesting Contracts + +Time-locked multi-sig contracts, so a treasury can commit funds to a schedule that +the threshold alone cannot shortcut. + +## Related + +[[Multi-Signature Wallet Core]] · [[On-Chain Checkpoints]] diff --git a/vault/features/Wallet Import.md b/vault/features/Wallet Import.md new file mode 100644 index 00000000..f78c1f5e --- /dev/null +++ b/vault/features/Wallet Import.md @@ -0,0 +1,19 @@ +--- +type: feature +area: Wallets & Discovery +state: delivered +owner: Quirin +milestone: 2026-06 +prs: [274] +updated: 2026-07-27 +--- + +# Wallet Import + +Importing an existing multisig wallet, including across instances and on mobile. +The import wizard renders before a wallet is connected so people can see what is +available; the per-tab actions still gate on a live connection. + +## Related + +[[Legacy Wallet Compatibility]] · [[Multi-Signature Wallet Core]] · [[Mobile & Responsive Foundations]] diff --git a/vault/features/Wallet V2 Registration & Discovery.md b/vault/features/Wallet V2 Registration & Discovery.md new file mode 100644 index 00000000..fc737481 --- /dev/null +++ b/vault/features/Wallet V2 Registration & Discovery.md @@ -0,0 +1,20 @@ +--- +type: feature +area: Wallets & Discovery +state: delivered +owner: Andre +milestone: 2026-07 +issues: [33] +prs: [340] +updated: 2026-07-27 +--- + +# Wallet V2 Registration & Discovery + +On-chain registration records and a discovery index, so a multisig wallet can be +found rather than only shared by link. Delivered on schedule, and the foundation the +Discover page now builds on rather than starting fresh. + +## Related + +[[Discover Page]] · [[Multi-Signature Wallet Core]] · [[Wallet Import]] diff --git a/vault/features/dApp Connector.md b/vault/features/dApp Connector.md new file mode 100644 index 00000000..6d4bd7c3 --- /dev/null +++ b/vault/features/dApp Connector.md @@ -0,0 +1,17 @@ +--- +type: feature +area: Platform & UX +state: planned +owner: Quirin +milestone: 2026-11 +updated: 2026-07-27 +--- + +# dApp Connector + +Let external dApps request multi-sig transactions, so a team treasury can be used +wherever a single-signature wallet would be. + +## Related + +[[Improved Authentication]] · [[Multi-Signature Wallet Core]] diff --git a/vault/states/Blocked.md b/vault/states/Blocked.md new file mode 100644 index 00000000..0914ed80 --- /dev/null +++ b/vault/states/Blocked.md @@ -0,0 +1,12 @@ +--- +type: state +order: 4 +tone: bad +updated: 2026-07-27 +--- + +# Blocked + +Cannot proceed, or has regressed, for a reason outside the normal build cycle — an +unpublished upstream dependency, an undeployed migration, or a broken pipeline. +Blocked items are the ones worth looking at first. diff --git a/vault/states/Delivered.md b/vault/states/Delivered.md new file mode 100644 index 00000000..a7f292b6 --- /dev/null +++ b/vault/states/Delivered.md @@ -0,0 +1,12 @@ +--- +type: state +order: 1 +tone: good +updated: 2026-07-27 +--- + +# Delivered + +Built, reviewed and merged to `preprod`. **Delivered is not the same as live** — the +production database and `main` can trail `preprod` by weeks, so a delivered feature +may still be unreachable in production. See [[Production Release Gap]]. diff --git a/vault/states/In Progress.md b/vault/states/In Progress.md new file mode 100644 index 00000000..5c2af7f5 --- /dev/null +++ b/vault/states/In Progress.md @@ -0,0 +1,10 @@ +--- +type: state +order: 2 +tone: active +updated: 2026-07-27 +--- + +# In Progress + +Actively being built right now, with code on a branch but not yet merged to `preprod`. diff --git a/vault/states/Planned.md b/vault/states/Planned.md new file mode 100644 index 00000000..5d6dd52a --- /dev/null +++ b/vault/states/Planned.md @@ -0,0 +1,11 @@ +--- +type: state +order: 3 +tone: neutral +updated: 2026-07-27 +--- + +# Planned + +Scheduled in [`ROADMAP.md`](../../ROADMAP.md) with an owner and a target month, but +not started. A planned feature has no code yet.