vzzk — Production Desktop Product Portfolio
Product:
vzzk— a Tauri v2 + Angular 17 desktop bot app for streamers on Chzzk (Korea's live-streaming platform, operated by Naver) Companion system:vzzk-api— a NestJS cloud API (remote overlay sync, authentication, entitlement verification) Period: first commit 2024-05-22 through 2026-08-06 (1,196 commits, 82 release tags) Two sets of as-of dates: Numbers from GA4/BigQuery are as of the dashboard snapshot on 2026-08-06, with the raw aggregates in.agent/tmp/portfolio/facts.json. Numbers that come from the repositories — commits, tags, tests, file counts — are as of thevzzk/vzzk-apiworking trees on that same date and are not in facts.json. Citation rule: Every technical claim here was checked by reading the actual file, then cited as a repo-relativefile:lineor a commit hash. For claims no file can prove — behavior of browsers, platforms, or vendor infrastructure — I link the official documentation; where I can't produce a link, I say so on the spot: "I confirmed this by measurement and could not confirm it in vendor documentation." Currency: Won amounts are quoted as-is. Parenthetical USD approximations assume ₩1,400/USD.
Table of Contents
- Executive Summary
- Problem Definition
- System Architecture
- Technical Highlights by Paradigm
- Quantified Impact
- Incidents and Structural Fixes — Case Studies
- Development Methodology — How Agent Rule, Workflow, and Skill Definitions Scaled Solo Development
- Limitations and Roadmap
- Appendix: Technology Stack
1. Executive Summary
vzzk is a desktop app that takes Chzzk chat and donation events as input, runs 11 bots on them, and draws the results as real-time overlays on top of the OBS stream output. The 11 break down as: always-on bots that keep running once switched on — live captions, TTS chat reader, auto-reply; on-demand bots the streamer fires at a moment — poll, raffle, roulette, donation goal; two wired directly to donations — object-throwing (TITS integration) and song request; plus remote overlay sync (sync) and the stream teleprompter (prompter). The last two are closer to infrastructure in character, but they run on the same BotService contract and the same lifecycle as every other bot, so I count them the same way.
Over a 759-day observation window, cumulative sessions total 55,193 and the trailing-30-day average DAU is 90.6. In a 98-day raw-event window, the price field of donation events handled by vzzk's revenue-linked bots (object-throwing, roulette, donation goal, song request) sums to ₩30,027,318 (~US$21,400), received by 44 streamers. Donations themselves originate on Chzzk and arrive in the app as stream events (src/app/service/tits.service.ts:94), so this amount is not vzzk revenue, and this data can't settle whether vzzk caused those donations either. The trailing-30-day per-session error rate is 0.16% (5 errors / 3,128 sessions), and all 5 are one single kind: a login failure against the external platform. But that numerator counts only the %Fail events I logged explicitly. There is no telemetry collecting unhandled exceptions or crashes, so "zero bot-logic failures" should be read not as evidence that no defects exist, but as the absence of any instrument that would observe that class (§8.2).
This document makes three technical claims.
First, I designed the failure modes around the live-stream runtime. An overlay first runs inside an OBS browser source — and it does so while the stream is on air. OBS's CEF will let you attach a console if you enable the remote debugging port, but it isn't exposed by default, and you can't walk through that procedure mid-stream. So the cloud push is detached from the local rendering path with tokio::spawn so a remote outage can't propagate into frames (detach point src-tauri/src/overlay.rs:318-322, target function src-tauri/src/sync.rs:234-291); the browser's six-connections-per-origin limit is worked around by binding 10 ports (src-tauri/src/overlay.rs:879-896); and for overlay HTML, which never passes through a bundler, I wrote an AST-based static lint myself (script/lint-overlays.js).
Second, I moved to an authentication design that puts the client outside the trust boundary. A security audit found 4 symmetric secrets sitting inside the distributed binary. I moved login to a PKCE exchange code, Pro entitlement verification to Ed25519 public-key verification, and token storage to the OS credential store. I named the backward-compatibility paths "bypass routes an attacker gets to choose," and instead of deleting them on instinct, I persisted a per-path call counter to a volume and made 14 consecutive days at zero the removal condition.
Third, I validated the metrics before trusting them. GA4 raw events are exported to BigQuery to build a unified 759-day time series; 26 queries run in parallel and bake into a static HTML dashboard with no server behind it. On 2026-08-06, while pulling the numbers for this document, I hit an "842% activation → core action conversion rate for the live captions bot" — a value impossible by definition — stopped, reviewed every query definition, and found and fixed 4 aggregation defects. Every number in this document is a post-fix value.
What the three claims share is a posture: write the limitation down together with the metric. DAU is an upper bound because dimensions get double-counted in the sum, and the cumulative revenue figure backcasts 22 of the observation window's 26 months (2024-07 through 2026-04) from 98 days of observation, which puts the top of the interval at 3.5× the bottom. This document writes that fact next to each number.
2. Problem Definition
2.1 The Problem the Product Addresses
In live streaming, a viewer-engagement tool (poll, raffle, roulette, donation goal, song request) has to exist on two different screens at the same time: the control surface the streamer operates, and the overlay on the stream output that viewers see. The two run in different processes, and one of them is an environment the developer can't touch once the stream goes live.
The official OBS documentation defines a browser source as "It is, quite literally, a web browser that you can add directly to OBS" (https://obsproject.com/kb/browser-source). So the unit of distribution for an overlay is not an installable app but a single URL, and the render target is the Chromium (CEF) that OBS embeds. That one fact generates nearly every architectural constraint in this product.
- An overlay can't be drawn inside the app window. To be captured on air, it has to be a separate document.
- A browser source has a lifecycle: it reloads on scene switches and shuts down when hidden. Reconnection and state recovery are mandatory, not optional.
- A URL the streamer saved into OBS once has to keep working after the app restarts and after the app updates. A change that breaks the URL breaks every user's stream output.
2.2 Observed Distribution Instead of Market Claims
This document makes no market-size claim. A number like total creator-economy TAM includes advertising and commerce, so it sits an order of magnitude away from what a desktop tool for Chzzk streamers actually addresses, and I didn't do the work of narrowing it down. Same reason I don't claim a market gap in §2.3, where I ran no competitive survey.
What I write down instead is only the observed distribution. In the 98-day window, 44 streamers were observed receiving at least ₩1 in donations; of those, the top 5 take 70.8% of the observed total and the top 1 takes 26.8%. Donation receipts are extremely concentrated in a few hands. vzzk is not a tool that changes that distribution; it is a tool that sits on top of it.
Why there is no denominator: Dividing these 44 by the 749 users the PRO conversion query observed gives 5.9%, but the two values are outputs of different queries and I didn't verify that they cover the same population. The retention cohort for that same 98-day window is 746, different again. I don't report the ratio.
2.3 Choosing Chzzk, and What It Cost
vzzk is Chzzk-only. That choice carries a clear cost.
What I got: narrowing the market to a single Korean-language platform brought the domain down to a size one person can grasp and cover end to end — the chat protocol, the shape of donation events, the user culture. The observed trailing-30-day OS distribution is Windows and nothing else, which means the matrix of platforms I have to support is narrow in practice too. That figure, though, is based on the 2,821 sessions in the raw VIEW (advanced_events) that carry an OS dimension, and it differs by 307 from the 3,128 total sessions in the unified VIEW for the same window (§5.4) — I didn't determine the cause of the gap.
What I gave up: platform risk isn't diversified. In practice, all 5 runtime errors observed in the trailing 30 days are chzzk_loginFail, a single kind — zero failures in bot logic itself, and every observed failure sits on the external platform's login path. The ceiling on product stability is outside my control.
What I didn't verify: I make no market-gap claim of the form "no such tool existed in the Chzzk ecosystem," because this document includes no survey of competing products. Instead this section describes the space vzzk actually filled using only observed usage and observed donation receipts.
2.4 How the Solo-Developer Constraint Shaped the Design
The solo-developer constraint comes from arithmetic, not statistics. Add one more thing to maintain and there is still exactly one person to maintain it; every procedure left unautomated is subtracted from that one person's available hours. This premise directly explains several of vzzk's decisions.
- The dashboard has no backend. A server is permanent debt — authentication, deployment, billing, incident response — and there is only one person to carry it. I chose static HTML baked from the 26 queries, which drops the operational burden to zero.
- The test gate is planted inside the release pipeline. This repository is private on a free plan, so neither branch protection nor repository rulesets can be turned on — as of 2026-08,
gh api repos/:owner/:repo/branches/main/protectionand.../rulesetsboth return 403Upgrade to GitHub Pro or make this repository public. Even if they could be turned on, a one-person repository means I merge my own PRs, so they'd carry little force. So I hung the gate withneedsin front of the point that is hard to undo — the place where a tag push turns straight into builds for 4 platforms (.github/workflows/release.yml:57-58). - Procedures that need judgment became checklists; things that have to be mechanically exact became code generation. Adding one bot touches 16 places, and automating all of them would make the generator as complex as the project.
3. System Architecture
The whole system has four participants: the Angular WebView (control UI), the Rust actix-web local server (state holder + SSE), the OBS browser source (overlay rendering), and optionally vzzk-api (remote overlay sync). OBS connects straight to the Rust server without going through Angular.
3.1 (a) App ↔ local actix-web server ↔ OBS overlays
flowchart LR
subgraph APP["vzzk desktop app · single Tauri process"]
direction TB
NG["Angular 17 WebView<br/>11 BotServices · Signal state"]
RS["Rust actix-web server<br/>src-tauri/src/overlay.rs"]
ST["AppState<br/>Arc Mutex latest state + broadcast channel, capacity 100"]
NG -->|"Tauri IPC · tauri-specta generated bindings"| RS
NG -->|"POST /vzzk/vote/update<br/>loopback Host + Origin allowlist + control token"| RS
RS --- ST
end
subgraph OBS["OBS Studio · CEF browser source · loaded over the file scheme"]
direction TB
V["vote.html · port 26213"]
S["subtitle.html · port 26216"]
P["sr.html + sr-player iframe · port 26218"]
end
RS -->|"SSE /events · init comment force-flushed as the first chunk"| V
RS -->|"SSE /events"| S
RS -->|"SSE /events + player command stream"| P
V -->|"GET /init · pull recovery for late subscribers"| RS
P -->|"POST heartbeat every 500ms · shared port 26210"| RS
P -->|"GET /status · reverse-direction player status"| RS
Three things to read out of this diagram.
The ten ports are origin separators, not routing. The same App factory binds to ten listeners on 26210–26219 (src-tauri/src/overlay.rs:852-897), and they all share a single web::Data<AppState> (:679-722). So a POST /update that arrives on 26213 still reaches a subscriber attached to 26210, and an overlay can open its SSE stream on its own port while sending heartbeats only to 26210 without any trouble. The one job a port does is tell the browser "this is a different origin."
The ": init\n\n" first chunk borrows a comment line that the SSE spec recommends and puts it to work as a handshake. As the paradigm table in §4.2 notes, the comment line itself is a spec recommendation, and its purpose there is to keep proxies from timing out. Here the purpose changes: it makes the moment onopen fires deterministic. An EventSource attached to a quiet channel doesn't fire onopen until the first byte arrives, and any recovery logic hanging off onopen dies with it. So every /events stream emits one comment line immediately after subscription (src-tauri/src/overlay.rs:267-276). The premise this rests on — that actix-web does not emit the 200 OK headers until the first data chunk goes out — I confirmed by measurement; I could not confirm it in vendor documentation.
It is the 127.0.0.1 literal, not localhost. I measured that OBS's CEF resolves localhost to ::1 first while the server binds to IPv4 only, which produces an immediate connection refusal (not confirmed in vendor documentation). I gave up readability and hardcoded the literal. App code running inside the Tauri WebView does the opposite and uses localhost as-is (src/app/service/overlay.service.ts:28) — the two are not the same rule.
3.2 (b) App ↔ vzzk-api ↔ remote overlay sync
Remote overlays are a PRO feature that lets a streamer open the stream view on another device (a dual-PC setup, a phone). The core design decision is layering local and cloud as primary/secondary rather than treating them as two equal paths.
flowchart TB
NG["Angular · SyncService<br/>extends BotService · turned on by a single console toggle"]
RS["Rust actix-web<br/>POST /vzzk/:type/update"]
LSSE["local SSE broadcast<br/>OBS overlays · synchronous path · always runs"]
SY["src-tauri/src/sync.rs<br/>detached with tokio spawn · one stderr line on failure"]
API["vzzk-api · NestJS · single Railway instance"]
MEM["in-memory Map<br/>channelId → overlayType → one latest state, overwritten"]
FB["Firebase RTDB<br/>cache for reconnect recovery · degrades to a no-op when unconfigured"]
RSSE["remote SSE<br/>45s ping · 14min reconnect_required · 18 connections per channel, FIFO"]
REMOTE["remote overlay browser<br/>same HTML · mode branches on location.protocol"]
VIEWER["viewer actions · song requests and the like"]
INB["inbound SSE → inboundCommand event bus<br/>domain services subscribe on their own via filter"]
NG --> RS
RS ==>|"① state update + local broadcast"| LSSE
RS -->|"② detached asynchronously"| SY
SY -->|"POST /sync/push · Bearer token<br/>target channel derived from the token, not from a header"| API
API --> MEM
API -.->|"write failures tolerated"| FB
MEM --> RSSE
RSSE --> REMOTE
FB -.->|"init fallback only when the in-memory cache is empty"| RSSE
VIEWER -->|"POST /sync/action/:channelId<br/>session decrypted, then viewerChannelId injected"| API
API --> INB
INB --> NG
The bold arrow (①) is the path that carries the stream. Putting the cloud in series with that pipeline means remote latency or a remote outage freezes local OBS frames outright. Detaching the push completely with tokio::spawn costs ordering guarantees, and in practice deactivate did race the final push on shutdown, which I work around with an invariant: no overlay push from a bot's onBeforeClose().
Two infrastructure layers cut SSE connections for different reasons — Cloudflare at 100 seconds idle, Railway at 15 minutes absolute. I measured both values from disconnect patterns in production; I could not confirm them in vendor documentation. Leaving it to the client's automatic reconnect means the drop is noticed late and the view freezes for several seconds. So I inverted it — the server disconnects first, before the infrastructure does — sending a ping every 45 seconds and having the server send reconnect_required at 14 minutes so the switch happens on schedule (vzzk-api/src/sync/sync.service.ts:216-227, :282-292). To absorb the cost of that periodic reconnect load, the server sends the cache immediately after each reconnect (:248-252).
The same HTML runs both locally and remotely. window.location.protocol decides which, branching only the 10 lines that assemble URLs; the rendering code is 100% shared (src-tauri/overlay/subtitle.html:195-205). The cost is a coupling — the remote server has to imitate the local Rust server's response schema exactly — which is why vzzk-api's default constants carry a comment saying they must match the defaults in the local Rust server's create_app_state() (vzzk-api/src/sync/sync.types.ts:73-94).
3.3 (c) PKCE authentication sequence
RFC 8252 §6 states that public native app clients MUST implement PKCE, and §8.5 says that a secret statically embedded in an app distributed to many users must not be treated as confidential.
vzzk does not apply that rule as written, though. In the sequence below, the party that exchanges the authorization code with Chzzk's authorization server is not the app but vzzk-api — my own backend acts as the confidential client upstream. The leg PKCE protects is the one-time exchange code between the app and my own backend, which is a different leg from the one RFC 8252 §6 governs (native app ↔ authorization server). Rather than following the rule, this reproduces on a different leg what the rule is trying to prevent: a public client using an intercepted code.
sequenceDiagram
autonumber
participant A as vzzk app · src-tauri/src/oauth.rs
participant B as system browser
participant S as vzzk-api
participant C as Chzzk OAuth
A->>A: Generate code verifier and state, hold them in process memory only
A->>B: Open the authorization start URL · code challenge attached
B->>S: GET login start
S->>B: Challenge and client state go into HttpOnly cookies, nonce is planted in both the state and the cookie, 302
B->>C: Authorization screen · user approves
C->>S: Callback · authorization code
S->>S: Compare the nonce in state against the cookie nonce, then discard the cookie immediately · blocks login CSRF
S->>C: Exchange the authorization code for an access token
S->>S: Issue a one-time exchange code · TTL · held in process memory
S->>B: 302 deep link · carries the exchange code only · no tokens
B->>A: Deep link delivered
A->>A: Check scheme + fixed host + state + TTL + single-use consumption
A->>A: Take the pending state before the network round trip · guards against duplicate deep links
A->>S: POST exchange request · exchange code + code verifier
S->>S: Compare verifier against challenge · reject on mismatch
S-->>A: Return tokens in the response body only
A->>A: Store in the OS credential store · never falls back to a plaintext file on failure
The PKCE challenge is not encoded into state; it lives in a server session cookie (vzzk-api/src/common/security/oauth-state.ts:55-68). State makes a round trip through the upstream provider (Chzzk) and is subject to length limits, while the cookie travels only inside my own domain and the client cannot touch it. I don't call this a deviation from the standard — it is an ordinary session pattern once a server sits in the middle, and PKCE's idiom is "the client holds the verifier," not "the challenge rides in state." What it buys is less coupling to upstream spec changes.
There is one more place where I inverted the order — step 12. The pending state is taken and cleared before the network round trip, and it is not restored on failure (src-tauri/src/oauth.rs:119-132, :220-234). On Windows, single-instance argv and the deep-link plugin both delivered the same deep link twice; this ordering ends the second call before it reaches the server (commit 8b11c8d). The justification — the code is single-use, so the same code could not be retried anyway — is recorded in a code comment.
The cost is just as clear. Because exchange codes live in process memory, the API server cannot scale horizontally (vzzk-api/src/auth/token-exchange.service.ts:17-21). That constraint is still on the roadmap in §8.
3.4 (d) GA4 → BigQuery → static dashboard pipeline
flowchart LR
APP["vzzk app<br/>src/app/service/analytics.service.ts<br/>single instrumentation point · event_name = category_action"]
GA4["GA4 property"]
BF["GA4 Data API backfill<br/>script/ga4-backfill.js"]
EX["GA4 Daily Export<br/>enabled 2026-05-01"]
V1["unified VIEW daily_summary<br/>759 days · rows aggregated per dimension"]
V2["raw VIEW advanced_events<br/>98 days · session_id · user_id · price"]
Q["script/generate-dashboard.js<br/>26 BigQuery queries · Promise.all in parallel"]
MC["script/monte-carlo-revenue.js<br/>10,000 simulations · sampling-uncertainty interval"]
HTML["vzzk-dashboard.html<br/>single static file · 19 charts · no server · $0 per month"]
APP --> GA4
GA4 -->|"returns aggregates only for past ranges"| BF
GA4 -->|"raw events · only from the day it was enabled"| EX
BF -->|"UNION ALL + date NOT IN<br/>dates covered by the export are excluded from the backfill"| V1
EX --> V1
EX --> V2
V1 --> Q
V2 -->|"raw-dependent charts fall back to a placeholder when the data is missing"| Q
Q --> MC
MC --> HTML
Q --> HTML
This pipeline's design follows from a single constraint: GA4 does not hand back the past as raw events. Daily Export only accumulates from the day it is switched on, so I backfilled the 2024-07 to 2026-04 range with Data API aggregates, used raw data from 2026-05 onward, and stitched the two sources together without overlap using UNION ALL + date NOT IN, producing a continuous 759-day series (script/ga4-backfill.js:334-357).
The cost is left in plain sight. Session-level and price-level analysis — retention rate, session depth, conversion rate, revenue — is only possible inside the 98-day window. This constraint is the source of nearly every limitation in §5. Forcing raw precision into the unified view would turn the entire past range NULL and break the whole dashboard, so I split the view in two and made the queries swallow the error and return an empty array when the table does not exist yet (script/generate-dashboard.js:110-112). As a result, only the raw-dependent charts among the 19 sit in a waiting state, individually.
4. Technical Highlights by Paradigm
§4.0 states what this architecture produces today. From §4.1 onward, each axis runs in the same order: the industry paradigm (definition and source) → how vzzk implements it (file:line) → what I gave up and what I got.
4.0 Current state — what being a native app makes possible
This section is about the finished result, not the process that got there. All four items below fall under "structurally impossible, or far more expensive, if this had been built as a web service," and each is verifiable in code.
① Every event is handled inside the streaming PC — integration with third-party desktop apps
Chzzk chat and donation events are received and processed on the streamer's machine, so vzzk can talk directly to the other broadcast tools running on that same machine. A hosted web service does not get to cross that boundary.
| Integration | How | As a web service |
|---|---|---|
| TITS (throwing objects at the avatar) | Connects directly to both the data and event endpoints of a local WebSocket at ws://localhost:42069 (.agent/skills/tits-api/SKILL.md:17-18) |
Cannot reach a local port on the user's PC |
| TTS audio output | Played straight to the OS audio device via Rust rodio (src-tauri/src/tts.rs). Unaffected by browser autoplay policy, tab focus, or audio permissions, and routable into OBS through a virtual audio cable |
Plays only inside the browser sandbox |
| Song request playback | Spawns yt-dlp as a child process to extract the stream URL, then relays it through a local HTTP Range proxy (src-tauri/src/youtube_proxy.rs) |
Cannot launch an external executable on the user's PC |
| Live captions | The app acquires OS speech-recognition permission directly and processes the microphone stream (including macOS TCC handling) | Breaks when the tab closes or goes to the background |
This choice carries a risk a web service doesn't have. Because the local server listens on fixed ports, any web page the streamer opens mid-stream can have its JavaScript send requests to that local server through the browser — and with fixed ports, there is no address to guess. That is why the local overlay server has its own access control (src-tauri/src/local_guard.rs). Choosing native means building that defense yourself; that is the cost.
② The server holds no streamer identity data — the blast radius of a breach
In 2026-07, Chzzk published a streamer advisory reporting a personal-data breach at an external platform integrated with Chzzk channels. The advisory listed the leaked fields as email, nickname, and internal user ID for all users, and legal name, date of birth, foreign-national status, phone number, carrier, and streaming-platform account information for some.
vzzk-api collects none of those fields. Inspecting the repository:
| Checked | Result |
|---|---|
| ORM, migrations, user table | None exist. No TypeORM/Prisma/Mongoose dependency and no entity definitions |
| Email, phone, legal name, date of birth | No code anywhere in the source handles these fields |
| What a viewer session carries | Only channelId, channelName, expiresAt (vzzk-api/src/auth/viewer-auth.controller.ts:109-110). The Chzzk access token is not included |
| Where streamer tokens live | Not on the server — in the OS credential store on the user's PC (src-tauri/src/keychain.rs) |
| Login mechanism | PKCE exchange code. No token rides in the deep link, and the exchange code exists only in process memory with a 2-minute TTL |
In other words, there is no personal-data database to breach in the first place. This isn't the outcome of responding to an incident; it was a design-time choice, and it is the server-side mirror of §4.3's principle of keeping the client outside the trust boundary.
Stated with the counterweight, so this doesn't overclaim. It does not mean vzzk stores nothing. Remote overlay state — caption text, the song-request queue, viewer nicknames — is cached in Firebase RTDB, and that read path is still unauthenticated (left open in §8.1). A third party who knows a channel ID can pull that state while the stream is live. The difference is the kind of data: holding no identity-class fields that would fuel phishing or smishing after a leak is a different risk profile from caching state that is already displayed publicly on screen during the stream.
③ Tauri distribution runs without a commercial service
Signing, distribution, and auto-update for a Tauri app are normally handled by subscribing to a commercial service such as CrabNebula Cloud. vzzk builds that path from GitHub Actions and object storage alone.
| Component | Implementation |
|---|---|
| Build | Four-platform matrix — Windows MSI, macOS arm64/x64, Linux AppImage (.github/workflows/release.yml) |
| Signing | A minisign signature (.sig) is uploaded alongside every artifact; the updater verifies it with the public key |
| Distribution | Artifacts go to a public object-storage bucket, and only the updater manifest (latest.json) is uploaded with Cache-Control: no-cache, so update visibility isn't delayed |
| Credentials | Code-signing certificate creation, renewal, and CI secret propagation run from a single script (script/generate-cert.js), with a separate workflow that verifies secret validity immediately before a release |
The cost is storage and egress, with no subscription fee. That said, as §5.4 records, I never measured actual storage and egress usage, so I don't claim a savings figure. What I can claim is the fact that signed four-platform distribution and auto-update are running without a commercial distribution service.
④ A standing environment for making product decisions from data
The GA4 event taxonomy is designed in three tiers — activation, core action, error — raw events export to BigQuery to form a continuous 759-day time series, and 26 queries bake into a static HTML dashboard (§3.4, §5.0). The result is a maintained state in which which bots actually get used, where users drop off, and which errors end sessions are all answerable as individual questions. That environment is what made a judgment like §5.3's "on-demand bot conversion of 1.8–7.9%" possible, and the 4 metric defects in §6, Case 3 were found inside it.
What these four share is that they all derive from a single decision: where to put the runtime boundary. Putting processing on the user's PC buys local integration and data minimization, and the price is that distribution, signing, and local access control have to be built by hand. §4.1 through §4.4 cover how that price was paid.
4.1 Desktop and Distribution — Local-First and Supply-Chain Integrity
Paradigm
| Paradigm | Definition | Source |
|---|---|---|
| Local-first / offline-first | The primary store for app state lives on the user's device rather than on a server; the server only assists with sync | https://www.inkandswitch.com/essay/local-first/ |
| Lightweight runtime on the system WebView | Tauri reuses the OS WebView and claims a minimum app size "under 600KB", while Electron bundles Chromium and Node.js and leaves the app developer responsible for patching their vulnerabilities | https://v2.tauri.app/start/ , https://www.electronjs.org/docs/latest/tutorial/security |
| Permission-boundary security model | Draw an explicit trust boundary between the frontend (low trust) and the native side (high trust), and declaratively whitelist which capabilities can be called | https://v2.tauri.app/security/ |
| Signature-verified auto-update | Tauri's updater states that a signature is required to verify an update came from a trusted source and cannot be disabled, and it forces the public key into the config file as a literal value | https://v2.tauri.app/plugin/updater/ |
| Build provenance (SLSA) | L1 provenance exists / L2 the hosted build platform signs it / L3 build isolation. The threats these address are, respectively, release mistakes / tampering after the build / tampering during the build | https://slsa.dev/spec/v1.0/levels |
How vzzk implements it
I applied local-first literally. The overlay HTML isn't served over HTTP; it loads through the file scheme (src/app/service/overlay.service.ts:85-90, src-tauri/tauri.conf.json:50). Serving over HTTP would leave the OBS source blank whenever the app is closed, whereas the file scheme loads independently of the app and SSE attaches afterward. The server lifecycle is decoupled from the UI lifecycle too: setup() spawns it with async_runtime::spawn and it isn't tied to window events (src-tauri/src/lib.rs:264-267) — previously the shutdown sequence hung off WindowEvent::Destroyed, so the server died silently on a hot reload or a window change. I chose "the server stays alive while live" over the correctness of a leak-free shutdown.
That costs two things. The overlay document becomes an opaque origin, which weakens origin-based defenses, and the YouTube iframe doesn't work under the file scheme, so exactly one player needed a two-stage workaround — an sr.html loader plus an HTTP iframe (src-tauri/src/overlay.rs:442-451).
The cost of reusing the system WebView isn't size, it's control over the runtime version. The "under 600KB" figure quoted in the table above is the vendor's hello-world number; vzzk's actual Windows installer is about 7.8 MB (local build output under src-tauri/target/release/bundle/nsis/, v1.2.12). The heavier cost is that I can't decide which WebView2/WKWebView version sits on a user's machine — rendering and JS behavior differences that reproduce only on a specific OS version can't be fixed in app code, and because the overlay runs inside OBS's CEF there is one more runtime in play. I haven't observed a case like that so far. But as noted in §2.3, every observed OS is Windows, so I have no data on what happens in other combinations.
I drew the permission boundary as a read/write asymmetry. Middleware checks a loopback Host and an Origin allowlist on every request, and only state-changing POSTs additionally require a control token held in process memory (src-tauri/src/overlay.rs:729-770, src-tauri/src/local_guard.rs:94-140). Opaque origins have to be allowed for the overlay to work at all, but an attacker page can produce the same origin with a sandboxed iframe. I left reads open and locked only writes so that nothing can manipulate the stream output while the URLs a user saved in OBS keep working. Blocking it completely would mean moving the overlay to HTTP with a URL token, and every saved path would change.
minisign covers update integrity; I gave up on execution reputation. The updater public key sits as a constant in tauri.conf.json, and the private key is read from a sync folder outside the repository and injected as an environment variable (script/build-signed.js:16-20, :44-49). CI receives the same value from GitHub Secrets. Distribution goes through a public GCS bucket, so without signatures a single bucket credential turns into RCE on every user's machine. Code signing, by contrast, is self-signed, so the SmartScreen warning remains — and the user-facing note in the release notes exists because of it. Integrity (tamper detection) is minisign's job, and reputation is covered by a note instead of the price of a certificate.
The cost of the minisign key is that it can't be rotated. Losing the private key means losing the auto-update path itself (README.md:52), and replacing the key forces existing users to reinstall manually (README.md:74). Both warnings are written down in the README.
Every build runs in CI. Tag push → 4-platform matrix build (macOS arm64/x64, Ubuntu, Windows) → signing → merging the per-platform partial manifests → GCS upload → creating the release on the public repository. My development workstation is Windows-only, so there is no way to produce the macOS and Linux artifacts locally — this isn't saved time, it's a capability I didn't have.
That said, I can't call this pipeline an SLSA level. By the definition in the table above, SLSA grades levels by the existence, signing, and isolation of build provenance, not by artifact signing — and .github/workflows/ has no step that generates provenance (zero attest/provenance-family actions). All I have is minisign artifact signing, and that isn't what SLSA counts — with no provenance, I don't even meet L1. I leave this open in §8.
The release ordering creates one problem of its own. The GCS upload starts by deleting the existing files, and the public-repository release is created after that. If the token for the later step is dead, what's left is binaries swapped with no release notes, and I only find out after pushing the tag. Rather than reorder steps that are hard to undo, I added a pre-flight verification workflow: checkout only proves read access, so it queries repository permissions directly, and it uploads and deletes a temporary object to confirm write access to the bucket (.github/workflows/verify-release-secrets.yml:21-32, :42-49, commit c198a2f). The cost is that it runs manually, and it's useless if I forget to call it.
4.2 Real-Time — Transport Choice and Connection Lifecycle
Paradigm
| Paradigm | Definition | Source |
|---|---|---|
| Transport chosen per direction | SSE for one-way server-to-client traffic; WebSocket only when bidirectional low latency is genuinely needed. Under HTTP/1.1, SSE is capped at 6 connections per domain per browser | https://html.spec.whatwg.org/multipage/server-sent-events.html , https://datatracker.ietf.org/doc/html/rfc6455 |
| The connection lifecycle is a state machine | A cycle running from liveness checks → disconnect detection → backoff reconnect → recovering the lost window. The SSE spec recommends sending a comment line roughly every 15 seconds to keep proxies from timing out | https://html.spec.whatwg.org/multipage/server-sent-events.html , https://dev.twitch.tv/docs/eventsub/handling-websocket-events/ |
| Backpressure and its absence | Backpressure feeds the consumer's spare capacity back to the producer to regulate publish rate. With no channel to feed back through, the only options left are load shedding and deduplication. The Node.js guide's 87.81 MB → 1.52 GB benchmark targets Node streams, so it's reference material that doesn't transfer directly here | https://nodejs.org/learn/modules/backpressuring-in-streams |
| Browser-source overlays | Treat the stream output as a web runtime. The unit of distribution is a single URL, not an installed app | https://obsproject.com/kb/browser-source |
How vzzk implements it
I picked SSE as the default and handled the 6-connection cap knowingly. Each overlay permanently occupies one SSE connection, so on a single origin the seventh silently freezes in Pending. I bound the same App factory to ports 26210-26219 to multiply origins, and the reason is left in a code comment (src-tauri/src/overlay.rs:879-896). The one recorded alternative was IP aliasing such as 127.0.0.2, ruled out because macOS blocks it by default (.agent/skills/overlay-architecture/SKILL.md:17) — that block is a hands-on measurement recorded in internal docs, and I couldn't confirm it in vendor documentation.
There is an alternative I never evaluated. The 6-connection cap applies to HTTP connections, so moving the transport to WebSocket would have solved it on a single origin. And this product already has a WebSocket path — the remote teleprompter connects over socket.io (src-tauri/overlay/private/prompter.html:436-441; the server side is vzzk-api/src/prompter/prompter.gateway.ts). But that's the remote sync path, and the repo holds no record of WebSocket ever being raised as a candidate for local overlay transport. I didn't consider it at the time is the accurate description.
Adding ports costs two things: (1) every new overlay requires hand-matching a Rust constant with an HTML constant, and (2) the user's firewall shows 10 listening ports. What I got is a connection limit that stays constant as the number of overlays grows.
The connection lifecycle treats disconnects as a normal path. It splits into three layers.
- Micro-disconnect tolerance: the heartbeat is 500ms, but the player isn't torn down until 5 consecutive failures (2.5 seconds) (
src-tauri/overlay/sr.html:192-198,:216-222). Using a 0.5-second heartbeat directly as a death signal would unmount the YouTube iframe on an ordinary CPU spike and start a restart loop. I delayed fault detection by 2.5 seconds and in exchange eliminated playback interruptions caused by false positives. - Late-subscriber recovery (two tracks):
fetchInitData()(an immediate pull) andconnectSSE()(a stream push) run in parallel, and anisDataInitializedflag prevents a double render (src-tauri/overlay/vote.html:405-436,:465-466). A broadcast channel never delivers messages sent before subscription, so for the result to be the same whether the app or OBS starts first, the server has to hold the state and the client has to pull it. I accepted one extra request every time and removed startup-order dependence entirely. - Separating planned reconnects from real failures: only
onerrorsets thewasDisconnectedflag and triggers a data refetch; the server-sentreconnect_requireddoesn't set it (src-tauri/overlay/subtitle.html:352-391).location.reload()is the easiest recovery, but it throws away the DOM, user input, and any animation in progress. I took on the complexity of hand-managing three state flags and chose soft recovery instead.
High-frequency pushes are handled by load shedding, not backpressure. Interim speech-recognition results are on by default (src/app/service/subtitle.service.ts:90), which produces several pushes per second. There is no backpressure signal in this system — the producer (the speech recognition engine) has no idea how much headroom the consumer has, and when it overflows, it drops. All three layers below are load shedding and deduplication, not publish-rate regulation.
- I opened a separate server-side ceiling sized to measured load. Applying the global rate limit of 300/60s (5 per second) as-is silently cuts captions off mid-stream. The push route alone gets 1800/60s (30 per second), with the rationale left in a code comment (
vzzk-api/src/sync/sync.controller.ts:119-122). - I defined the cache as a single overwritten last-state entry rather than an accumulating queue (
vzzk-api/src/sync/sync.service.ts:145-161). A reconnecting overlay receives only the latest entry instead of hundreds of backlogged captions at once — reconnect cost is fixed at O(1). The cost is that captions in the disconnected window are lost permanently, and that rests on a domain judgment that only the latest caption value carries meaning. - The client skips the render when the payload is identical (a
lastEventDatastring comparison). Putting that variable insideconnectSSE()would reset it on every reconnect and defeat the guard, so the skill document nails down the scope location itself as a warning.
Leaving debounce out was an explicit choice. Interim results exist for real-time feedback, so I decided not to add delay to publishing. But I never evaluated a trailing throttle — which would cut interim publish volume without adding latency to the final result. So this wasn't a decision that separated "don't add latency" from "don't reduce publish volume."
The Rust-side broadcast channel (capacity 100) takes the same stance. Shedding RecvError::Lagged is the exact opposite of backpressure: instead of feeding rate back to a lagging consumer, it drops whatever piled up. It sheds, keeps receiving, and ends the stream only on Closed (src-tauri/src/overlay.rs:479-488). A slow subscriber that falls behind loses messages, but cutting the stream and forcing a reconnect would mean refilling state through a status query anyway. I accepted losing individual messages in exchange for keeping the connection, on the grounds that most overlay protocols are state-based and send a full snapshot, so losses don't accumulate.
I checked every protocol, and that premise doesn't hold for donation goals. The local SSE sends only the single updated goal (src-tauri/src/overlay.rs:417-419); the full array rides only on /init and the cloud push path (:651-654). If one goal's update is dropped as Lagged while multiple goals are running, that goal keeps a stale value until the next update or a reconnect. It's the only path where the state-based rationale doesn't apply, and I leave it in §8.1.
I moved abuse defense from rate limiting to a connection-count cap. The SSE, init, and static HTML routes are exempt from throttling. An OBS browser source reconnects at 1-second intervals when it drops, so if 6 overlays drop at once, reconnects alone reach 360 per minute, hit the limit, and at that moment the stream output never recovers. I made "never rate-limit the failure-recovery path" a principle, and put a cap of 18 SSE connections per channel with FIFO eviction in its place (vzzk-api/src/sync/sync.service.ts:203-207, :426-452). I chose FIFO — dropping the oldest — over a 429 rejection because the window a user just opened is the one that has to survive.
The cost only reads accurately when overlaid on the earlier part of this paragraph. An evicted overlay also reconnects one second later, so above 19 connections this doesn't stop at "the first window silently dies" — it degrades into a cycle where the oldest connection is evicted in rotation. At the current scale (6-10 overlays per channel) the cap has never been reached, so it hasn't been observed, but it is a structural defect. Sending a reconnect-suppression signal along with the eviction is the next step, already scoped, and I leave it in §8.1.
4.3 Security and Authentication — Keeping the Client Outside the Trust Boundary
Paradigm
| Paradigm | Definition / requirement | Source |
|---|---|---|
| Public client model | RFC 8252 §8.5: a secret statically embedded in an app distributed to many users must not be treated as confidential. Client authentication based on a shared secret is NOT RECOMMENDED | https://www.rfc-editor.org/rfc/rfc8252.html |
| PKCE | RFC 8252 §6: public native app clients MUST implement PKCE. OAuth 2.1 extends this to all clients | https://www.rfc-editor.org/rfc/rfc7636 , https://oauth.net/2.1/ |
| Public-key signature verification | The client only verifies and holds no secret at all. Ed25519 keys and signatures are small enough to place directly in a config file | https://www.rfc-editor.org/rfc/rfc8032.html , https://github.com/jedisct1/minisign |
| Server-authoritative entitlement | CWE-602 classifies a server relying on a protection mechanism placed on the client as its own vulnerability class. The mitigation is duplicating every security check on the server side | https://cwe.mitre.org/data/definitions/602.html |
How vzzk implements it
The principle is pinned to a single sentence in the vzzk-api README: no symmetric secret goes into a distributed client binary. The reasoning sits in the same document — a symmetric secret requires both sides to hold the same value, so if one side lives on a user's PC, that value belongs to the user and one strings run extracts it.
I implemented this principle on 2026-07-28 as a pair of changes committed to the two repositories 25 seconds apart: server first (d288032 16:51:59), app second (1dfaeec 16:52:24). The order runs that way because the server has to widen first before the app can use the new path, and because a broken server deployment hits every user, I tested not only the new path but also that the old path still worked untouched.
Four symmetric secrets were each removed a different way.
| Secret removed | Replacement | Evidence |
|---|---|---|
| Deep-link token decryption key | PKCE exchange code — the token no longer rides on the deep link | src-tauri/src/oauth.rs:47-57, :189-209 |
| Static API key shared by every installation | The logged-in user's Bearer token | src-tauri/src/sync.rs:271-276 |
| HMAC signing key for Pro entitlement | Ed25519 public-key verification (the app holds only the public-key constant) | src-tauri/src/pro.rs:29-59 |
| Copy of the server key in a test script | Changed the server to return a plaintext token | vzzk-api README.md |
Today not one of those four secrets' names remains anywhere in src-tauri/src/ (verified by grep). But an identifier disappearing from source and secret material disappearing from a distributed binary are different layers. This section started from "one strings run extracts it," so verification should sit at that same layer — and I never ran a string scan against a release binary.
A signature alone wasn't enough. After verifying the Ed25519 signature, the app checks that the payload's channelId matches the currently logged-in account (src-tauri/src/pro.rs:89-93). Verifying the signature alone would let someone copy another Pro user's legitimate response and use it — what this closes is the path of copying someone else's response. Replaying one's own past response is blocked not by this comparison but by the payload's exp check (:100-109, which drops isPro to false once expired). The comparison does use a constant-time compare (src-tauri/src/oauth.rs:89-96), but channelId isn't a secret, so a timing oracle has nothing to gain — that's habit showing through, with no security effect.
Two cases that solved the same problem in opposite directions show the criterion I use on this axis.
For Pro entitlement, the server sends HMAC and Ed25519 at the same time. This is a pure loss: request with the old convention and the forgeable verification is still there, so even at the point Ed25519 was added, forging Pro entitlement remained possible. I took this shape anyway because the app and the server ship on separate schedules and a user can postpone updating.
For viewer sessions, by contrast, the old CBC format is rejected at the format-check stage (vzzk-api/src/auth/viewer-session.ts:83-88). CBC without an authentication tag made channelId forgery undetectable.
What separated the two decisions wasn't whether it is safe, but what a break costs the user. A viewer session is short-lived and one re-login ends it; Pro entitlement requires replacing the app binary.
I also put the diagnosability of failure modes above clean naming. The documentation explicitly says not to rename signatureEd25519 to signature when HMAC is removed. Renaming it would make an old app interpret the Ed25519 signature as an HMAC and end in a verification failure; if the field is simply absent, the cause is visible in the response itself.
Token storage fails toward the safe side. If the OS credential store (Windows Credential Manager / macOS Keychain / freedesktop Secret Service) is unreachable, the app does not fall back to a plaintext file — it keeps the session in memory only and requires a re-login on the next run (src-tauri/src/keychain.rs:17-20). The previous store, tauri-plugin-store, wrote an unencrypted JSON config file that any process running as the same user could read. Reads, on the other hand, never throw under any circumstance, so they can't block the startup path (:42-45).
I allow boot when the key is missing. If the signing-seed environment variable is absent, the service logs a warning and sends HMAC only (vzzk-api/src/pro/pro.service.ts:76-104). Giving up fail-fast shrinks "one missing environment variable takes the whole service down" into "only new-version apps fail to get a Pro verdict." The cost is that a bad deployment can pass silently, so the README spells out a manual post-deploy check.
4.4 Data — Event Taxonomy and Distribution Metrics
Paradigm
| Paradigm | Definition / requirement | Source |
|---|---|---|
| Event taxonomy | Pin event names and properties with a tracking plan documented in advance, then compute funnels and retention on top of it. Amplitude states that events differing only in letter case are captured as different events | https://amplitude.com/docs/data/data-planning-playbook |
| Distribution metrics | The Google SRE book: mean latency hides the case where most requests are fast but the long tail is far slower. Most metrics are better thought of as distributions than as averages | https://sre.google/sre-book/service-level-objectives/ |
| Warehouse-native | Don't treat vendor-UI aggregates as the final output; export the raw events to a warehouse and redefine the metrics in SQL. Data ownership stays with the user | https://support.google.com/analytics/answer/9358801?hl=en |
How vzzk implements it
I carried the taxonomy in a string convention instead of a separate registry table. Event names are forced into a category_action shape, and a suffix decides which of three tiers an event falls into: _open is activation, everything except _closed and Fail is value creation, and the Fail suffix is an error (script/generate-dashboard.js:283-286).
What I got: adding a new bot takes one reporter method, and 26 queries recognize that bot automatically. What I gave up: compile-time guarantees. And I actually paid that price — in BigQuery LIKE an underscore is a single-character wildcard, so the PRO prefix pattern pulled three prompter_ events into the funnel. §6, case 3 covers it.
I split three tiers because the questions differ in kind, not for analytical convenience. _open means the bot was only switched on from the menu, so it measures interest; a core action means the bot actually ran, so it measures value creation; Fail measures why users drop off. Counting all three as one lump makes a bot that was merely left on indistinguishable from a bot that was actually used. In practice, the activation → core action conversion rate over the last 30 days spreads widely, from 96.6% for subtitle down to 1.8% for vote. I don't turn those into cross-bot multiples, though — "open" means different things per bot (for captions it's starting recognition, for polls it's opening the panel), so the denominators don't mean the same thing. This metric is for comparing one bot against its own time series (§5.3).
The client filled an instrumentation gap. GA4 export's device.operating_system sometimes arrives empty from the Tauri WebView, so the app attaches os to every event's parameters (src/app/service/analytics.service.ts:383; the value is derived at :361-367) and the raw view merges the two with COALESCE(event_params.os, device.operating_system) (script/ga4-backfill.js:395-398). The event payload grows; in exchange the OS distribution comes out consistently as a single value.
This correction has two scope limits. First, the COALESCE applies only to advanced_events (98 days), not to the 759-day unified view. Second, the app's getOS() reads the same navigator.userAgent that GA4 does, so it doesn't explain why the value goes missing on only one side — and I never measured how often it's missing. In other words, this patches over a gap rather than diagnosing its cause.
I compute P90 instead of the mean, at the BigQuery layer. Donation amounts and stream durations are extreme long tails dominated by zeros, so the mean gets dragged down by unpaid and test sessions. I produce P90 with APPROX_QUANTILES (script/generate-dashboard.js:154-162, :223-231). I accepted that P90 is not a representative value but the top-10% value, and in exchange removed the downward flattening that outliers cause. An additional cost: APPROX_QUANTILES is approximate, so I traded accuracy for scan cost.
For the same reason I dropped the point estimate for total revenue. A point estimate — event count times average unit price — extrapolates 26 months of cumulative revenue from a 98-day sample while looking pinned down to the decimal, which manufactures false precision. I modeled ARPE as Normal and the paying ratio as Beta, sampled 10,000 times, and took the 2.5/50/97.5 percentiles (script/monte-carlo-revenue.js:93-100, :239-242, :279-284). The fact that the top of the interval is 3.5x the bottom is itself an honest statement of how much this estimate can be trusted.
I observed the double counting instead of assuming a constant for it. A donation goal fires on the same single donation as other revenue features, so summing them naively double-counts. I observed a 22.2% session-level intersection and subtracted it, falling back to a 0.5 default only when the data is missing — where I don't know, I cut revenue conservatively in half (script/monte-carlo-revenue.js:166-176).
5. Quantified Impact
5.0 Two observation windows, and what I did before trusting any metric
Every figure comes from one of the two sources below, and coverage differs completely depending on which one.
| Source | Period | Days | What it is |
|---|---|---|---|
daily_summary (unified VIEW) |
2024-07-07 to 2026-08-05 | 759 (758 with activity) | GA4 Data API backfill merged with Daily Export. Aggregated rows by date, event, country, device, and OS dimension |
advanced_events (raw VIEW) |
2026-05-01 to 2026-08-06 | 98 | Raw GA4 Daily Export. Carries session identifiers, user identifiers, and the price parameter |
Session-level and price-level analysis — retention, session depth, conversion, revenue — is only possible inside the 98-day window. That constraint is the source of nearly every limitation in this section.
I did not verify that the two sources define a session the same way. The earlier stretch of the unified VIEW comes from GA4 Data API aggregates (values that have been through modeling and thresholding); the later stretch is a count of session_start from Daily Export. The meaning of a session may shift at the seam, and I have never pulled both sources over an overlapping range to measure the difference. The cumulative 55,193 sessions in §5.1 carries this unverified item.
Four aggregation defects I fixed (2026-08-06)
To write this document I reviewed every dashboard query, found four defects, fixed them, and regenerated the output. Every figure in this section is a post-fix value.
| # | Symptom | Root cause | before → after |
|---|---|---|---|
| 1 | Teleprompter events leaked into the PRO checkout funnel | In BigQuery LIKE, an underscore is a single-character wildcard |
7 rows → 4 rows |
| 2 | Retention collapsed the further out the week | Using week-one active users as the common denominator for every week right-censored the result | W14 8.2% → 38.4% |
| 3 | Live captions bot activation → core action conversion of 842.3% | Opens and core actions were counted separately, with no session-level intersection | 842.3% → 96.6% |
| 4 | Opened-but-unused sessions always came out at 0 | The core-action count included non-bot events such as session start, so the numerator degenerated | 0 of 9,305 sessions → 23 of 9,124 sessions |
The third defect is the worst of the four in character. The 842% was rendering on the dashboard as-is, and a ratio above 100% is itself a confession that the definition is wrong — yet it sat there. The second runs the other way: W14 at 8.2% does not look like a usable retention rate, so it could have pushed a product decision in the wrong direction. The real value is 38.4%.
Five known limitations I did not fix
These come from instrumentation gaps or from too short an observation period, so no query fix resolves them. I state each one alongside the metric it affects, every time I cite it.
| id | Detail | Metrics affected |
|---|---|---|
dau-dimension-double-count |
daily_summary stores one row per dimension, so summing active users double-counts across dimension combinations |
DAU, cumulative DAU-days |
sr-price-not-instrumented |
Of 33,077 song-request queue adds, only 1 (0.003%) carried a price | The SR contribution to the revenue estimate |
donationgoal-degenerate-prior |
A donation-goal contribution event only fires when there is a contribution, so a paid ratio of 1.0 is structurally true. The Beta(1013,1) prior drives uncertainty to near zero | Width of the revenue estimate interval |
os-windows-only |
No macOS/Linux sessions observed in the last 30 days. macOS support code and release artifacts do exist | OS distribution |
revenue-backcast |
The price parameter has only been observed for the 98 days since 2026-05-01. Of 26 months, setting aside the 4 observed months (2026-05 to 08), 22 months (2024-07 to 2026-04) are a backcast, and it does not account for ARPE drift over time | The entire cumulative revenue estimate |
Four more values corrected in the aggregation script
When I recomputed the metrics from the raw query results, four disagreed with what the aggregation script produced. I fixed the script and regenerated, so the right-hand values below are the ones this section uses. I show the left-hand values because two of the four were not rounding-notation issues but problems in how the metric was defined.
| Metric | Previous | Corrected | What was wrong |
|---|---|---|---|
| Median receipts per streamer | ₩183,640 | ₩130,220 | n=44 is even, so the median is the mean of the 22nd and 23rd values. 183,640 is the upper median and does not satisfy the definition of a median. The max-to-median ratio changes too: 43.9× → 61.9× |
| P90 interactions per session | 1,357 | 1,356.6 | Percentile computed by linear interpolation |
| Opened-but-unused session rate | 0.3% | 0.25% | 23/9,124. Below 1% I write two significant figures |
| 30-day errors per session | 0.2% | 0.16% | 5/3,128. Same reason |
The first two came from recomputing over the raw session array and receipts array, and those arrays contain identifiers, so they are not included in this document. In other words, these two corrections cannot be reproduced from this document alone.
5.1 Scale and growth
| Metric | Value |
|---|---|
| Cumulative sessions | 55,193 |
| Cumulative DAU-days | 45,984 |
| Peak DAU | 117 (2026-02-13) |
| Latest DAU (2026-08-05) | 99 |
| Mean DAU, last 30 days | 90.6 |
| Mean DAU, prior 30 days | 90.1 |
| Mean DAU, same period last year | 75.6 |
| Observation period | 759 days (758 with activity) |
Monthly session trend:
2024-07 250 |=
2024-12 775 |===
2025-03 2,124 |========
2025-06 2,857 |===========
2025-09 2,704 |==========
2025-12 2,393 |=========
2026-03 3,568 |==============
2026-05 3,380 |=============
2026-07 3,291 |=============
New vs. returning over the last 30 days: 92 user-days new, 2,625 user-days returning, a returning share of 96.6% on a user-days basis. The unit is user-days, not people — the total of 2,717 is the same order of magnitude as mean DAU 90.6 × 30 days, and a different order of magnitude from the unique user count over the same window (see the 749 unique users in the 98-day window, §5.5).
What this means: over the first 12 months (2024-07 to 2025-06), sessions grew from 250 to 2,857. But the base month, 2024-07, is a partial month covering only the three weeks after the first release (v0.0.0, 2024-07-09), so I don't use that multiple as a growth rate. Taking the first complete month, 2024-08 (473 sessions), as the base gives 6.0× over the 11 months to 2025-06. The 13 complete months that follow (2025-07 to 2026-07) oscillate between 2,393 and 3,568 — that is a plateau, not a growth curve. I excluded 2026-08 from that range because it is a 5-day partial month (536 sessions). Year over year, DAU went 75.6 → 90.6, or +19.8%, but against the prior 30 days it is +0.6% — effectively flat.
The 96.6% returning share supports the reading that whoever stays keeps using the app. But this metric cannot tell you the absolute volume of new arrivals — 92 is user-days, not people, and the current instrumentation does not produce a new/existing split on a unique-user basis. All I can say is the direction: the current scale rests on retention of existing users, not on new acquisition.
Limitations:
- DAU is an upper bound.
daily_summarystores one row per dimension, so summing active users double-counts across dimension combinations. Don't read the absolute level as "117 concurrent users"; use it only for trend comparisons between values computed the same way. The year-over-year and prior-30-day comparisons above satisfy that condition. - The cumulative 55,193 sessions suffers less from double-counting than DAU does, but it sits on the same dimensional structure, so it is not fully free of it. The unverified source definition from §5.0 — I never confirmed that the backfill stretch and the Export stretch define a session identically — attaches here as well.
- The early stretch (2024-07) contains negative returning-user rows. That is a GA4 aggregation characteristic, where new users exceed active users as counted by session start.
5.2 Retention — how long users stay
Definition: the share of cohort users who were active at least once in that week. It is not "still active through that week", so it does not decrease monotonically. The denominator is the part of the cohort actually observed that far out (script/generate-dashboard.js:233-271).
| Week | Cohort | Active | Retention |
|---|---|---|---|
| W0 | 746 | 746 | 100% |
| W1 | 732 | 336 | 45.9% |
| W2 | 701 | 274 | 39.1% |
| W4 | 652 | 239 | 36.7% |
| W8 | 499 | 161 | 32.3% |
| W9 | 469 | 149 | 31.8% |
| W12 | 321 | 121 | 37.7% |
| W13 | 254 | 98 | 38.6% |
| W14 | 159 | 61 | 38.4% |
Session quality:
- P90 session length 712 min (11 h 52 m) — over sessions longer than 5 minutes
- Interactions per session: P50 357, P90 1,356.6, max 53,552
- 10,002 rows analyzed (98-day window). The query has no
LIMIT, but the result lands close to 10,000, and I did not check whether it hit a ceiling, so I don't claim this equals every unique session in the 98-day window.
What this means: retention falls from 45.9% at W1 to 31.8% at W9, then flattens between 31.8% and 38.6% across W8–W14. Once the early churn is over, the remaining cohort stops shrinking. The rise at W12–W14 is not a sign of improvement — read it as noise from a shrinking sample. W14's denominator is only 159 users, and the longer a cohort runs, the more a selection effect leaves only users who were active from the start.
A P90 session length of 712 minutes suggests this is the kind of tool that stays on for a whole stream. But that value is the top-10% point of the session distribution, not the top 10% of users — if one person produces many long sessions, that person alone can fill this band. Nor should it be converted into stream hours (see the limitations below).
Limitations:
- The furthest observable week is W14. The raw data starts at 2026-05-01, so W15 and beyond cannot exist at all. I can say "38% retention at three months" and no further; there is no data for 6-month or 12-month retention.
- Later weeks have smaller samples. W14's cohort is only 159 users (21% of W0), so the standard error on 38.4% is far larger than in the early weeks. Fixing the right-censoring bought accuracy at the cost of statistical stability.
- The P90 session length is a conditional distribution, covering only sessions longer than 5 minutes.
- Session boundaries follow GA4's 30-minutes-of-inactivity definition. If the app keeps sending events while live, the session continues, so the equation "712 minutes = time spent live" does not hold.
5.3 Feature adoption — which of the 11 bots actually get used
Share of sessions that opened a bot and then reached a real core action within the same session (last 30 days):
| Bot | Sessions opened | Sessions reaching a core action | Conversion |
|---|---|---|---|
| prompter (teleprompter) | 21 | 21 | 100% |
| sync (remote overlay) | 27 | 27 | 100% |
| subtitle (live captions) | 326 | 315 | 96.6% |
| speech (TTS) | 2,047 | 1,728 | 84.4% |
| autoreply (auto-reply) | 1,623 | 848 | 52.2% |
| tits (object-throwing) | 547 | 254 | 46.4% |
| sr (song request) | 1,281 | 453 | 35.4% |
| donationgoal (donation goal) | 1,095 | 79 | 7.2% |
| roulette | 1,142 | 64 | 5.6% |
| raffle | 1,198 | 30 | 2.5% |
| vote (poll) | 1,250 | 22 | 1.8% |
Cumulative core actions (full period; the unit is the number of session-days on which that event occurred):
speech_speak 33,886
autoreply_replied 16,261
sr_queueAdd 14,247
tits_trigger 7,685
roulette_roll 858
subtitle_translate 396
vote_start 127
prompter_activate 123
donationgoal_success 14
Pulling 30-day per-bot usage from the unified VIEW gives a different ordering. The definition is the daily maximum session count, summed over 30 days (script/generate-dashboard.js:370-382).
| Bot | Sum of daily max sessions (30 days, unified VIEW) |
|---|---|
| subtitle (live captions) | 2,482 |
| speech (TTS) | 2,356 |
| autoreply (auto-reply) | 1,646 |
| sr (song request) | 1,287 |
| vote (poll) | 1,259 |
| raffle | 1,205 |
| roulette | 1,147 |
| donationgoal (donation goal) | 1,113 |
| tits (object-throwing) | 556 |
| sync (remote overlay) | 27 |
The previous table has 11 rows; this one has 10. The query carries a LIMIT 10 (script/generate-dashboard.js:381), which cut off prompter as the smallest — the data is not missing.
Opened-but-unused sessions (98-day window, 2026-05-01 to 08-06): of the 9,124 sessions that opened at least one bot, 23 (0.25%) never reached a core action on any bot.
What this means: by conversion, the 11 bots split into three groups.
- Always-on (84–100%): subtitle, speech, sync, prompter. Turn them on and they run by themselves.
- Middle band (35–52%): autoreply, tits, sr. Left running, they act when a condition is met.
- On-demand (1.8–7.2%): vote, raffle, roulette, donationgoal. 1,250 sessions opened the poll bot; 22 actually started a poll.
The low conversion on on-demand bots has two readings, and the data alone cannot separate them. (a) Streamers switch them on out of habit at the start of a stream and use them only at the moment they are needed — here low conversion is normal, and in fact means "it is already ready when needed". (b) Streamers open one, cannot figure out how to use it, and give up — that is an onboarding defect. Settling this requires the distribution of time from opening a bot to its first core action, which the current instrumentation does not capture.
The 0.25% opened-but-unused figure cannot settle it either. That metric counts sessions where no bot reached a core action, so whenever speech (84.4%) or subtitle (96.6%) is open in the same session it converges to near zero almost every time — it gives no independent information about whether someone who opened an on-demand bot knew how to use it. The observation windows differ as well (98 days for opened-but-unused, 30 days for the conversion table above). I leave the question open as an instrumentation gap.
Limitations:
- The two tables above come from different sources, so don't divide one by the other row-wise. The conversion table's denominator is the unique session count from the raw VIEW (326 for subtitle); the usage table is the sum of daily max sessions from the unified VIEW (2,482 for subtitle). Subtitle reading 326 against 2,482 is a difference in aggregation unit, not in session definition.
- The unit for cumulative core actions is session-days, not event counts. One heavy user pressing it 500 times in a day still counts as 1. Read it as the number of streams on which the feature was actually used, not as absolute call volume.
- In the conversion table, "open" means something different per bot (for subtitle it is starting recognition; for vote it is opening the panel). I don't use it to compare bots against each other, only to compare one bot over time.
- The 100% conversion on prompter (21 sessions) and sync (27 sessions) rests on samples too small to trust to that precision.
- The conversion figures use a 30-day window, so they do not absorb seasonality (peak streaming periods).
5.4 Reliability and operations
Runtime errors (last 30 days: 2026-07-07 to 2026-08-05)
| Metric | Value |
|---|---|
| Total error events | 5 |
| Total sessions | 3,128 (unified VIEW) |
| Errors per session | 0.16% (5 / 3,128) |
| Days with zero errors | 26 of 30 |
All 5 errors are the same single type, chzzk_loginFail (1 on 2026-07-12, 2 on 07-18, 1 on 07-28, 1 on 08-05). So over the last 30 days there were zero explicitly logged bot-logic failures, and every observed failure sits on the external platform's login path. Across the full 98-day window, 19 of 10,002 rows (0.19%) are sessions that had an error, and 0 of those sessions clicked through to the guidelines.
Limitations:
- Zero is a function of instrumentation coverage, not a reliability achievement. The error rate counts only explicitly logged failure events — the issue is not a small denominator, it is that the numerator's coverage depends on a convention. The convention that a new bot must explicitly log its failure events is written into the skill documents, but compliance has not been verified, and there is no telemetry collecting unhandled exceptions or crashes. The fact that 3,128 sessions produced zero errors outside login is equally well explained by "logging effectively exists only on the login path".
- With a sample of 5, I don't compute whether users churn after an error.
- The 3,128 total sessions come from the unified VIEW. The 2,821 sessions behind the OS distribution in §2.3 come from the raw VIEW, so the same 30-day window differs by 307 sessions, and I did not track down the cause.
Release cadence
| Metric | Value |
|---|---|
| Total tags | 82 (v0.0.0 2024-07-09 to v1.2.13 2026-07-28) |
| Tags with a published GitHub Release | 72 (82 = 72 published + 1 draft only + 9 with no release, per gh release list --json tagName,isDraft) |
| Span | 749 days |
| Mean interval between tags | 9.2 days |
| Median interval between tags | 2 days |
| Longest gap | 111 days |
| Follow-up tags within a day | 40 of 81 intervals |
| Releases in the last 6 months | 29 |
| By year | 28 in 2024 / 21 in 2025 / 33 in 2026 |
The gap between a 9.2-day mean and a 2-day median is a hotfix pattern. 40 of the 81 intervals are under a day, so half the releases are follow-up fixes to the release right before them. The monthly distribution of the 1,196 commits is just as lopsided — 177 land in 2026-04, and five months in 2025 have zero. Development runs as bursts of concentrated sprints alternating with complete stops, which is what happens in a solo project when the day job, personal condition, and platform issues print straight onto the commit graph.
Limitation: 10 of the 82 tags (9 with no release, 1 draft only) have no published release, so the reference count when talking about cadence is 72, not 82. Whether those 72 published releases actually reached user machines — that is, whether the updater manifest shipped — I did not separately confirm. Stretches with 3–4 tags on the same day may be workflow-debugging tags.
Manual work that automation removed
| Automation | Command | If done by hand |
|---|---|---|
| Rust↔TS bindings generation | npm run gen:bindings |
8 steps. Prefix module paths onto 28 commands and 20 types; create, run, and delete a temporary binary. Output is 606 lines |
| Overlay sync | npm run sync:overlays |
21 steps (7 files × 3 operations). 4 minify options plus a path-flattening copy |
| Code-signing certificate renewal | npm run gen:cert |
7 steps. Generate the certificate → export PFX/base64 → update the config file → apply 2 CI secrets → record the expiry date. Valid for one year, so it repeats annually |
| Analytics dashboard generation | npm run dashboard |
28 steps. Run 26 BigQuery queries individually, plus Monte Carlo, plus HTML injection |
| Version bump | npm run bump |
3 steps. Edit three files without letting them drift out of sync |
The release pipeline has no manual substitute at all. The development workstation is Windows-only, so there is no way to produce macOS and Linux artifacts locally.
I don't claim any hours saved. I never measured the manual time before automation, and since releases cannot be performed locally in the first place, there is no baseline to multiply "time removed" against. What this table says is not time but only the split between what has no substitute (4-platform builds) and what does (bindings, overlay sync, dashboard).
Analytics infrastructure cost
| Service | Free tier | Cost |
|---|---|---|
| BigQuery storage | 10 GiB active storage (always) | $0 |
| BigQuery queries | 1 TB/month | $0 |
| GA4 Daily Export | 1M events/day (export stops when exceeded) | $0 |
759 days of events and 98 days of raw session data run inside these limits, and the bill is $0. I have not measured actual usage, so I don't state what percentage of the limits this consumes.
Limitations:
- This table covers analytics infrastructure only. It excludes the API server (a single Railway instance), storage and egress on the public GCS bucket, and the domain, and this document does not compute a combined monthly operating cost. So don't read this $0 as "the product costs nothing to operate".
- If GA4 Daily Export exceeds its ceiling of 1M events per day, the export stops silently. The raw VIEW is the only source for this section's session and revenue analysis, so the metrics would cut off at that point.
Structural constraints created by the server choice
The API server is a single Railway instance, and the constraints that choice creates show up directly in the design — an 18-connection SSE cap per channel, a 14-minute reconnect instruction to stay inside Railway's 15-minute hard timeout, and a 45-second heartbeat against Cloudflare's 100-second idle timeout.
Limitation: state straddles an in-memory session map and a persistent volume, so horizontal scaling breaks both at once. At the current scale (DAU around 90) no problem has been observed, but there is no open path to scale. It is the first item on the §8 roadmap.
5.5 Monetization
There are two separate stories here: PRO subscriptions (vzzk's own revenue) and donations observed on streams where vzzk was running (the streamers' revenue). They must not be mixed.
PRO subscription funnel — vzzk's own revenue
| Stage | Events | Conversion |
|---|---|---|
pro_dialogOpen |
697 | — |
pro_checkout |
158 | 22.7% of opens |
pro_success |
11 | 7.0% of checkout attempts / 1.6% of opens |
Entry paths: banner 253 / badge 88 / unlabeled 97. On a per-user basis, 9 of the 749 users in the 98-day window converted (1.2%).
So what does vzzk actually earn
PRO is not a separate payment system; it is gated on a Chzzk channel subscription. A subscriber is a PRO user, and the revenue arrives as a platform payout. PRO launched in 2026-06, so there are two months of payout records.
| Month | Payout |
|---|---|
| 2026-06 | ₩89,341 |
| 2026-07 | ₩89,341 |
The figure reconstructed from assumptions and the actual payout do not match. Roughly 24 subscribers × a Chzzk tier-1 channel subscription at ₩4,900/month × a 70% payout share comes to ₩82,320, while the actual payout is ₩89,341 — about 8.5% apart. Working backward from the payout under those same assumptions gives 26 subscribers (₩89,341 ÷ ₩3,430 = 26.05). I have not established which side is wrong — at least one of the subscription tier mix, the VAT treatment, or the exact subscriber count differs from my assumptions. Until I check the payout statement, I treat only the ₩89,341 payout as fact and label the subscriber count and payout share as assumptions.
The two months matching down to the won means the subscriber mix held steady — and equally that there was no net growth over that period.
Subscriber data is the only value in this document that lives outside the analytics pipeline. Every other figure can be reproduced from a dashboard query or from the repository, but PRO entitlement is checked by the server asking Chzzk (the Pro module in vzzk-api), so the subscriber count is not data vzzk holds. The payout is an actual settlement record and therefore close to an observation, but its internal composition is not visible from vzzk.
This also explains the mismatch between 11 pro_success events and roughly 20-odd subscribers. When someone subscribes directly on the Chzzk channel page instead of going through the in-app checkout dialog, no event reaches vzzk. So the 11 are a subset of all subscriptions, and the "7.0% checkout completion" in the §5.5 funnel is a figure limited to the in-app path. There is no instrumentation to confirm this hypothesis (§8.4).
The three amounts side by side
| Amount | What it is | |
|---|---|---|
| Streamer donations observed by vzzk (98 days) | ₩30,027,318 (~US$21,400, at an assumed rate of ₩1,400/USD) | Someone else's money. Not vzzk revenue, and causation cannot be established |
| vzzk's own monthly payout | ₩89,341 (~US$64) | My money. An actual payout, identical across 2 months |
| Monthly infrastructure cost | About ₩7,000 | API server at $5/month (₩1,400/USD assumed). Analytics infrastructure stays inside the free tiers |
Monthly net profit is about ₩82,000, annualized revenue about ₩1,072,000. But I have not measured the domain, the storage and egress of the object storage that holds release artifacts, or Firebase RTDB usage. They are likely inside free tiers, but I have not confirmed that, so the net profit above should be read as an upper bound.
This contrast states the product's current position more precisely than anything else. vzzk sits on top of streams through which ₩30M-plus in donations moved over 98 days, and on top of that it earns something in the ₩80,000s per month. It is profitable, but profitable only because not one won of labor cost enters the calculation. The gap between the technical structure that carries this scale and a revenue structure that could sustain it is this project's largest unsolved problem (§8.5).
What this means: the bottleneck is the step from checkout attempt to completion. 158 reached the checkout screen and only 11 completed. 7% is low enough to suspect a checkout UX or payment-method problem.
Limitations:
- The data alone cannot distinguish "conversion is low" from "completion events are being dropped". Whether the success event fires exactly at the moment payment completes has itself not been verified.
- Comparing the effect of each entry path is impossible because of two layers of gaps. Of the 438 events that carry a path row, 22.1% (97) are unlabeled; the bigger problem is that only 438 of the 697
pro_dialogOpenevents carry a path row at all — measured against dialogOpen, 51.1% (356/697) have an unknown path. Some call sites are not attaching the path parameter.
Streamer donation receipts — the observed 98 days
The amounts here are not vzzk's revenue. Donations happen on Chzzk and arrive at the app as stream events; vzzk reads the price carried on those events and triggers bots from it (src/app/service/tits.service.ts:94). So the values below are the sum of price on donation events observed on streams where vzzk was running, and this data cannot settle whether vzzk's presence caused those donations.
| Metric | Value |
|---|---|
| Streamers with observed donation events | 44 |
Sum of donation price observed over 98 days |
₩30,027,318 |
| Top-1 share | 26.8% |
| Top-5 share | 70.8% |
| Top-10 share | 86.4% |
| Largest receipts by a single streamer | ₩8,057,155 |
| Median | ₩130,220 (n=44 is even, so the mean of the 22nd and 23rd) |
| P90 receipts across paid sessions | ₩102,320 |
Label correction: the field in the source data was named supporter, but every GA4 event is emitted from the streamer's desktop app (there is exactly one instrumentation point,
src/app/service/analytics.service.ts, and I confirmed that none of the 10 overlay HTML files calls an analytics tag). So this distribution is donations received per streamer, not per supporter. I found and corrected this while writing this document.
Mean receipts per session broken out by feature — the per-session total of donations that triggered that bot: object-throwing ₩54,843 (458 sessions), roulette ₩18,277 (74 sessions), donation goal ₩14,223 (250 sessions), song request ₩1,000 (1 session).
What this means: the distribution is extremely concentrated. The top 5 take 70.8% of the observed total, and the maximum is 61.9× the median (8,057,155 / 130,220). This concentration is not a product problem; it mirrors the structure of the live-streaming market.
I attach no denominator to these 44, for the same reason as in §2.2 — dividing by the 749 users the PRO conversion query observed yields 5.9%, but the two values come from different queries and I never confirmed they cover the same population (the retention cohort over the same 98-day window is 746, different again). I don't use the ratio.
The product implication has to be written with the two kinds of revenue kept apart. What collapses if these top 44 leave is the observed donation receipts, which is not vzzk's revenue but an exposure-and-dependency metric showing how large the streams are that the product sits on. vzzk's own revenue is the ₩89,341 monthly payout (identical for 2026-06 and 07), and because subscriber-level data is not in the analytics pipeline, I cannot compute concentration or churn on it. I can observe the concentration in donation receipts, where the top 5 take 70.8% — yet I have no means of observing the concentration of my own revenue.
Cumulative donation estimate — Monte Carlo
10,000 simulations, 2024-07 to 2026-08 (26 months cumulative):
| Amount | |
|---|---|
| Sampling-uncertainty interval, lower bound (2.5th) | ₩115,553,946 |
| Median (50th) | ₩254,493,960 |
| Sampling-uncertainty interval, upper bound (97.5th) | ₩400,822,458 |
This estimate should be read as "somewhere between ₩116M and ₩401M", not as "about ₩250M". The top is 3.5× the bottom.
I don't call this interval a "95% CI". The simulation assigns distributions to exactly two quantities: ARPE and the paid ratio. The dominant error source in this estimate is limitation 1 below — the assumption that the 98-day ARPE and paid ratio apply unchanged to the preceding 22 months — and no distribution was assigned to that. So the interval above is an interval on sampling uncertainty, not an interval on the estimate as a whole.
Every assumption, stated: ARPE is Normal (clamped against negatives), the paid ratio is Beta (uniform prior), event-loss correction ×1.03, donation-goal double-count deduction ×(1−0.222), interval taken at the 2.5th–97.5th percentiles. The per-feature anchor statistics are object-throwing mean ₩2,410 with a 50.1% paid ratio, donation goal ₩3,510 and 100%, roulette ₩2,247 and 23.7%, song request ₩1,000 and 0.003%. Assuming a Normal for ARPE is not conservative — I put a symmetric distribution on a right-skewed one where a single streamer holds 26.8%, and the zero clamp pushes the mean up. A log-normal or a bootstrap resample fits this shape better, but I did not apply either.
Internal consistency check: inside the observation window (2026-05 to 08), the model's revenue for that stretch is ₩30,077,088 against a directly observed price sum of ₩30,027,318 — a difference of ₩49,770 (0.17%). But this check is partly circular — ARPE and the paid ratio were estimated from that very window, so it is not independent verification. The two correction factors also cancel each other inside the observation window: ×1.03 applies to every term, while the double-count deduction applies only to the donation-goal term (script/monte-carlo-revenue.js:259-264), and donation goal is about 12% of the observed total, so the net effect is under 1%. In short, this check confirms only that the arithmetic reconstruction holds; it guarantees nothing about whether the correction factors are valid.
Limitations — state these whenever this estimate is cited:
- The 22 months before 2026-05 (2024-07 to 2026-04) are a backcast. Of the 26 months, price was actually observed in only 4 (2026-05 to 08); for the rest I applied the last 98 days' paid ratio and ARPE directly to each past month's event counts. That rests on an assumption that donation culture, prices, and the feature mix did not change over two years, and that assumption has not been verified.
- The song-request contribution cannot be trusted. 1 of 33,077 events carries a price. Tracing the call sites: of the two emit points, one hard-codes the price to 0 and the other defaults it to 0, and only one path actually passes a price through. This could be a call-site defect, or there could genuinely be almost no paid song requests, so I don't assert a cause. Either way, this term enters the revenue model as effectively zero.
- The donation-goal prior is degenerate. A paid ratio of 1.0 is structurally true, so Beta(1013,1) drives the uncertainty to near zero, and this term's contribution to the interval width above comes out narrower than it really is.
- Because monthly event counts come from the unified VIEW, the dimension double-counting problem from §5.1 partly propagates here.
- PRO revenue is not included in this estimate. Every amount above is donations received by streamers, not vzzk revenue.
- Only Windows was observed for OS. macOS support code and release artifacts do exist. Whether this means no real users or an instrumentation problem, I cannot say — I only say it has not been observed.
6. Incidents and Structural Fixes — Case Studies
In all three cases I replaced "a fix that covers the symptom" with "a structure in which that class of problem cannot come back." The last section of each case also records the risk that is still open.
Case 1 — Four symmetric secrets shipped inside the distributed binary
What happened
In 2026-07, a personal-data breach at an outside service prompted me to run a security audit of my own. A server-side commit message records that trigger — it states that the audit findings which could be fully closed by API-server changes alone would be applied first (vzzk-api 6796723, 2026-07-28). The items the server could close by itself (OAuth redirect allowlist, state nonce, global validation pipe, moving viewer sessions to AES-256-GCM) shipped first, and the commit explicitly noted that items requiring a client release were outside its scope. That remainder is vzzk 1dfaeec.
The central finding was that the distributed binary contained four symmetric secrets.
Root cause
A symmetric secret only works if both sides hold the same value. Once one side sits on a user's PC, the value belongs to the user, and one run of strings extracts it. Two consequences followed necessarily.
- The Pro signing key was its own verification key, so a user could sign their own Pro entitlement.
- The static API key was meant to prove "is this our app?" — a question the client cannot answer. As a result the push endpoint took the target channel from a request header, and anyone holding the key could write to any streamer's on-air screen.
What I changed
Instead of patching symptom by symptom, I fixed the principle in a sentence first — "no symmetric secret goes into a distributed client binary. What the client gets is the ability to verify, nothing more."
- Login: moved to a PKCE exchange code (sequence in §3.3). Deep-link validation went from checking only the scheme to checking host, state, expiry, and single-use consumption.
- Pro entitlement: HMAC → Ed25519. The app carries only the public-key constant; the private key lives only in a server environment variable. The key-generation script writes the private key to a file and never prints it to the screen (
script/generate-pro-keypair.mjs:1-13). - Push target resolution: inverted so that the server derives the channel from the Bearer token rather than reading a header.
- Token storage: moved to the OS credential store, with no fallback to a plaintext file on failure.
- Local overlay server: origin allowlist + Host loopback check (blocking DNS rebinding) + a per-process control token on state-changing routes. The read routes OBS opens were left as they were so that URLs users had already saved don't break.
Recurrence and remaining risk
These defenses are verified only by Rust tests, and CI was running the web tests only. The entire security surface sat in a CI blind spot. So the same day I added Rust tests and the overlay lint to PR CI (9bd8f3c), and — since this repository can't have branch protection enabled — made the release job depend on the tests via needs (650f79f).
This is not closed yet. The server still carries paths L1–L5 for older app versions, and the Pro status response still returns an HMAC signature alongside the Ed25519 one. A legacy path is not "a less safe alternative"; it is a bypass the attacker gets to choose. So instead of deleting them on instinct, the server aggregates a per-path call counter and last-seen timestamp, and I opened an observation window right after the new release with 14 consecutive days at 0 as the removal condition.
The 14 has a reason attached to it as well — users who aren't streaming send no pushes, so you need at least two weeks to catch users who stream only on a weekly rhythm.
And the design itself turned up one more defect. The counters lived in process memory, so every Railway redeploy reset the observation start to boot time — which means you read "zero since restart" as "zero for two weeks." The commit body names that failure exactly: I would have been reading "zero since the last restart" while believing it said "zero for two weeks." The commit that built the observation instrument and the commit that found it wasn't measuring anything are 51 minutes apart. This is a case of verifying the instrument before acting on it.
The fix had four parts. Persist the counters to a volume; write through immediately on the 0→1 transition (that signal flips the verdict, so it must not be lost); coalesce later increments over 30 seconds (it's a high-frequency path); write atomically via temp-file rename (a truncated JSON file loses the whole observation window); and restart the window on a corrupted file instead of partially recovering it (a missing value is something you can notice, a distorted one is not).
I also didn't pretend to measure what can't be measured. There is no separate counter on the Pro HMAC path — the server only returns the response and can't tell which signature was verified. Instead I documented the indirect inference: if the login-path counter is 0, older apps can't log in at all, so those paths get deleted together.
Remaining risk: the remote overlay read path is unauthenticated — a third party who knows the channel ID can pull live caption text, the song-request queue, and participant nicknames in real time (adding a capability token breaks the URL the user saved in OBS; §8.1). And there is no server-side token revocation API.
Case 2 — Overlay HTML with no static-check path → a self-built lint gate
What happened
The overlays are 10 standalone HTML files (7 at top level + 3 under private/), and all the logic lives in inline scripts. Unlike the Angular app, they go through no bundler, no type checker, and no unit test. The first execution is the moment an OBS browser source opens the file.
I introduced the lint on 2026-04-26 while building the teleprompter, and its first run caught a TDZ violation. The prompter.html diff in commit 451d2f0 is that fix.
const noteInput = document.getElementById('note-input');
+ let socket;
+
if (!channelId || !token) {
showError('유효하지 않은 링크입니다. VZZK 앱에서 다시 열어주세요.');
} else {
initSocket();
}
- let socket;
-
function initSocket() {
initSocket is a function declaration and hoists, but socket is a let and sits in the TDZ. The fix moves the declaration above the call. As written, opening the teleprompter with a valid link failed every time with an access-before-initialization error.
This defect never reached a user. The lint caught it on its first run, and nowhere in this document is there a production overlay incident. The "failure while on air" discussed below is not an event that happened; it is the risk this structure allowed.
Root cause
It followed from the structure. Overlay JS had no static-check path at all, so every error that parses syntactically survives to runtime. And the overlay's runtime isn't a developer's screen — it's an OBS scene that is on air. If a defect reaches a user, the moment of discovery is the moment of the incident — that is the risk this case is about, and it has never materialized.
Two deployment paths made it worse. The overlay sources go into the install package unchanged as Tauri resources (src-tauri/tauri.conf.json:50), and the same sources are also copied as-is to vzzk-api (script/sync-overlays.js:26-33). Neither deployment path has a step that looks at the code.
What I changed
The immediate fix was one line moving a declaration up, but along with it I wrote script/lint-overlays.js. It pulls script blocks out of the HTML with a regular expression, parses them with Acorn preserving line offsets, and runs the rules with acorn-walk. Any error exits with code 1.
no-use-before-define: collects the positions of top-levellet/constdeclarations and reports when a hoisted function is called ahead of one of them and references that variable (:148-183). Function-local declarations and parameter shadowing are excluded, which removes false positives (:216-274).no-unescaped-innerhtml(added during the 2026-07-28 security audit,d5fca67): checks through the AST whether template-literal interpolations in aninnerHTMLassignment pass through an escape function (:396-437). The only exemption is an expression that can be conservatively proven to evaluate to a number.- Treats a parse failure itself as an error.
Both rules have off-the-shelf equivalents. no-use-before-define is an ESLint core rule, extracting inline scripts from HTML with line-offset correction is what the eslint-plugin-html family does, and innerHTML checking is eslint-plugin-no-unsanitized territory. I did not evaluate whether that combination could replace this. This repository has no ESLint config and no ESLint dependency (acorn-walk is the only parser-family dev dependency in package.json), and the TypeScript side has no lint at all — only a formatter (Prettier). Writing my own was a choice not to add dependencies, and the cost is the uncalled rule under "remaining risk" below.
The reason an XSS rule is needed lies in the structure. The values an overlay renders (song titles, requester nicknames, poll options) are controlled by viewers, and the same HTML runs both as a local overlay and on the same origin as the viewer session cookie. A single missing escape leads to both stream-screen tampering and session theft. So I pulled the private/ subtree, which had been left out of the check, into scope — the files that had been missing were exactly the ones running on the viewer origin.
Blocking every position where safety can't be statically proven would make the tool unusable, so exceptions are declared in a comment together with a reason (:444-462). There are only 3 exceptions today, each with a stated reason. Instead of "block it completely," I chose "the exception stays visible in code review."
Recurrence and remaining risk
The lint is the first stage of npm test (package.json:23), runs in both PR CI and the release workflow, and the release build depends on the test job via needs. All 10 files are at 0 errors today. Top-level-scope TDZ and unescaped innerHTML can't get through even if a person forgets.
The order of the three stages has a reason too. The overlay lint finishes in under a second, and on failure it pinpoints the cause down to file:line. cargo test runs 176 tests and takes minutes including compilation; Karma runs 944 cases including browser startup. The cheapest check for the failure that surfaces latest (at runtime, while on air) goes first.
The remaining risk is the scope of the rules.
- The TDZ check looks at top-level scope only, so it can't catch TDZ inside a block scope within a function. That is the result of choosing zero false positives over accurate scope analysis, and the choice is stated in a code comment.
- The XSS rule only looks at the
innerHTML+ template-literal combination, so string concatenation,insertAdjacentHTML, anddocument.writepass. - The "undeclared variable access" check advertised in the file's header comment is never actually called. The function and the browser-global list are defined but never invoked from the lint body (defined at
:279-328, absent from the call site at:379-380). Doing it properly requires real scope tracking, and I didn't pay that cost. There is nothing gained here, and what is lost is a mismatch between the documentation and the behavior — I leave it in §8 as an unresolved item. - Judging the reason in an exception comment is still a human.
Case 3 — The dashboard already existed, and I still re-read the definitions before trusting the numbers
What happened
On 2026-08-06, pulling numbers for this portfolio, I opened the existing dashboard and saw a value that is impossible by definition. The live captions bot's activation → core action conversion rate was 842%. That metric cannot exceed 100% by definition. I stopped there, re-read every query definition, and found 4 defects (fix commit 1fb3322).
Root cause
The four are different in kind.
- LIKE wildcard: in BigQuery
LIKE, an underscore matches any single character. Bot event names follow abot_actionconvention, so the underscore is the prefix separator — and I picked an operator that treats that same character as a wildcard. The PRO prefix pattern swallowed teleprompter events. - Right-censoring: I used first-week active users as the common denominator for every week. Raw data starts on 2026-05-01, so for later weeks a cohort that hasn't been observed that long yet drops out of the numerator only. Survivorship bias looked like a retention cliff.
- No session intersection: opens and actions were counted separately. An always-on bot, turned on once and left running, produces actions with no open event inside a 30-day window. The denominator didn't contain the numerator, so there was no upper bound.
- Degenerate numerator: the action count included non-bot events such as session start, which made every session have at least one action.
What I changed
I changed the shape of the queries, not the values.
- Replaced every string prefix/suffix match with wildcard-free functions (
STARTS_WITH/ENDS_WITH). There is now no position where an underscore can be read as a wildcard. - For retention, I compute the maximum observable week first and restrict each week's denominator to the cohort actually observed for that many weeks (
:249-270). The chart normalization was fixed along with it. - Conversion decides open/action per session first and then counts sessions where both are true (
:306-338). The numerator becomes a subset of the denominator, so the ratio is structurally capped at 100%. - Opened-but-unused sessions count bot events only, and the denominator is restricted to "sessions that opened at least one bot."
Above each query I left a comment about why it has to be written that way, not about the value.
This wasn't the first such audit. The v1.2.6 rework in 2026-05 already handled 4 defects of the same family, and the results are still in the code — P90 instead of the mean, denominator alignment for the last 30 days, and excluding GA4 automatic events at the query level after they had been showing up as phantom bots.
Recurrence and remaining risk
The LIKE mismatch family is structurally impossible now, because the operator itself is gone. Ratio metrics were rebuilt at session granularity so that the numerator is a subset of the denominator; exceeding 100% can't happen, and reproducing that class of error would require reverting the query structure.
But there is no automated verification. A person re-read the definitions and caught these — not a lint, not a test — and the same will be true next time. There isn't even "an assertion that fails when a ratio metric exceeds 100%" yet; that is a §8 roadmap item.
And the limits I didn't fix are recorded as unfixed. That is the 5 known limitations in §5.0. The real output of this case is that data-quality issues weren't hidden but turned into an asset — a list named known_limits_not_fixed.
7. Development Methodology — How Agent Rule, Workflow, and Skill Definitions Scaled Solo Development
The subject of this chapter isn't "I used AI." It's what context structure I built to maintain 1,196 commits, 10 overlay HTML files, and 26 features in the feature map without a reviewer.
First, a scope note. The mechanism that loads and routes rules, workflows, and skills is implemented by the agent tooling. What I wrote is the 39 definition files layered on top of it (3 rules + 11 workflows + 25 skills), and "the definition layer" below refers to that bundle. I did not build the tool itself.
7.1 Why the definition layer is needed
Anthropic defines context engineering as "the set of strategies for curating and maintaining the optimal set of tokens during LLM inference" (https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents). The DORA 2025 State of AI-assisted Software Development report finds that 90% of respondents use AI at work and over 80% perceive a productivity gain, but that AI adoption correlates positively with delivery throughput and negatively with delivery stability (https://dora.dev/research/2025/dora-report/). The conclusion is that AI is an amplifier — it enlarges an organization's existing strengths and weaknesses alike.
In other words, what separates outcomes is not the tool itself but the design of the definition layer that writes rules, tests, and review gates into the repository. Rule-file formats have standardized in the direction of avoiding vendor lock-in; AGENTS.md has been adopted by more than 60,000 open-source projects (https://agents.md/).
vzzk's definition layer was designed on the premise that "the agent loses its memory every session." So only what must not be lost is always loaded, and I held that total down to 4,065 bytes (the three rule files combined).
7.2 Three always-loaded rules — a different trigger point means a different file
| Rule | Size | Failure it prevents | Trigger point |
|---|---|---|---|
01-mandatory-skill-reading.md |
1,987 B | The agent reimplements things its own way without knowing the existing architecture | Before work starts |
02-code-quality-invariants.md |
1,240 B | With no reviewer, the same mistake gets reintroduced every time | During work |
03-post-change-consistency.md |
838 B | The code gets fixed while specs, maps, and docs fall behind — drift | After work |
I didn't merge them into one, because a different trigger point also means a different moment at which a violation can be judged.
The invariants in ② aren't abstract coding rules; they're items this project actually paid for.
- No
Math.random()— the randomness entropy for the roulette and the raffle comes fromwindow.crypto.getRandomValues()(src/app/util/random.ts:2-3). That said, line 4 of that utility usesMath.random()to pick an index among 5 words. Every returned value is still CSPRNG output, so randomness quality doesn't degrade, but the rule isn't self-consistent inside its own utility. And because the result is computed on the client, the choice of entropy source does not guarantee the fairness of a raffle — a verifiable draw (commit-reveal or similar) is not implemented, and I leave it in §8.2. - NgZone wrapping — EventSource/WebSocket callbacks arrive outside the Angular zone and the UI doesn't update (
src/app/service/analytics.service.ts:102-109). - Environment guard — calling a Tauri API from a browser breaks initialization (
src/app/util/env.ts:32-33, referenced in 5 places in the code). - No
awaitinside a sync function — the race condition covered in §2.
None of these can be understood from the code alone, which is why they became always-loaded. The cost is that the reasoning itself doesn't stay resident — to question a rule you still have to open the skill.
And these four are conventions, not structural defenses. No automated check catches a violation — neither "no await inside a sync function" nor "no overlay push from a bot's onBeforeClose()" (§3.1) holds unless a person or an agent remembers it. Unlike the two rules the overlay lint checks, these items pass CI. I leave them in §8.2 as verification gaps.
7.3 Three-tier index routing — between reading everything and reading nothing
Keeping the bodies of all 25 skills resident leaves no context. So only "what to read" stays resident, and the bodies are lazy-loaded. It's the same structure as the progressive disclosure Anthropic describes — metadata, then body, then reference files, each read only when needed.
.agents/rules/*.md (always loaded — 3 rules)
.agent/AGENTS.md (index — 11 workflows / skill table / scripts, one-line descriptions only)
→ .agent/skills/best-practices/SKILL.md (tech · experience second-level router)
→ .agent/skills/<domain>/SKILL.md (25: overlay-protocol, sync-architecture, ...)
The cost of this structure is that the index falls behind, and it did — the skill table in .agent/AGENTS.md has 24 rows while the actual directory (.agent/skills/) has 25 (analytics-bigquery is missing). This drift happened even with rule ③ in place, and it shows exactly the limit of a checklist that isn't CI. I leave it in §8 as unresolved.
7.4 Two axes for cutting down what doesn't fit in one pass
| Tool | Cutting criterion | Method | Where the cut part is recorded |
|---|---|---|---|
| chunked-work | Output tokens | Splits into per-file units under .agent/tmp/<task>/ when the work exceeds 3 tables or 500 lines |
depends_on frontmatter |
| cross-feature-audit | Combinatorial explosion | A full cross of 26 features is 325 pairs × 5 dimensions = a 1,625-cell matrix. Narrowed by bidirectional traversal of impacts |
impacts in feature-map.json |
In both cases I gave up "complete" to get "executable," and recorded where the cut was made — in depends_on and in impacts respectively. In practice a Pro-related audit narrows from all 325 pairs to 28 (verified by running it).
The cost is clear. feature-map.json is a registry maintained by hand, so it doesn't sync with the code automatically. Miss an update and the audit scope gets a hole. That's why updating impacts is demanded redundantly in three places — rule ③, the audit skill, and the PR workflow. For chunked-work leftovers, I put --check-all into the PR workflow to make it the gate immediately before the PR is created.
7.5 Enforced by order, not by judgment
The /create-pr workflow is the single gate that checks whether all these conventions were actually followed. It runs overlay lint → vzzk-api sync → Rust tests → web tests → leftover-work check in a fixed order, and if even one of them fails it doesn't create the PR.
The failure it prevents is the agent deciding "the tests will probably pass" and skipping them. I removed the room for judgment and pinned it to command order. The same chain exists in npm test, and in the CI and release workflows. On top of that, the workflow documents spell out the cross-platform traps (PowerShell doesn't support chaining operators, PATH isn't inherited, write Korean-text files with a file-writing tool instead of the shell) so that differences between tool environments don't cause failures in the first place.
"Enforced by order, not by judgment" summarizes the design stance of the entire definition layer.
7.6 Where code generation belongs and where a checklist belongs
Adding one bot touches 16 places (service, settings component, route, console registration, overlay HTML, overlay types, Rust data types, wiki, tests, feature map, and so on). Automating all of it makes the generator as complex as the project.
I generated code only for the two places that have to be mechanically exact — the service file scaffold (script/generate-service.js:104) and the settings key → type map update (:114-151). The rest needs judgment, so it stayed a 16-step checklist document (.agent/workflows/create-bot-service.md:424-439). It's a compromise — "automation plus a verifiable procedure" instead of full automation.
The same stance applies at the type boundary. Rust↔TypeScript bindings are generated (28 commands + 20 types), but overlay HTML goes through neither a bundler nor TS, so it can't import the bindings. Accepting that in a three-party structure the type system covers only two parties (Angular↔Rust), I pinned the remaining edge by having Rust tests assert the JSON key names directly as string literals (src-tauri/src/overlay.rs:1240-1337). A round-trip test can't catch this failure — Rust↔Rust stays symmetric even if you delete the serialization rules.
This does not mean 16 places became 2. The generator covers those two; the code work that still requires fresh judgment — service logic, console registration, routes, settings component — remains untouched (checklist items 2, 6, 7, and 9; of these, the settings component is the longest step in the workflow document — .agent/workflows/create-bot-service.md:183-338). Wiki, feature-map, and skill updates (items 8, 15, 16) are needed as well, overlay or not. Bots that need an overlay — 7 of the 11 (live captions, poll, raffle, roulette, donation goal, song request, teleprompter) — add 7 more items: HTML, overlay type registration, Rust types, and serialization tests (items 3, 4, 5, 11, 12, 13, 14). What the generator removed is repetitive typing, not decision points.
7.7 I did not measure this structure's effect
This chapter is design description from start to finish; there is no measurement of what the 39 definition files actually improved. I never set up a baseline for comparing before and after. What does exist is a list of the failures this structure did not prevent — the index drift in §7.3 (24 rows in the skill table against 25 directories) and the 5 documentation drifts in §8.3 both happened with rule ③ in place. I have not proven the "AI is an amplifier" conclusion quoted in §7.1 with my own case.
8. Limitations and Roadmap
This chapter is a list of the things I know about and haven't closed yet. Every item is one I confirmed in documentation or in code.
8.1 Architecture
| Item | Current state | Why it isn't done |
|---|---|---|
| No horizontal scaling path | PKCE exchange codes live in process memory, the SSE session map is in memory, and the legacy counters are on a single volume. All three break the moment there are 2 instances | At the current scale (DAU 90) the problem hasn't been observed. Moving them to external storage (Redis or similar) when scaling becomes necessary is the next step, already scoped |
| Remote overlay reads are unauthenticated | The initial-state and SSE read paths for a channel overlay have no authentication. A third party who knows the channel ID can pull, in real time, live caption text (a full transcript of what the streamer says), the song-request queue, raffle participant and winner nicknames, poll tallies, and donation-goal progress (state schema: vzzk-api/src/sync/sync.types.ts:73-94). Channel IDs are public information |
Adding a capability token breaks every URL a user has saved in OBS. I judged that migration cost larger than the risk — but that judgment was made before naming the exposed items the way they are named above. I never examined a gradual transition such as keeping existing URLs and applying tokens only to newly issued ones |
| No server-side token revocation API | The server has no way to immediately invalidate a leaked token | Not implemented. Recorded in the README as a remaining risk |
| Legacy bypass paths L1–L5 not removed | The Pro status response still returns an HMAC signature alongside = a path for forging Pro entitlement is open | The observation window (14 consecutive days at 0) hasn't been met yet. The removal procedure and its order (remove code → verify → rotate keys), along with the list of tests to delete, are ready in the documentation |
| Self-signed code signing | Users see the SmartScreen warning as-is | I chose a note in the release notes over the cost of a certificate. minisign handles integrity |
| minisign keys can't be rotated | Changing the key means existing users stop receiving automatic updates | A structural constraint. Losing the private key means losing the auto-update path, and the README states this as a warning |
| No build provenance (SLSA L1 not met) | There is only minisign artifact signing; no build provenance is produced (0 attest/provenance-family steps in .github/workflows/). A signature says only "this file was signed with my key," not "this file was built from this source by this workflow" |
Adding actions/attest-build-provenance is one step in the release workflow, but it has no effect unless the verifying side (the updater) checks the provenance. I deferred it as work that attaches both sides together |
| Cyclic eviction at the SSE connection cap | 18 connections per channel with FIFO eviction, but an evicted overlay reconnects a second later. Above 19 connections it degenerates into a cycle in which the oldest connection is dropped in rotation (§4.2) | With 6–10 overlays per channel today, the cap has never been reached. Sending a reconnect-suppression signal in the eviction response is the next step, already scoped |
| The donation-goal SSE isn't a snapshot | The justification for tolerating Lagged loss is "always a full snapshot," yet only the donation goal on the local SSE sends individual goals (src-tauri/src/overlay.rs:417-419). If an update to one of several goals is dropped, a stale value stays until the next update |
Unifying on a full-array push makes the payload grow with the number of goals. With no observed case I put it at low priority, but it is the one path where the premise for tolerating loss breaks |
| WebSocket alternative for local transport not evaluated | I worked around the browser's 6-connection limit by adding ports, but that limit is HTTP/1.1-only and doesn't apply to WebSocket. The teleprompter already uses socket.io, so the path exists inside the repository | I didn't evaluate it at the time. Keeping overlay HTML as dependency-free plaintext and getting SSE's automatic reconnection for free are real benefits, but they aren't a conclusion I reached by comparison |
8.2 Verification gaps
| Item | Current state |
|---|---|
| No automated verification on the SSE receive path | Stream reception is documented as an explicit non-test area and depends on manual E2E. A regression such as a header flush still isn't caught by tests |
| CI doesn't check binding drift | Binding freshness depends on "the developer runs a dev build at least once." Type-checking after regeneration is enforced only by the workflow document, not by CI — the weak link today |
| The lint's uncalled rule | The "undeclared variable access" check advertised in the header comment of lint-overlays.js is never actually called. A mismatch between documentation and behavior |
| Lint rule coverage | TDZ checks top-level scope only; XSS checks only the innerHTML + template-literal combination |
| No regression tests for metric definitions | The 4 defects in Case 3 were caught by a person re-reading the definitions. There isn't even an assertion that "fails when a ratio metric exceeds 100%" |
| No automated check on the invariants | Of the invariants in §7.2 ②, "no await inside a sync function" and "no overlay push from a bot's onBeforeClose()" are conventions; violating them still passes lint, tests, and CI |
| Verifiable draws not implemented | Roulette and raffle results are computed on the client, so viewers have no way to verify a result. CSPRNG entropy and a fairness guarantee are separate things. Removing the Math.random() left in the index selection at src/app/util/random.ts:4 is bundled into the same item |
| No unhandled-exception or crash telemetry | The only failures observed are the %Fail events the code logs explicitly. There is no mechanism collecting panics, unhandled exceptions, or forced app termination, so "0 bot logic failures" (§1, §2.3, §5.4) also means that class can't be observed. The denominator of the error rate is trustworthy; the numerator depends on instrumentation convention |
| No string scan of release binaries | The secret-removal verification in §4.3 stops at the source grep level. Since the original argument was "one run of strings extracts it," the verification should match it by scanning strings in the built binary. I confirmed 0 hits by identifier, but I never ran the artifact scan |
8.3 Documentation drift (confirmed)
| Item | Detail |
|---|---|
| Auth architecture skill document | Still describes a deleted structure (symmetric-key decryption, tokens carried in the deep link) |
| Pro subscription skill document | Documents an HMAC setup procedure that no longer exists |
.agent/AGENTS.md skill table |
24 rows, while the actual directory (.agent/skills/) has 25 |
The hmac dependency in Cargo.toml |
Not used anywhere in the source |
| Sample size in the revenue model document | Written as "6 days," while the current observation is 98 days |
This list is itself an output of writing this document — I produced it by checking code against documentation. Cleaning it up is scheduled as the next unit of work.
8.4 Data and instrumentation
| Item | Detail | Priority |
|---|---|---|
| Time from opening a bot to first action isn't instrumented | I can't tell whether the low conversion rate of on-demand bots is "habitual setup" or "onboarding failure." The most expensive gap, and it bears directly on product decisions | High |
| Song-request price instrumentation gap | 1 out of 33,077 records. One of the two call sites hard-codes 0; the other defaults to 0 | High |
| Verifying the PRO completion event | 7% completion against payment attempts — I can't distinguish a UX problem from missing instrumentation | High |
| PRO entry path unlabeled | Of the 438 records that carry a path row, 22.1% (97) are unlabeled; measured against 697 pro_dialogOpen events, 51.1% have an unknown path. No comparison of effect by path is possible |
Medium |
| No retention data beyond 6 months | The raw export covers only 98 days, so W15 and beyond is structurally impossible. Time solves this one | Time |
| No observed macOS usage | Support code and release artifacts exist. Whether there are no real users or an instrumentation problem can't be settled | Medium |
| DAU dimension double-counting | It must be used for trend comparison only, not for absolute level | Low (handled by documentation) |
| GA4 Daily Export cap | BigQuery daily export on a standard property has a cap of 1 million events per day, and exceeding it stops that day's export. advanced_events is the only source for session and revenue analysis, so the pipeline could stop silently as usage grows. Current usage has headroom against the cap, but I haven't set up an alert |
Medium |
| Whether the two sources define a session the same way is unverified | The unified VIEW stitches Data API aggregates (backfill) and Daily Export (raw) together by date. I never pulled an overlapping range at the seam to check whether the two sources count session_start the same way. The 55,193 cumulative sessions rest on that assumption |
Medium |
8.5 Product
The biggest unresolved problem isn't technical — it's stalled growth. The numbers in §5.1 say it directly: 6.0× growth over 11 months from the first complete month, then 13 complete months of flat, and DAU is +0.6% against the previous 30 days. Returning users make up 96.6% on a user-days basis, which is what holds up the current scale, but the absolute number of new users can't be derived from the current instrumentation (92 is user-days, not people). With today's data I can't even decompose whether the plateau comes from weak acquisition or from churn.
At the same time, the donations observed rest on a concentration where the top 5 take 70.8% of the total. But that is a measure of the product's exposure and how much it is relied on, not vzzk's revenue.
The revenue structure hasn't kept up with the scale. vzzk's own revenue is ₩89,341 (~US$64) a month, against roughly ₩7,000 in infrastructure cost (§5.5). It's profitable, but profitable without labor in the calculation, and the payouts for 2026-06 and 07 are identical down to the won — net subscription growth has been 0 for the two months since PRO launched. Against a 30-day average DAU of 90.6, paid conversion sits in the single-digit percent. So the plateau isn't only in acquisition; it's also in the stretch that turns users who already arrived into revenue. Three unresolved things overlap here: (a) the 7.0% in-app payment completion rate can't be separated into a UX problem versus leakage to direct subscription on the channel page, (b) subscriber data sits outside the analytics pipeline, so I can't see churn, renewal, or net growth, and (c) storage and domain costs aren't instrumented, so the lower bound of profit and loss isn't fixed. The technical structure is built to handle tens of times the current scale, and the revenue structure to support it isn't built.
Unlike the technical improvement items, this problem doesn't close with code, and it's more accurate to write that this document doesn't have an answer for it.
9. Appendix: Technology Stack
| Layer | Technology | Role in this project |
|---|---|---|
| Desktop shell | Tauri v2 | Reuses the system WebView, packages resources, deep-link protocol handler, single instance |
| Frontend | Angular 17 | Control UI. State in signal/computed, events in RxJS Subject only — the split is enforced |
| Native backend | Rust | actix-web (local HTTP/SSE server, 10 ports bound), tokio (detached async execution, broadcast channels) |
| Type boundary | tauri-specta | Auto-generates TypeScript bindings for 28 Rust commands + 20 types |
| Credentials | OS keychain | Windows Credential Manager / macOS Keychain / freedesktop Secret Service |
| Cryptography | Ed25519 (RFC 8032) | Pro entitlement signature verification (the app holds only the public key); updater signatures use minisign |
| Authentication | OAuth 2.0 + PKCE (RFC 7636/8252) | Public-client model. Exchange-code flow, HttpOnly cookie nonce |
| Cloud API | NestJS | Remote overlay sync, SSE streams, AES-256-GCM viewer sessions, rate limiting, Joi env-var validation |
| Cloud storage | Firebase RTDB | Cache only, for reconnect recovery. Degrades to a no-op when unconfigured |
| Hosting | Railway | Single instance + persistent volume. Cloudflare in front |
| Overlay runtime | OBS browser source (CEF) | 10 plain HTML/JS files with no bundler (7 top-level + 3 under private/). socket.io (teleprompter), YouTube iframe (player) |
| Voice | Web Speech API | Live captions (speech recognition, interim results enabled), TTS |
| CI/CD | GitHub Actions | 4 PR jobs (web tests / Rust tests / build / format), 4-platform release matrix + test gate, pre-release credential verification |
| Distribution | Public GCS bucket + GitHub Releases | Artifacts, signatures, update manifest |
| Testing | cargo test / Karma + Jasmine | 176 Rust tests (152 unit + 24 integration), 39 web specs / 944 cases. Coverage isn't measured, and what isn't covered is in §8.2 |
| Static analysis | Acorn + acorn-walk | Self-built lint for overlay HTML only (2 rules + parse check). The TypeScript side has no lint — only a formatter (Prettier) |
| Analytics | GA4 → BigQuery | Unified VIEW over 759 days + raw VIEW over 98 days. 26 queries run in parallel |
| Dashboard | Node generator + ECharts | A single static HTML file with no server, 19 charts |
| Statistics | Self-built Monte Carlo | 10,000 simulations, Normal/Beta sampling, sampling-uncertainty interval (see §5.5 for why I don't call it a 95% CI) |
| Agent context | 3 rules + 11 workflows + 25 skills (39 definition files) | 4,065 bytes always loaded, the rest lazy-loaded. The loading and routing mechanism comes from the agent tooling |
Self-built scripts (script/, the 5 main ones out of 14)
| Script | Command | What it does |
|---|---|---|
generate-bindings.js |
npm run gen:bindings |
Regenerates the TS bindings by cloning and recompiling the Rust source, without a dev build |
lint-overlays.js |
stage 1 of npm test |
AST static checks for bundler-free overlay HTML |
generate-dashboard.js |
npm run dashboard |
26 BigQuery queries → Monte Carlo → a single HTML file |
generate-cert.js |
npm run gen:cert |
Creates and renews the code-signing certificate and updates the CI secrets; --check for an expiry pre-check |
generate-service.js |
npm run gen:service |
Bot service scaffold + automatic settings type-map update |
How this document was written Every technical claim here was confirmed by reading the actual files in the
vzzk/vzzk-apirepositories as of 2026-08-06. The numbers come in two families — metrics derived from GA4/BigQuery come from a dashboard snapshot generated the same day, and repository statistics (commits, tags, tests, file counts) come from the working tree on the same date. Each number carries its definition and observation window. What I couldn't confirm, I didn't write; what I didn't measure, I marked as an estimate.