A stacked PR runs the checks.ci.yml and desktop.yml filtered pull_request to branches: [master], so a PR whose base was another branch triggered no CI at all — the required ci status never reported and the diff could only be judged by reading it (a fix nearly landed that way). The base filter is gone: every PR gets the same gates.
Added
Chat can answer with the app's own panels. When the question is about the app ("what are my open todos?"), the best answer is the app's own component with real data, not invented markup — the tool-based band of generative UI. Chat's prompt now advertises todo_list {filter: open|done|all}: the model picks the SLICE and never the content (props are the render shape, so an answer that supplies rows or any unnamed field fails closed), the worker hydrates rows from the store (the renderer has no store and gets none), and a panel that cannot be hydrated is DROPPED rather than shown empty — "you have no todos" would be a claim the app has not earned. The panel is chat-only: Ask never advertises it, so the router's labels cannot reach it either. Rows render read-only with a link to /todo, because a write from here would need the todo feature's patch target to stay live.
Every AI answer reports its own outcome as one countable log line.genui: catalog answer validated carries attempts, repaired and components; the terminal cases carry outcome=fallback (Ask) or outcome=degraded (chat). Stdout is JSONL, so the repair rate and the unrecoverable rate are a read_json query away (docs/duckdb.md) — the two numbers that say whether the prompt or the catalog is the thing to fix, instead of guessing.
A rejected answer gets one repair before the user sees a failure. Catalog validation is a runtime gate, and both AI legs now feed the validation error back to the model once (AnswerWithRepair, capped at one extra call) before Ask shows "could not shape the answer" or chat degrades to prose. The retry carries the failure and an excerpt of the rejected answer, so it is a repair rather than a re-roll; a provider error stays distinct from a shape error, so "no API key" never reads as "the model returned bad JSON".
Ask AI can ask a classifier which component to lead with. A jev-compatible API (JEV_ROUTER_ENABLED=true, endpoint shared with the review judge) picks the catalog component an Ask answer leads with; off by default, and the prompt is then byte-identical to the catalog-only one (TestAskPromptRouterOffIsUnchanged). The pick is a HINT, never a narrowing: the catalog stays fully advertised, so a section still gets its children, and every miss — confidence below JEV_ROUTER_CONFIDENCE_THRESHOLD, a label outside the registry, chat's html_block, a dead endpoint — falls back to the whole catalog instead of failing the request.
The class vocabulary model markup may use is safelisted, and the prompt shows exactly it. An invented html_block exists only at runtime, so Tailwind never scanned its classes: measured on this tree, opacity-60 and sm:grid-cols-3 — advertised by the prompt and used by the keyless chat demo answer — were absent from app.min.css and rendered unstyled. One Go list (features/genui/components/htmlclasses.go) now feeds both the @source inline safelist in each skin's stylesheet and the prompt, with TestModelHTMLClassVocabulary failing (message names the fix) when the list, a safelist and a built bundle disagree.
Skills are registered for agent hosts.skills/<name> is now reachable BY NAME at .agents/skills/<name>, and cmd/check-skill-frontmatter — already in pre-commit and CI — fails when a skill is unregistered, a link dangles, or the registry holds a stale entry. Documented rules only bind an agent that can load them.
Viewer owns their private todos. Delete restrictions now apply to shared rows, never your own: owner deletes own in every role (policy grants the delete verbs, canDeleteRow enforces per-record, Clear stays own-scoped). Red-proofed across policy, middleware, handler and UI affordance tests.
Review run survives idle proxies. A 15s SSE heartbeat holds the stream between LLM phases (a killed stream left Run disabled with no error); submit sets pending explicitly.
Chat invents visuals, Ask stays catalog. New html_block lets the chat model compose Tailwind/DaisyUI markup the catalog cannot express — sanitized server-side through a closed allowlist (red-proofed), advertised only in the chat prompt. Chat/Review pages gain TechStack notes; navbar order is now Ask AI, Chat, Review.
Agent tooling is enforced, not just documented.make check-agent-tools (bin/check-agent-tools.sh, runs in make setup) fails with install lines when ripwire/cymbal/xerj/sem is missing; AGENTS.md marks all four required with a decision rule (call-graph to ripwire/cymbal, meaning over prose to xerj, diffs/history to sem).
Docs index rebuilt around a hierarchy. The site sidebar was a flat mix (how-to recipes before the strategy record); the manifest now reads Get started (strategy up front) / Agents / Core / Async / AI / Frontend / Ship / Operate, and the three orphan pages (api-routes, no-auth-no-db-recipe, db-concurrency) join the map. No document merged or lost — order only.
Architecture decision is current and visual. Fixed stale identifiers (dbschema.Migrate/NewID, handlers/ layout, desktop vs GUI boot) and disclosed the historical chi names; auth plugins render as site cards; the landing AI-ready section links the strategy.- Shared vs private todos (two-browser collab demo). Every todo is private (owner only, the old behaviour) or public (every login lists it), via a visibility column (0010_todo_visibility, existing rows default private) and a Shared checkbox on the create form. Listing, toggle, delete and the NATS bridge are visibility-scoped; shared mutations fan out to every login's streams (BroadcastToAllUsers, each re-listing its own visible set) while private rows stay owner-only. Visibility is listing scope, not permission: viewer never deletes, owner deletes own, admin deletes any visible row (403 otherwise, 404 when the row is not visible at all). Scope tabs (All / Mine / Shared) slice the list; shared rows carry a badge + author. Follow-ups: record-event re-lists preserve the tab's stored filter/scope (per-stream view prefs in the Hub, tracked from tab clicks); per-row delete/authorship comes server-stamped on the item (IsMine/CanDelete, one rule home instead of template copies); "Clear completed" is honestly labeled "Clear my completed" (it only ever deletes the requester's own rows). Red-proofed in features/todo/handlers/sharing_test.go (+ Hub pref tests). Slice-preserving endpoints: every list-affecting mutation (toggle, clear, delete-confirm, realtime resync, both skins) carries filter+scope+clientID, so acting from Mine/Shared never yanks the tab back to All (template URL-contract tests in both skins).
make verify-ci (post-push). Deploy ships on push without waiting for CI; bin/verify-ci.sh (over the cali-ops-verify-ci skill) checks default-branch CI + live /health with a bounded wait, JSON out. Documented in docs/local-ci.md. It caught a real red race job on its first run (parallel timer tests tripping the goleak guard — fixed by de-parallelizing the new hub tests).
Docs indexing gaps closed.docs/api-routes.md (load-bearing inventory, test-pinned) was in neither the site map nor any doc link — now in site/build.mjs beside no-auth-no-db-recipe.md, and the local-ci.md hooks table lists the full pre-commit job set.- Demo roles ship with the seed; the login card explains RBAC.internal/demo stamps demo1 admin and demo2 the restricted viewer, so the login card's permission split (admin reaches /config; viewer loses deletes + AI Suggest) is true by construction. The navbar gates the operator surfaces to admin and shows the session role. Roles are users.role; RBAC_ENABLED=true enforces, and ADMIN_EMAILS/VIEWER_EMAILS re-map without a rebuild.
Dynamic RBAC example: ABAC + a DB-backed policy.internal/rbac gained an ABAC Object with matcher functions (pathMatch, attrOK) and a Casbin persist.Adapter over a new casbin_rule table, selected with RBAC_POLICY_SOURCE=db (embedded policy stays the default). Taught in docs/rbac.md ("Dynamic permissions"). internal/db/sqlc.yaml's schema path was corrected (../dbschema/migrations), so make sqlc / sqlc-check work again.
data-demo removal handle. The TechBadge and the login demo-accounts block carry data-demo attributes, so agents strip the template's self-advertisement with one grep (docs/scope-taxonomy.md).
Registry conformance pre-commit hook.TestRegistryCoversPackages only ran in CI, so an unregistered package (internal/hostgate) went red there first. bin/check-registry.sh runs the instant conformance trio pre-commit (same-implementation-as-CI, like api-routes); red-proofed by removing the hostgate entry (fails) and restoring it (passes).
hostgate in the capability registry.internal/hostgate shipped without lifecycle metadata and failed TestRegistryCoversPackages (red 0.44.3 CI, including inside trimmed preset trees). Registered as non-offered core.
Go-test orphans reaped with the smoke sweep.t.TempDir self-cleans on pass, but aborted runs orphan /tmp/Test* + /tmp/go-build* (proven live). reap-stale-smoke.sh (already at every gate start) now also reaps those past 24h (TEST_STALE_MIN) — longer margin than the 6h smoke patterns, since Test* is a generic prefix on a shared box. Verified with a planted 2-day-old decoy (reaped) beside a fresh dir (kept).
In-test host gate for heavy tests (internal/hostgate). Makefile and script pre-flights cannot cover a bare go test -run ... invocation, and TestTrimmedTreeStillBuilds IS the heavy workload (repo copy + forked go vet ./... per unit, unbounded parallel — the storm that wedged the shared host). hostgate.SkipIfConstrained skips with dimensions, reason and fallback when off-CI and constrained (RAM/load/temp-disk, env-tunable, CI_LOCAL_FORCE=1 overrides, CI never skips); the trim-build vets are also capped at 2 concurrent. Unit-tested pure verdict.
PR-body template check..github/workflows/pr-body.yml fails PRs whose body still carries template placeholders (Closes #NNN, Write this yourself, Required, one line) — GitHub pre-fills the template but nothing made authors complete it. Bodies without template markers (bot PRs, fully custom text) pass vacuously; prose quality stays review judgment. AGENTS.md now orders agents to fill every section on gh pr create (linked issue, motivation, one-line AI disclosure, verification).
Review loop (/review, Genkit + goai + jev judge). Paste text or a diff — a writer and a critic iterate bounded (MaxIterations), then a classifier judge delivers the verdict over ephemeral SSE, discarded afterwards. Zero server-side artifacts (billing ledger only), real-LLM only (no key → setup hint + 503, never simulated). Genkit brings typed flows + per-step traces with zero provider plugins (generation reaches providers only through llm.Client, so the Biller choke point holds); the judge is any jev-compatible API via JEV_* env (SimpleJev demo by default), escalating to a human below JEV_CONFIDENCE_THRESHOLD. Trim with gogogo trim review (dropped by minimal + no-ai); see docs/review.md. Red-proofed (loop bound, no-write, fail-closed).
llm.Client.ChatWithUsage. Usage-returning variant of Chat for typed orchestrators (Genkit model action); metering stays inside the client (one authorize + one settle). Plus a one-line audit fix: fromGoAIUsage folds cache-write tokens into input instead of dropping them.
Flat-stack dependency guard.cmd/check-flatdeps (make check-flatdeps, CI + pre-commit) now proves three invariants without booting: no banned framework in the closure, no cross-feature domain imports (shared UI leaves authviews, sounds, genui/components excepted), and sql.Open only in owner packages (internal/db/, internal/queue/, features/credits/). Both new checks are red-proofed (violation probes fail, clean tree passes).
Suggest output contract.internal/llm forces untrusted model text into shape — max 3 items, trimmed, deduped, cut at 60 runes (rune- not byte-cut, so multibyte tails stay valid UTF-8) — on both the JSON path and the plain-text fallback.
Worker shutdown leak assertions.goleak.VerifyNone on the queue shutdown tests, so a worker surviving Stop()/Close() fails the suite instead of leaking silently.
T4 host-capacity pre-flight.scripts/check-host-capacity.sh (wired as the first make ci-local prerequisite) measures free RAM, cores, 5-minute load and free disk, and refuses the full gate on a box that cannot carry it — full-repo lint + race suite + four parallel browser suites pins a small shared host at 99% CPU and stops it answering. Refused runs fall back to make ci-local-fast + PR CI; CI_LOCAL_FORCE=1 overrides. Each refusal branch is probe-verified (RAM/load/disk trip on demand, capable box passes).
Automatic smoke hygiene.ci-fanout.sh traps INT/TERM (no orphaned suites on interrupt) and removes its log dir on green runs only; both local gates reap mtime-stale smoke corpses at start (reap-stale, 6h default — concurrent runs untouched, caches never listed so nothing rebuilds).
Homolog-light recipe (no staging env).docs/local-ci.md documents the staging-without-a-server loop (make dev + make ci-local-fast + smoke + optional tailscale serve URL with localhost fallback and install/why notes), so "does it work" is answered in seconds without a second stack. Four places that claimed the master deploy waits for CI (deploy.yml, local-ci.md, lefthook pre-push comment, AGENTS.md) now state the parallel truth: deploy compiles only, PR CI is the pre-merge gate.
Parallel CI jobs.ci.yml runs checks | race | browser | prod-image on isolated runners — wall-clock is the slowest job, not the serial sum. Same steps verbatim (deadcode moved earlier, one shared binary across the client suites, image boot fails fast instead of last). A ci aggregator job preserves the branch-protection status context (parallel jobs cannot provide a stable name).
Docs-only changesets still report ci. A trigger-level paths-ignore skipped the whole workflow, so a docs PR left the required ci status pending forever and needed an admin bypass to merge. ci.yml now always starts and classifies the changeset itself: when every changed path is docs/, site/, *.md, LICENSE, .stelow/ or .gitignore the four heavy jobs skip while the ci aggregator reports green. An unreadable diff (force-push, shallow history, dispatch) fails open to a full run.
Commit-time vulnerability scan.govulncheck runs pre-commit on go.mod/go.sum (same script as pre-push/CI), so a vulnerable dependency fails the commit instead of the push.
Reconciler ships green SHAs only. The schedule drift check deploys master HEAD only when it carries green CI, and skips docs-only pushes whose badge already matches validated code.
Skip summary on green gates.ci-fanout.sh prints host-gated SKIP lines before log cleanup, so green-with-skips is never misread as full green (race log is now -v).
Verified SSE stream open.GET /api/stream/status reports hub registration per tab; the four Datastar stream openers (themselves declarative data-on:datastar-ready, vanilla listeners removed from the allowlist) click through a shared verifier that re-clicks until registered and throws loudly past its bound — the pre-binding fallback click used to poison the opener into silent death on slow clients (red-proofed by aborting the Datastar bundle: old tree silent, new tree loud; scripts/sse-reopen.test.mjs runs in CI and the local fan-out). Details + upstream watch (starfederation/datastar#1203): docs/realtime-recipe.md.
CI job self-provisioning guard.bin/check-ci-deps.sh (pre-commit + CI) fails a job that runs node/go without installing them earlier in the same job — the missing-npm ci that broke the first parallel run, which local gates cannot catch (a dev box always has everything warm).
Reliable module downloads.GOPROXY uses the pipe form (proxy|direct: any proxy error falls back to git, verified empirically) and every Go download leg warms with bounded retries (bin/go-mod-download.sh in CI jobs, the same loop inline in Dockerfile.prod, which has no bash) — the proxy's transient INTERNAL_ERRORs killed cold-cache go list, go test and a 268s image build that go does not retry itself.
Retried toolchain install. The 5 Install Go steps (CI, desktop, deploy) now share bin/install-go-toolchain.sh: same tarball, plus bounded retries — a 4-minute download dying with HTTP/2 INTERNAL_ERROR (curl 92) failed a whole job once. check-ci-deps.sh recognizes the script as Go provisioning.
Native window ships in releases.cmd/gui (pure Go, CGO_ENABLED=0) builds for all six OS×arch (verified locally) and rides the release archives as gogogo-gui, installed by install.sh next to the CLI. Dropping the Wails shell is documented (docs/desktop-mobile.md: delete cmd/desktop/, drop desktop targets + job, tidy — proven to build clean with wails gone).
Centralized write transactions + contention logging.dbschema.Transact is the single home for multi-statement units (begin/commit/rollback, busy classification, slow-txn debug) — adopted by migrations; single-statement writes stay autocommit by design. docs/db-concurrency.md is the runbook for ever enabling MVCC (status as probed, exact steps, red-proof plan).
Changed
The removal class is one value again, enforced.SCOPE annotations and the registry's Kind had drifted in six packages: internal/components, internal/datastar and features/genui said core/feature while their units say plugin, and internal/natswire said plugin while its unit says core (handlers name its types in signatures even with NATS off). A reader got opposite instructions depending on which file they opened. The registry is the source of truth for kinds, so the files moved to match it, and TestScopeAnnotationsMatchRegistryKind now fails the build on any new drift — recursively, so a subpackage is checked against its top-level unit (red-proofed by inverting an annotation, top-level and nested).
docs/scope-taxonomy.md says what ships as what for the tree that exists. The table was missing internal/rbac/, features/genui/, features/chat/, internal/goakt/, internal/natswire/, internal/observability/, internal/demo/, internal/hostgate/, internal/capabilities/, internal/installer/ and features/onboarding/, and it called features/authviews/ a feature when the auth unit owns it. It also names the source-of-truth rule so the next addition has somewhere to look.
RBAC and the Genkit plugin are visible where the stack is described.docs/features.md gained the access-control row and marks Genkit as the plugin the review feature rides; the landing page says so on the Review card. Casbin was previously reachable only from docs/rbac.md.
CHI_ADDR is documented as what it is. The decision record called it a removed transitional seam, but cmd/web honors it and cmd/desktop sets it. docs/desktop-mobile.md no longer calls the flat stack "the chi stack".
The stack docs split benefits from tradeoffs.docs/stack-layers.md now states what each choice buys (Casbin and the stdlib router were missing from the table) and hands the cost side to the new docs/stack-tradeoffs.md — rejected alternatives, the Turso cost that used to sit in the decision record, and a three-way database comparison (Turso vs plain SQLite vs Postgres/Supabase) that no longer implies SQLite and Turso are the same thing. The decision record leads with the flat-stack benefits as an icon card grid; the site builder renders any emoji-led list that way, so the page stays a plain list on GitHub and in llms-full.txt.
Stale schema path corrected in docs.internal/db/migrations → internal/dbschema/migrations in the decision record, stack-layers.md, CONTENT_WRITING.md, the Makefile sqlc comment and the sw.js comment.
Full-suite script refuses a wedged box.scripts/test-web.sh (the T4-equivalent workload agents kept launching by hand on the shared host) now runs the check-host-capacity pre-flight first — skipped only on CI runners (CI=true) or explicit ALLOW_HEAVY=1. Same refusal contract as make ci-local.
Dagnats add-anchor follows the single-hub boot.BootChi declares shutdown := []func(){chiQ.Close} (no bare var shutdown anymore), which killed the dagnats unit's addAfter anchor and failed the checkTree drift gate on master. The anchor now points at the live declaration line.
ci-local-fast has a capacity pre-flight and a changeset budget.check-host-capacity.sh --fast (lighter floors: 3 GB RAM, 2 cores, 1.5 load/core, 1 GB disk) refuses a wedged box before scoped race starts, and past 8 changed packages (FAST_MAX_PARALLEL_PKGS) the race run goes serial (-p 1). AGENTS.md now forbids full-suite runs on the shared host explicitly — the enforcement that was missing when two parallel sweeps wedged it.
Collab fan-out is doc-scoped. The shared SSE hub broadcast every whiteboard/notes event to every tab on every doc, so one board's shapes overwrote another board's view ("drawings erased") and one note's text was adopted by another note and posted back mixed (two notes each holding part of the other). SSEHub now tracks each stream's doc (SetDoc at ServeStream) and all collab legs use BroadcastExceptToDoc / BroadcastToDoc (join/leave/count/cursor/draft/shapes/note-text), with a client-side msg.doc guard in whiteboard.js/notes.js as defense in depth. Red-proof: TestSSEHubDocIsolation, TestSSEHubDocBroadcastToDoc, TestWhiteboardCrossDocIsolation, TestNotesCrossDocIsolation.
Notes rehydrate on cache miss.notes.NewHandler never wired its persister into the DocStore, so an op arriving after an evict/restart applied to an empty doc and persisted the empty resolution over the good snapshot (text lost). The store now rehydrates before the op, like the whiteboard worker already did. Red-proof: TestNotesOpAfterEvictKeepsText.
Notes standalone carets pause while ops are unconfirmed. Every keystroke sent a caret anchored to the stale base AND a batch carrying the exact caret; the stale one overwrote the exact dot, jumping it a line per letter on the peer's screen. The input handler no longer sends a standalone caret (the batch carries it atomically ≤100ms later) and maybeCaret skips non-forced reports while the outbox is unconfirmed.
Queue + Retry demo runs end to end again. Two compounding defects: BootChi built streams on a standalone hub while workers broadcast on the queue's own hub (all worker feedback swallowed — stepper stuck on step 1), and handleRetryDemo dropped the tab's clientID (feedback unroutable). One shared hub (chiQ.Hub() for every stream and worker, incl. chat/genui), the job carries its clientID, and retry feedback + toasts route to the originator (hub.Send, broadcast fallback). The todo online pill now uses CountUserClients() so other features' streams on the shared hub don't inflate it. Red-proof: TestHandlersRetryDemoPropagatesClientID and the production-boot TestRetryDemoReachesStream (fails on the old wiring, passes on the new).
Deploy LLM default is a live OpenRouter free model.llama-3.1-8b-instant no longer exists on OpenRouter, so Ask AI failed with "model does not exist". Defaults are now GOAI_BASE_URL=https://openrouter.ai/api/v1 + GOAI_MODEL=nvidia/nemotron-3-super-120b-a12b:free (verified live against the models API in Oct 2026: $0 in/out, text→text, structured outputs). Override via env to pin another :free id — the free roster churns.
Installer templates follow the single-hub wiring. The trim/add substrings still carried the old queue.NewSSEHub() chat/genui constructions, so a trim+add cycle would have silently reverted the hub unification. Templates updated, and TestTrimSubstrsMatchLiveChiGo now pins every chi.go substring against the live file (red-proof: fails on the old template).
Chat is linked in the navbar./chat existed (route, worker, page) but no nav entry pointed at it — reachable only by typing the URL. The shared navbar now shows Chat next to Ask AI.
Todo stream ignores foreign feature results. On the shared hub a genui/chat broadcast without clientID (Ask posted before its stream connected) crossed the todo stream and was merged as lastJob noise. The dispatcher now swallows genui/chat envelopes silently — their own tabs render them. Red-proof: TestDispatchIgnoresForeignResults.
Offline replays reach every stream. Mutation handlers fan out (Hub + JetStream) before the "replayed" short-circuit, and replays no longer exclude the queued clientID — a background replay is not the originating interaction, so no-echo does not apply. Fixes the offline "Add button stuck + todo lost" class the Playwright smoke caught (red-proof: TestChiTodoReplayReachesOriginator).
CHI_ADDR honored by cmd/web. The env var was dead on the main binary (only cmd/desktop read it); listenAddr unifies the rule — override when set, HOST:PORT otherwise.
Dead realtime subscription removed. Every list page opened an EventSource on /api/realtime, a route that died with the framework — a 404 per page plus stale PB narrative. PbRealtimeResync becomes ListResync: same hidden-@get button ids, same visibility refetch, no subscription. Record sync arrives over each feature's SSE stream.
Demo seed covers both accounts. The flat port seeded only demo1 while the login form promises demo1+demo2; internal/demo seeds both again (red-proof: TestEnsureSeedsBothDemoAccounts).
Dead ENCRYPTION_KEY removed. The PB-era key has no consumer on the flat stack (sessions are random tokens; BYOK has its own CREDITS_ENC_KEY): field, /config row and mask entry gone, masking tests re-pinned on AdminToken.
ai-credits v0.5.0 (tursogo, no modernc). The ledger library drops modernc.org/sqlite; modernc leaves our go.mod and the binary closure. Its write lock is now driver-proof (immediateTx forces the upgrade up front), which the concurrent-reserve suite red-proofs.
PocketBase is gone. The framework layer the flat stack replaced is fully removed: db/ (app handle, seeds, idempotency hook), router/ (route wiring), internal/routeutil, internal/collabpb, features/store (PBStore + CRDTStore — one data layer remains), features/auth (legacy middleware), and every feature's PB handler pair. cmd/web serves only the flat stack: BootChi → startDagNats → startNATS → AttachJetStream → Serve, blocking on SIGINT/SIGTERM so cleanups drain in reverse order. cmd/gui (native window) now runs on the same Turso + owned-auth layer. pocketbase + dbx leave go.mod; the binary shrinks ~22% (141M → 110M). No contract was lost silently: every PB-era test was re-pinned on the chi slice that replaced it (cross-session create, notes caret/presence/rev protocol, whiteboard peer counts + no-echo + 409, room roster resilience, chat/genui workers, auth sessions + /api/session, theme toggle), and the chi todo slice gained two fixes the migration exposed (Signals.DagNatsEnabled registration truth, the /static + /sw.js + /health contract mounts). ENTITY_STORE is gone with the strategy switch; internal/db IS the data layer. Also gone with no replacement: the /_/ admin UI (inspect with sqlite3 plus the read-only /config view) and file storage (no feature uses uploads); auth collapses to a single gogogo_auth cookie and record push to the SSE Hub record event.
Flat architecture. Removed the internal/ports + internal/repos + internal/backend abstraction layer (a shallow pass-through with no implementation wired into the app) and the DB_BACKEND selector. Data access calls sqlc-generated functions directly from handlers.
Turso, not libSQL. Config names Turso Database; the libSQL Go driver and the LIBSQL_URL fallback are gone (turso.tech/database/tursogo is the driver).
SCOPE is one axis. The annotation is now // SCOPE:<core|plugin|feature> — the redundant layer= axis, which only restated the directory, is dropped, and make check-scope accepts only this form.
Opt-in Casbin RBAC.RBAC_ENABLED=true binds an enforcer built from the embedded model + policy in internal/rbac/casbin.
No “POC” language. The native-window target, docs, CI job, and scripts no longer describe anything as a proof-of-concept.
Feature slices renamed chi → handlers.features//chi (package chi) is now features//handlers (package handlers): inside those files chi.Router always meant go-chi while the package itself was also chi, forcing meaningless import aliases (todochi, wbchi, …). Importers now use todohandlers, noteshandlers, etc. No behavior change (pure rename: 52 files moved, import/alias/SCOPE/doc updates only).
SCOPE linter exempts generated files.make check-scope now skips any file carrying Go's standard // Code generated … DO NOT EDIT. marker, so regenerating sqlcgen (or any future generator output) never needs a hand-added annotation.
Post-framework legacy prose purged. SCOPE annotations, installer anchors, docs, skills and CI comments that still pointed at deleted paths (router/*.go, db/seed.go, features/auth/views.templ, pb_data/, routeutil, defer shutdown(), Phase 2: CRDTStore) now name the flat-stack call sites (buildChiMux, fail() in the todo slice, features/authviews, features/onboarding, features/credits). The orphan docs/investigation/crdtstore-recordroundtrip.md (236 lines on the deleted CRDTStore) is gone, and lefthook post-merge regen-assets uses ORIG_HEAD (HEAD@{1} never fired — lefthook rewrites {1}). gogogo add anchor drift is now a --check failure (red-proofed), not a silent no-op.
Credits error bodies stop leaking internals. The balance 500 and the managed-request/checkout 400s returned err.Error() (gateway errors wrap driver detail: credits: reserve: …); all three now log via internal/observability and return static public messages.
Leak rules rewritten for the flat stack. The three ruleguard 500 guards matched Echo's c.String(...) (zero occurrences — the footgun shipped through them); they now match the real shapes (writeJSON/writeText/http.Error + StatusInternalServerError + .Error()), with fixtures re-pinned and the suite green.
Fixed
Chat appends the thread, not its container. The SSE hub patched the assistant answer by appending the whole message column into #chat-messages — a server-side patch resolves its selector to every match, so from send #2 on the answer rendered twice (measured in Chromium: 4 containers, 3 answers for 2 questions). The container owns the id and the first-run guidance (data-show="$chat_empty"), the stream appends a ChatThread fragment that carries neither, and the ask ack now echoes the user's own bubble — which previously reached the DOM only on reload. History is server-rendered from the store, as the page copy always claimed.
The Run button on /review is usable again. The pre-paint theme bootstrap wrote a fresh data-signals object, dropping the page's own seeds before Datastar read the attribute. Datastar reads a signal that never arrived as "", and its data-attr:disabled plugin sets the attribute for "" — so the button was born disabled, with no way out: the only handler that clears review_pending lives on that button's own (unsubmittable) form. The bootstrap now merges theme into the page's JSON seeds, every page ships data-signals as JSON, and the smoke run asserts per page that the seeded signal survives load and that no submit control is born disabled. Ask and Chat had hidden the same loss: their SSE handshake re-seeds pending on connect.
Stdout is JSONL, no exceptions. Bare slog calls used the default text handler (ignoring LOG_LEVEL) and log.Printf wrote timestamps everywhere — one slog.SetDefault plus converted call sites later, every emitter is JSON on stdout (Genkit included: it respects a custom default). Boot smoke proves zero non-JSON lines. Anything parsing the old text format must switch to JSON keys.
Debugging with DuckDB (recipe).docs/duckdb.md: query slog JSONL and attach the Turso file read-only (TYPE sqlite, READ_ONLY) after checkpoint+copy — verified, including the WAL-invisibility trap.
AI answers parse like the model writes.ParseDirectives — the one front door Ask and Chat share — reads an answer as a JSON-ish token stream when strict encoding/json rejects it, so the structural slips a small model actually makes stop costing the whole answer: the envelope key repeated where an array element belongs (invalid character ':' after array element), an object closed with the square bracket (invalid character ']' after object key:value pair — the exact error the Ask panel showed), a section's children parked inside props under children or components, a trailing comma, a closer that does not match, an answer cut off mid-token. Both paths end in the same catalog validation, so nothing was loosened: one unknown type or undecodable props still rejects the answer. A payload that fails validation now leaves the prose the model wrote around it, so the chat bubble never shows the literal JSON — red-proofed with the live plan answer from the report (nested sections, chart and buttons render) and with the exact decode error, which reverting the reader reproduces. Composers bind their inputs, guard double-submit explicitly, and clear on ack.
Chat reads like Chat, not like Ask. Both features answered with the nearest catalog component, so the two demos looked identical. The chat prompt now advertises html_block first and tells the model to build the visual for the request out of its own Tailwind/DaisyUI markup, keeping the catalog for what markup cannot do (a form posting to this app, a single line of text) — sanitizing and the closed allowlist are unchanged. Ask stays catalog-only and never names raw HTML. Both pages say which half they are.
One click, one answer. goqite re-delivered jobs after its 5s default visibility window (up to MaxReceive=3) while slow LLM calls still ran — triple bubbles from a single submit, since persistence dedupes by idem but SSE sends do not. Queue visibility is now 10min (red-proofed: a 6s job executes exactly once); fallback answers also render once (text or component, never both).
Deploy reconciler is version-aware (badge lag fixed for real). The push-deploy runs before the release tag exists and the tag-deploy arm never fires for release-please tags, so production served new code under the old badge (observed: 0.47.0 serving the v0.48.0 tree). The schedule reconciler compared SHAs only — identical SHAs meant no deploy. It now also compares the badge version against the latest tag and redeploys on mismatch (empty tag lookup skips rather than forcing a restart). Verified against live state before merging.
User dropdown: invisible rows + snap-shut menu. The info rows (email, role) inherited body text, which in dark mode is near-white on the always-white menu card — readable only when selected. They now carry an explicit ink color in both modes. The menu also closed mid-travel: the margin-top gap between trigger and menu dropped :hover before the pointer reached the first item. An invisible ::before bridge now covers the gap.
Single presence slot (regression of the 0.46 unification). The header pill AND the standalone Realtime card both rendered X online + transport — the header pill had been re-added after the release that removed it. The card is gone (it carried no action, only a second copy); presence lives in the header pill, with what-it-means in its tooltip. Both skins.
Login demo block is visual, not a paragraph. The three-paragraph wall is now two account cards (Demo 1 admin / Demo 2 viewer), a password + two-browser collab strip, and the Casbin mechanics behind a <details>. Same facts, same data-demo="demo-accounts" removal handle.
TechBadge → TechStack: legible pills + per-layer notes. The muted text-xs inline list is now readable pill tags with a "What each layer does here" notes list, in one shared skin-agnostic component (inline styling, so DaisyUI and Basecoat read alike). All six call sites carry per-feature role notes; the data-demo="tech-stack" removal handle stays.
Dead DATABASE_PATH config removed. Nothing opened it: the app DB rides TURSO_DB_PATH, the queue derives from DATA_DIR, and the credits ledger opens its own Turso file — the var was only read into config and displayed. Removed the field, its env read, dev default, config-UI row and docs row (plus the smoke test's stale comment claiming the queue opened it); boot smoke green without it.
Ask AI / Chat 401 "Missing Authentication header" (split LLM triple).deploy/docker-compose.prod.yml set GOAI_BASE_URL/GOAI_MODEL OpenRouter defaults in environment, which always wins over the secrets env_file — so the Groq GOAI_API_KEY from the rendered secrets file was sent to OpenRouter, and every Ask/Chat failed with OpenRouter's 401 (verified live: any gsk_ key gets exactly that body). The compose file no longer sets either var: the whole triple comes from the secrets file the deploy workflow renders (GH secrets + vars), and its comment pins the invariant. Provider auth failures in all three LLM workers (Ask, Chat, Suggest) now append a key↔base mismatch hint via llm.WithAuthHint instead of surfacing the raw provider string. Red-proofed per path: TestAuthHint (unit), TestHandlersGenuiAskAppendsAuthHint, TestWorkerAppendsAuthHintOnProviderAuthFailure (chat), TestHandlersSuggestAppendsAuthHint (suggest), and TestDeployComposeKeepsLLMTripleInSecretsFile (compose invariant) — each verified to fail on the reverted production code.
Collab notes caret jumps with same user in two tabs. Presence and op events keyed peers by display name (User = authed email), so two tabs of one account shared a single caret entry and each tab's heartbeat/caret overwrote the other's — the peer's dot changed position and line while they typed, with the other user idle. Different users never collided (distinct emails). The server now stamps the per-tab clientID as PresenceMsg.From / NoteTextEvent.From (plus a display User), and notes.js keys carets/typists by that id while rendering the display name. Same-user tabs track independently. Whiteboard cursors/drafts had the identical flaw and got the same split (key by from, color/label by user). Red-proofed end to end: TestNotesSameUserTwoTabsKeepDistinctCarets + join/leave from asserts (fail without the server stamping), TestPresenceMsg_FromRoundTrip (fails without the From field), TestNotesCaretsKeyedByTabID / TestWhiteboardCursorsKeyedByTabID static JS contracts (fail on name-keyed JS), the notes-caret / whiteboard-client same-user browser sections (two dots on the peer), and TestWhiteboard_PresenceBroadcast's from assert.
Added
Flat data layer (internal/db). Turso Database over the tursogodatabase/sql driver (pure-Go, no CGO, no libSQL): connection + pragmas, an embedded migration runner (schema_migrations), the core schema (users, sessions, todos — incl. owner scoping and the partial idem_key dedupe index the offline queue depends on), and sqlc query sources generated into internal/db/sqlcgen (package dbgen, emit_interface: false). make sqlc regenerates; make check-generated fails on drift when sqlc is on PATH. Every path is covered by tests that run against the real driver.
Chat conversation demo on the genui catalog.features/chat/ persists both sides per owner (idempotent on idem + idem#answer, so SW replays never duplicate), parses every answer through ParseChatAnswer (catalog directives render, anything else degrades to a safe prose bubble — chat never dies per message), and fans the answer over a dedicated SSE hub. Page at /chat (POST /api/chat/ask, GET /api/chat/stream); history store is in-memory behind MessageStore, PocketBase collection is the documented upgrade path. Red-proofed worker/store/parse/render tests.
Owned auth (internal/auth). Argon2id passwords (~350ms, versioned encoding), opaque sessions keyed by SHA-256, gogogo_auth cookie, plus magic-link (single-use, silent nil), TOTP second factor (owned RFC 6238, recovery codes, attempt throttle) and OAuth (generic x/oauth2 flow with Google/GitHub presets, PKCE). Red-proofed against the real driver, including RFC 6238 vectors and a stub-IdP flow.
Chi todo slice (features/todo/chi). Login/logout/index pages on the shared templates plus the data endpoints and NATS bridge, served when CHI_ADDR is set. Same paths and wire, contract-tested with -race.
Queue on Turso. goqite opens the queue file through tursogo; the full queue suite runs on the new driver.
PB-free UI package (features/authviews). LoginPage, Navbar and ThemeHead live here so flat-stack pages render without the framework; the CRUD wire lives in internal/natswire for the same reason.
cmd/check-flatdeps CI gate. Proves the flat-stack packages import no framework and only Turso is opened outside the transitional allowlist.