The living catalog of every feature and service in the product — the single source of truth for demos, sales calls and onboarding. If it shipped, it's on this page.
Reverse-chronological. Every ship and every fix lands here, newest first.
slack_bot and actions feature flags now default to on. Actions stay approval-first (approve needs an active plan; MCP can only propose). Slack installs without the app_mentions:read scope just never receive mention events, so nothing errors. Kill switch and ENHANCIAR_FF_FORCE_*=0 still turn either off.waitlist_open (GA + dh-track) when the modal opens, so the funnel reads page_view → waitlist_open → waitlist_click → waitlist_join. Before, a visitor who opened the form and left was invisible, so 0 events couldn't distinguish "nobody clicked" from "clicked and bounced"./og-image-v2.png in the cream/black theme: “Ask your company anything.”, every-role question cards, logo and enhanciar.in pill. Rendered by brand/social/og_image/render_og.py (Playwright, fonts-loaded check).og:image, twitter:image and JSON-LD image updated in landing.html and all 21 static pages in public/, which also gained og:image:width/height/alt and twitter:image:alt. The old /og-image.png is kept for existing links.docs/business/promotion_posts.json, seeded with 266 records from the old log, transcripts, live YouTube/Dev.to/GitHub listings, the sitemap, the outreach log and the scheduled tasks. Deleted, scheduled, pending and blocked items are kept with their status.admin/promotion_refresh.py, 9:00 AM scheduled task) appends snapshots to docs/business/promotion_metrics.json. Login-only platforms come in via a manual-metrics JSON written by the browser check.personality.css; PersonalityPage.jsx and the design file are unchanged.get_page no longer crashes on loosely-shaped argumentsget_page without both category and name hit a pydantic ToolError. The tool now accepts path (“concepts/my-page”), slug, page and title aliases, coerces non-string values, finds the category itself when only a slug is given, and returns a clear {error} instead of throwing.llms.txt and llms-full.txt.llms.txt and llms-full.txt.secrets job (gitleaks over full history) failed on three historical findings: two generic-api-key hits in scripts/run_retrieval_eval.py and PLAN_GRAPH_MEMORY_IMPL.md (commit 99751f1), and a private-key hit in devhive/tools/repo_tools.py (a5d0cc1).ci.yml already sets. The third is the ingest secret-guard’s own matcher constant (the literal PEM header it refuses to ingest). Nothing to rotate..gitleaks-baseline.json, but that baseline was written with --redact, so each entry stores Secret: REDACTED; gitleaks compares baseline entries on the whole finding, so they never matched anything. The PEM constant was covered by a path allowlist for enhanciar/tools/repo_tools.py, which stopped covering the historical commit once the package was renamed from devhive/..gitleaksignore lists the exact fingerprints (commit:path:rule:line), each with a written justification — no path globs, no rule disabled. It also pins a fake api.acme.io curl example in mock data that only gitleaks ≥ 8.22’s curl-auth-header rule flags, so bumping the pinned 8.21.2 does not re-break the build. Verified locally: zero findings on both 8.21.2 and 8.30.1.<title> said “the company brain that shows its receipts”, the H1 said “Know what breaks before you decide” and every post said “why the code is the way it is”. Title, OG and Twitter titles now read “the company brain that knows why your code is the way it is”. The hero H1 is “Know why your code is the way it is.” in both the live React hero and the crawler-visible fallback, which must match or it reads as cloaking. The animated “breaks” word treatment went with the old headline.Organization.sameAs, next to X, GitHub and the MCP registries. sameAs is the list of profiles Google uses to tie the name “Enhanciar” to this site.-w -C -C, git log -L and -S, commit → PR, review threads, what to do when nothing was written down) with Article and FAQPage JSON-LD. Enhanciar appears once, at the end, as the automated version of the same search. It is linked from the sitemap, the crawler-visible footer and llms.txt.not measured, not 0: LinkedIn has no page-analytics API on a personal-admin page and X’s is paywalled, so impressions are typed in by hand and a blank must not read as a measurement. (3) Impact is labelled correlation, not attribution on the page itself — GA4 reports social referrals as t.co / referral or Direct, so no session can be traced back to a comment.admin/ is gitignored (local-only tooling), so the page itself never ships — but the log lives in docs/business/promotion_log.json next to PROMOTION_TRACKER.md, sorted newest-first on every write so the git diff of a week’s promotion reads the same as the page. A fresh clone keeps the entire history and only has to re-create the viewer./vs/glean. The comparison page’s meta description, OG description, lede and the “Built for” row all pinned Enhanciar to teams of roughly five to fifty people, and one bullet opened with “Your team is small”. Nothing in the product depends on team size — ingest, the knowledge graph, citation verification and MCP work the same at any headcount — so the copy now reads “engineering teams of any size” / “Engineering teams, any size”. The rest of the comparison (when Glean is the better fit, the sourced side-by-side table) is unchanged.deploy.yml to deploy main to a new Cloud Run service named enhanciar. Nothing routed to it (firebase.json rewrites and the Cloud Scheduler jobs all point at devhive) and it carried 5 env vars and no secrets. Real prod stayed frozen on the pre-rename image while its env vars had already been renamed to ENHANCIAR_* — old code reads DEVHIVE_*, so the nightly freshness-probe / freshness-sweep / connector-sync jobs 503’d (“Internal worker auth is not configured”) and none of the 2026-09-20 backend fixes were live. main now deploys to devhive again (service name kept on purpose); the orphan service is deleted.ask_teammate chip instead of the generic ingest nudge, when a person resolves. Resolution reads the pages retrieval almost matched: their file_paths against the ingest-time authors index (_authors_index.json), the GitHub commits API for any file that changed since the ingest (installation token, commit author so squash-merges credit the writer, cached an hour and invalidated by the next push), connector pages’ own authors, and finally whoever ingested that source (every job records it). Everything passes through the people registry, so an opted-out person never surfaces; the asker is never suggested to themselves. New module enhanciar/ask_teammate.py./api/ask/suggest (keyword retrieval + the index, no model, always 200) and shows “might know this — Ask Priya →” in the same slot as the scheduling assistant.POST /api/ask/teammate files a slack.ask_teammate proposal, approves it (the click is the consent) and posts it as an in-channel @mention — DMs would need new OAuth scopes on every install. The Slack ts is recorded on the ask (users/{ws}/asks plus a flat ask_threads pointer) so the reply can be found. Hard politeness caps: three asks per person per week, none within a day of the last.people-answers/<slug> page with the reply verbatim, and a “From the team” section appended to the page the question was nearest to, cited to the message and marked re-verified. The chat polls while an ask is waiting and drops the answer in as a turn. The bot thanks them in-thread with where it was filed./mcp (endpoint, auth, Claude Code / Cursor / Claude Desktop set-up, the twelve tools, what it can never do), /about (founder, principles, contact) and /vs/glean (an honest comparison that says when Glean is the better fit, plus a line on Unblocked). All three use the public-page template, are in the sitemap, the landing footer, the nav of every public page and llms.txt.apis.google.com iframe loaded on every visit only to check whether the visitor was already signed in (so they could be bounced to /app), and GA4’s 150 KB library booted before first paint. The signed-in check now runs only when the browser carries the dh_authed marker from a previous sign-in — a first-time visitor never downloads Firebase — and gtag is queued at t=0 but fetched on idle or first interaction, so no event is lost. Verified locally: fresh visitor makes zero Firebase/gapi requests; a returning user still gets the redirect.list_pages with no bearer token; the server answered with the intended “Unauthenticated, pass Authorization…” error and Sentry paged anyway. The before_send filter now also drops ToolErrors whose message is one of ours about auth or membership (unauthenticated, invalid or revoked key, not a member, refused). A tool that fails for any other reason still reports.utm_source/medium/campaign; GA4 already read them for page views, but a join stored only document.referrer, which X, LinkedIn and Slack blank out. The landing now keeps the first utm_* seen in sessionStorage (dh-track), the waitlist modal forwards them with the join, waitlist.join stores them, and the admin Waitlist table shows utm_source/utm_medium per entry — so “12 joins from HN, 3 from X” is a column, not a guess.Organization.logo were still the old blue “E” tile — and the logo URL sat under the robots-blocked /enhanciar/ prefix, so Google could not fetch it and showed the bare domain as the site name. All icons are regenerated from the brain-nodes mark (favicon.svg/.ico, 16/32, apple-touch-icon, new icon-512.png), the logo points at the root, and WebSite.alternateName is set so the site-name algorithm has every signal it asks for./enhanciar/ — a prefix robots.txt disallows — plus one that 301s and the internal changelog; it now lists the eight public root URLs only. /benchmarks declared a canonical of the old /enhanciar/benchmarks.html and linked to the changelog and /enhanciar/api-docs; fixed. The <title>/OG title (“The Company Brain for developers and teams”) now matches the rest of the site (“the company brain that shows its receipts”). JSON-LD: Organization.sameAs filled (X, GitHub, Glama, registry); Solo/Team offers carry availability: PreOrder since sign-up is waitlisted; the two Early Access FAQ answers no longer assert a fixed rupee price, an instant unlock or a lifetime discount — they say what the page says (priority invite, credit, 10% first year, refundable). Firebase Hosting now sends the same security headers Cloud Run already did (nosniff, Referrer-Policy, X-Frame-Options, HSTS with preload). The crawler-visible footer links every public page (before, only privacy/terms), and /llms-full.txt ships alongside /llms.txt. Left as findings, not changes: mobile TBT 640 ms from the Firebase Auth iframe + GA4 loading eagerly on the landing; missing “Glean alternative” / “MCP server” / impact-analysis pages; no founder/about page; www. does not resolve.og:image/twitter:image pointed at /enhanciar/assets/…, which robots.txt disallows (the whole app prefix is blocked so login pages don’t get indexed), and link-preview bots honour robots.txt for images. The card image now lives at the root (/og-image.png, regenerated 1200×630 with the current line — “the company brain that shows its receipts” — instead of the old “knowledge mesh” copy), and robots.txt explicitly allows /enhanciar/assets/ so older shares resolve too./.well-known/glama.json is served from Cloud Run alongside the registry proof, which unlocks the logo, health-check history and analytics on the listing.enhanciar/ (was devhive/): every import, test, the Dockerfile, CI and the admin panel follow. Environment variables are ENHANCIAR_*; the package mirrors DEVHIVE_* ↔ ENHANCIAR_* at import so older deploy configs and .env files keep working, and both Cloud Run services were re-pointed to the new names. Logger names, log lines and docs say Enhanciar. Deliberately unchanged: the internal route prefix /devhive/api (the public alias is /enhanciar/api; Slack, GitHub and OAuth callbacks are registered against it), the Cloud Run service names and the GCP project id.__verifymcp_auth_probe_…__), bad params, stale session ids — are the client’s mistake answered as a clean JSON-RPC error, yet the SDK’s MCP integration reported them as unhandled and paged the minute the server was published; a before_send filter drops ToolError/McpError of that shape while real faults inside a tool body still report. (2) decrypt() logged at ERROR on every read of a field encrypted under a key that no longer exists (one stale row = 300+ events, since the model picker reads all keys on each page load); it now warns once per ciphertext and the UI’s _unreadable state carries the rest. The two stale fields on the affected account were cleared. (3) Ask-a-Teammate returned 502 when the bot was not in the chosen Slack channel; that is the asker’s to fix (the sheet says so) and is now a 409, so 5xx alerts mean an actual outage.registry.modelcontextprotocol.io as in.enhanciar/enhanciar (a remote streamable-HTTP server with a required Authorization header) so it surfaces in Glama, PulseMCP and every client that browses the registry. Namespace ownership is proven over HTTP: a signed proof file is served at /.well-known/mcp-registry-auth from Cloud Run — Firebase Hosting drops dotfolders from its upload, so firebase.json now rewrites /.well-known/** to the API, which also un-404s the Apple Pay domain file. The private key never touches the repo; mcp-registry/server.json is the source of truth for the listing.justify-content:space-between without the space React serialises, so it never fired; chip rows wrap whole chips rather than breaking a chip’s words; grids nested inside cards stay two-up; the graph card scales its 800×520 drawing and drops the pixel-placed labels, with its stat rows in normal flow. Follow-up: the marquee pills were still tall ovals with the text at the top on real phones — the browser rewrites the inline animation shorthand (so the selector never matched) and the fluid-width rule caught the track via its font-size: 13px, squeezing it to the viewport; both fixed, pills are single-line and centred. The wiki card’s sidebar + page grid stacks instead of being forced two-up..signup-screen (theme-personality.css), so the signed-in app keeps its own theme.ask_teammate flag (default off); needs clarify_chat for the chip UI and a Slack install for the workspace. Tests in tests/test_ask_teammate.py.frontend/src/landing/PersonalityPage.jsx is generated from “Redesign 3 v2 – Personality” by a converter (kept in the session scratchpad; the file header says so) rather than hand-ported — attributes map 1:1, inline styles become style objects, <sc-if>/<sc-for> become conditionals and maps, and the runtime’s style-hover/style-active become generated classes in personality.generated.css. Behaviour is one hook, PersonalityState.js, a line-for-line translation of the design’s state class. Cream ground, Inclusive Sans at 120px with tight tracking, a JetBrains Mono body, tinted rounded cards, pill buttons.PersonalityApp.jsx. All five “Join the waitlist” CTAs open the same modal (free join + paid Early Access Pass, with its GA funnel events); the Solo and Team pricing CTAs join the list while the waitlist is on and start the real Razorpay checkout when it’s off, so sandbox still works end to end; the signed-in redirect to /app, the social-proof toast, and the brand-name override all carry over. Footer and nav placeholders (#docs, #api, legal) resolve to the real routes.prefers-reduced-motion.white-space: pre had their newlines collapsed, which turned the skills YAML into one long line and blew the features grid out to the right — the converter now preserves whitespace there, and grid children get min-width: 0. The 1440px artboard is fluid: containers cap at 1172px, grids go single-column under 900px, the hero’s lead-and-CTA row stacks, and the fanned cards become a snap-scrolling strip on phones.Math.random, so a re-render cannot reshuffle the picture.pages / links / clusters) instead of one run-on sentence, and the canvas carries the key on the drawing: one node = one page · size = connections · click to read it. The prose stat line stays for screen readers.POST https://openrouter.ai/api/v1/chat/completions, and our field asks for a Base URL, so the endpoint is what gets pasted — reasonably. The verifier then requested …/chat/completions/models, got a 404, and _verify_status_for mapped 404 to invalid_key: “the provider does not recognise this key.” The key was valid. Verified against the live endpoint: pasted URL → 404, trimmed URL → 200 and 396 models.normalize_base_url() strips a trailing operation path (/chat/completions, /responses, /messages, /completions, /embeddings, /models, and combinations) and runs both when the URL is saved and when a request is built — so URLs already stored wrong start working without anyone re-saving them. A 404 from a custom endpoint is now bad_endpoint, not invalid_key: we build the first-party URLs, so a 404 there really is a bad request, but a custom URL is the user’s and is the likelier thing to be wrong. And the field says what it wants: the root ending in /v1, paste either, we trim it.Follow-up: the optimistic paint was not enough on its own — every control that writes now also says it is writing. A spinner appears beside the one control you clicked (tracked by field, not by the pane-wide saving flag, so one click does not spin every switch), and the segmented control is no longer disabled mid-save: disabling the thing you just clicked is what made it feel slow, because the selection had already moved and the pane greyed out anyway.
_load_prefs now returns which providers are stored-but-unreadable, the API passes it through, and the row says Unreadable in orange with the fix: paste the key again. Three states, because there are three.prefs holds the server’s masked form, and writing a raw key into it would show the key back in its own Saved badge.title tooltip. The consequence of the mode you are on is now written out in full under the control, and it changes when you switch.overflow:hidden for its rounded corner, which clipped the model picker’s dropdown in half — a clipping ancestor is invisible until something inside it needs to escape. And ModelPicker gained a triggerLabel so its button can read as an action while the model name is stated once, larger, above it.resolve_lite_model returned one — and that returns None whenever no key reaches the provider. So the pane told users no background model existed, when in fact the model was perfectly well known (gemini-3.1-flash-lite for Gemini, gpt-4o-mini for OpenAI, claude-haiku-4-5 for Claude) and only unreachable. Unreachable is not unknown, and unknown is never rendered as none. New lite_model_name() answers “which model” without asking “can I call it”; the API now returns the name plus a reachable flag, and the line reads “…use a cheaper model, gemini-3.1-flash-lite” either way, adding “Not reachable yet — no working key for this provider” only when that is so.resolve_lite_model; naming the model needed the same table. Factored into _custom_lite_name and shared, rather than copied — a second copy of a mapping is a second thing that can be wrong about the same question.class="b", but the stylesheet only defines .bill-section .body. Text sat flush against the card edge, and three rounds of inline-style patching had failed to fix it — because the bug was a class name, not a spacing value. Read the governing CSS first.src/index.js is one key for both repos and a plain dict.update kept the last writer. The survivor is not a missing node: its file claims one repo while the edges pointing at it came from the other, so impact analysis answers confidently about the wrong codebase. Both loaders — the graph tools and impact analysis, which had separate copies of the merge — now share one rule: first writer keeps the bare key so every existing lookup still resolves, a colliding node is kept under {repo}::{key} rather than dropped, every node carries the repo it came from, and the collision is logged instead of being discovered through a wrong answer.chats/conversations drift was still live in a second consumer. Fixed in the contradiction ledger days ago; skills._EVIDENCE_CATEGORIES had the identical list with the identical omission, so every Slack and WhatsApp page was invisible to skill evidence — and a skill’s evidence is most often a conversation. Every consumer that filters pages by category is now checked against what the pipeline actually writes.lite_complete — the right place, because truncating upstream loses records while these tasks are classifications whose answers do not improve past a few thousand characters.databases/(default) was hardcoded, so anyone who had created a named database got a 404 — reported as an empty collection, silently. The database is read through the same resolver as the project id and api key, for the same reason: a second place that knows where a stored value lives is a second place that can be wrong about it._detected_sources bug because save() replaces the file wholesale. It does — but every writer goes load → mutate → save, so a second repo’s ingest adds its pages instead of erasing the first’s. Now pinned by a test, so it stops being re-suspected.{"gallery": [{"url": …}]} — one wrapper key more, and a perfectly ordinary way to store pictures. Its key list is vendor-neutral now rather than naming only the store in front of us.DEFAULT. enhanciar wrote plain text to stdout, so the “WARNING” in WARNING enhanciar.email_send: email send failed was Python’s format string INSIDE the message, not the entry’s severity. Nothing the app ever said about itself could be found by a severity filter — not in Logs Explorer, not by an alert, not by our own errors page, which reported “no errors” while a customer’s confirmation email was failing. Cloud Logging promotes known keys from a JSON line, so {"severity": "ERROR", "message": …} is all it took: _log.error is an error everywhere now.K_SERVICE) or an explicit flag; locally a wall of JSON is strictly worse than the text a person reads. And it re-formats the EXISTING handlers rather than adding one — uvicorn installs its own first, and a second handler prints every line twice, once structured and once not, which is worse than either.query alone. Free on a free tier — and not free at all on gpt-4o-mini, where a user was paying as they typed with no way to see it. Recorded now as kind lite, deliberately separate from query: “what I asked for” and “what the product did on my behalf” are different questions, and blending them hides which one is growing. Pricing needed no work — the catalog resolves it from token counts already.meter.record DROPS the call, because both counts are zero. So a provider that says nothing is estimated rather than zeroed; recording zero reports a real cost as free.server._estimate_tokens, justified by a circular import that was only half real — server imports llm_model, so the dependency runs one way and the shared home was always available. Two copies of one heuristic are two things that can drift apart about the same question. One now, in llm_model, with server delegating. Suite 2635 passed / 1 skipped.553 Sender address rejected, and _send_blocking swallowed it. Swallowing was RIGHT — a failed email must never break a signup — but it returned nothing, so the caller could not tell sent from not-sent and therefore always claimed sent. It now returns the outcome without ever raising, the API carries emailed, and the modal drops the promise when it is false: the person is still on the list, they are simply not sent hunting through their spam folder for our mistake. An unconfigured SMTP counts as failure, not success — reporting “sent” with no SMTP configured is how a local run convinces you email works./models endpoint is public, so the list is now fetched: 396 models, 21 free, free-first, every id still carrying the openrouter/ prefix that routes it.openrouter/* model.gpt-4o-mini as they typed with no way to see it. Resolved server-side and displayed, because the mapping lives in resolve_lite_model and a second copy in the client is a second copy that can be wrong.severity>=ERROR — while Cloud Run logs a quota-refused request at WARNING. 89 of the 429s that took both sites down were structurally invisible. Worse, EVERY application log line is severity DEFAULT (enhanciar writes plain text to stdout, so the “WARNING” in a message is Python’s format string, not the entry’s severity) — so no _log.error in the codebase could ever reach that page. Now both services, a WARNING floor, and a text match for app-level failures. Rows 13 → 112.import enhanciar.email_send in a new test file bound the submodule as a package attribute at COLLECTION time, silently defeating monkeypatch.setitem(sys.modules, …) in an unrelated suite. The baseline was clean, so it was mine. Lazy import. Suite 2623 passed / 1 skipped."firebasestorage" test, next to two general rules that object-store URLs do not match — they carry no file extension, and a hostname is not a /storage/ path segment. So a customer on S3, Azure Blob, plain GCS, Cloudinary or CloudFront got image_cols=[] and no gallery: the exact bug this feature exists to fix, reserved for everyone who is not the test workspace. Now one predicate over the CLASS — extensions, path words, object-store hosts, and signed-download parameters — and the two copies of that rule, which had already begun to disagree, are one.subtitle_field: "area", meta_field: "duration", y: "bookings". A model copying those onto an invoices table names columns that do not exist, and the resolver drops the whole component — silently. The example is built from the block’s actual columns, so it cannot teach a foreign vocabulary and it demonstrates the “must match exactly” rule by obeying it.what…stored and how…stored were there; the most natural phrasing of a storage-location question was not. “Where are the images stored, give me the structure” matched only on the trailing word structure — the same sentence without it returned an empty block.CustomPopularTreks had exactly ONE field — selectedPopularTreks — because every real field sits inside an array of maps below it. The chat path had understood that shape for days; the sampler kept its own shallower idea of the same data. That is the “one shape, two readers” bug from the audit, in the two readers that most need to agree.enhanciar/record_shape.py, used by the chat path and the schema sampler both. A collection now reports images:array @ selectedPopularTreks[].Details.images — the field, its type, and where it actually lives, which is the whole question._sampled_doc_counts hands that figure to the collection chooser as “~N documents”. The count is taken before reshaping, the fields after.updatedAt as selectedPopularTreks[].updatedAt. A path that is confidently wrong is worse than no path, so carried-over parent fields are now tracked through expansion and keep their own depth.reason=ok · blocks=1 · image_cols=['images'] · q='show me photos from CustomPopularTreks'. Rows expanded, the picture column detected, the block on the wire. And no [autorender] line — because the model HAD emitted a ui block, just one with no image_field. The deterministic gallery only fires when the model emitted nothing, so a nearly-right spec beat it. extractUiSpec reads the FIRST fence, so appending a corrected one could never win.image_columns — the server’s own answer to “which column holds pictures” — so a card_grid that omitted image_field takes it from there. No second copy of the detection rule on the client: it consumes the decision, it does not re-derive it.image_columns the cards render titles and no pictures, exactly as before — and every existing guard still holds, so a cell that is a javascript: string reaches no <img src>.card_grid with no image_field beside a block naming its image column — and Playwright counted 3 images on screen. An earlier run of that harness had proved nothing because a broken patch left the stub unmodified; the test only counts once it is failing first. Both halves pinned by test_chat_data_blocks.py. Suite 2577 passed / 1 skipped.Details — the map holding the name and the image URLs — before flattening could see it, so the ranked cap added minutes earlier had nothing to rank. The log showed the result exactly: cols=['price','area','maxAltitude','Details.price','rating','highlights',…] image_cols=[]. Positional truncation is a decision about importance made by insertion order.additionalPaymentInfo.["https://…"] or {"url": …} and only a bare string by accident — ranking on the raw type kept name and still dropped images. One reader of that shape, used by both._db_schema files the panes read — engine-agnostic, so a Postgres schema renders in SQL words and a document store in document words, and sampled counts are labelled sampled. No model call, no live read, nothing added to an ordinary question. Suite 2577 passed / 1 skipped.cols=['Details', 'price', 'isCustom'] · image_cols=[] — the diagnostic added minutes earlier settled it. Expansion had lifted the array into rows correctly, but each record keeps its real fields one level deeper inside a Details map. There was never an image COLUMN to detect, only a map that happened to contain one. Two prompt rewrites had been aimed at a model that was behaving reasonably.Details.contact.phone, and past that a document is usually holding a sub-TABLE, which flattening would render unreadable. The remainder is kept whole rather than exploded.price on the parent and price inside the map are different facts, so a collision keeps both full paths.images column detected from its Firebase Storage URLs, blocks=1 on the wire, and an imperative instruction naming the column and the component. The model described the photos in prose regardless.ui block of its own, the card_grid is appended deterministically. It references the block by id, so the values are the database’s, never retyped.CustomPopularTreks document expanded into 3 rows, the images column was detected from its Firebase Storage URLs, blocks=1 reached the client, and the model was explicitly told “picture URLs live in: images… USE card_grid”. It ignored it.reason=no_sql collections=[] while Tables showed 14 collections and the database was connected — so the chooser was declining, not failing. Reproducing the exact prompt it sees made the cause obvious: the privacy rule added yesterday ended with “When in doubt, NONE”, and on this workspace every trek-named collection is EMPTY while the records live in a sibling. The rule turned the normal case into doubt.budget_tokens is deprecated on 4.6 and rejected with a 400 on 4.7, 4.8 and the 5 family, which take {"type":"adaptive"}. The gate returns a MODE now, not a boolean. A third trap surfaced inside the fix: claude-sonnet-4-20250514 parsed as version 4.20250514 — “newer than 4.6” — and would have been sent the parameter it rejects.r.json() calls in DB Graph, ERD and Tables, where a 500’s error body normalises to an empty graph and the pane reports “No schema to graph” for a server error. Same false-empty class already fixed in Wiki and Code Tree; these were missed then.docs/planning/BUG_CLASS_AUDIT.md generalises each of today’s nine bugs into the SHAPE that caused it, records where that shape was swept and found safe (claims merge correctly; graph.json is namespace-wide by design), and ranks six unencountered risks by what to test first — freshness records surviving a second repo’s ingest at the top, because it is the same wholesale-replace shape that deleted the detected-sources record._firebase_conn returned the FIRST connection and firestore_query had no hint parameter at all — while the SQL path had had connection selection and a “which database?” clarify for months. So project B was unqueryable and project A silently answered questions about it. Firestore now uses the same picker and the same ambiguity sentinel, so the existing clarify chip covers it without a second protocol; two projects and no clue in the question asks which, rather than guessing.trekngo-f4e81 vs trekngo-new), so name-token overlap matches both. Project IDs are matched as whole labels and the longest match wins._db_schema file each, so Tables, ERD and DB Graph list both databases through the picker they already had._detected_sources.json is a per-NAMESPACE file that was written per-REPO and wholesale, so ingesting a second repo deleted the first repo's detected databases — its Firebase stopped being offered in Connectors and its collection list vanished from every live query. Records now merge by project identity, keep both repos as witnesses, and union what each repo knew. The connector card says found in <repo>._firebase_conn returns the WHOLE saved connection; the ids live nested under config. The chat path unwrapped that correctly. The schema sampler, written later, read project_id at the top level and therefore always found nothing — while is_firestore_connected kept answering True, because it checks the nested value. Hence the contradiction on screen: a connection named after its own project, reported as having no project.firebase_project(uid) resolver now owns the shape and both paths call it. A second place that knows a data shape is a second place that can be wrong about it.{"project_id": …} that nothing ever saves. That is precisely how the bug passed its own suite while failing in production. The stubs now use exactly what the one-click connect writes, so the tests exercise the real path.chats; the connector pipeline writes conversations. _KIND_TO_CATEGORY maps a CONVERSATION source to conversations, and the ledger’s category list still named the older chats — so every Slack and WhatsApp page was skipped in silence. That is the ledger’s most human source of disagreement (two people saying different numbers in a thread), removed by a word nobody re-checked when the pipeline was renamed. Both names scan now, so pages already on disk under the old one keep working.entities, concepts and flows are LLM prose ABOUT code, so a disagreement between two of them is a summarisation artefact rather than two humans contradicting each other — the exact false positive that trains a team to ignore a doc-drift bot. A test now pins that, so changing it needs a reason rather than a nudge./api/db/connections dropped every connection that was not Postgres or MySQL, because its first and only consumer was an introspection picker that needs a DSN. Every pane added since read it as “what does this workspace have connected” — so a Firebase-only workspace answered nothing. Connections are now all returned with an introspectable flag; the picker filters, the empty state does not.null and a failed lookup stays there, so a network blip can never render as “no database connected” — the same rule the action-routing note needed.jsonb (or MySQL json) column holding an array of objects is the identical “one cell contains the whole table” problem, and the SQL path now gets the same treatment. It stays deliberately narrow: one-or-two-row results where exactly ONE field is an array of 2+ objects. Two array fields is ambiguous — which one is “the” table? — so nothing is reshaped; an array of scalars is a list, not a table; a many-row result keeps its own row identity. Ordinary relational rows are returned untouched, by identity.items array out of an order row keeps order_id on every produced row — dropping it would silently lose the thing that makes the rows mean anything. Suite 2498 passed / 1 skipped.CustomPopularTreks is one document whose array holds every featured trek — so the working “treks between November and January” answer was reading the whole catalogue out of that one uncapped cell. Truncating at 400 characters cut it mid-array: verified before shipping that Trek 6 had vanished from the block. A cost fix that silently deletes data is not a fix.Orders collection and printed real customers’ email addresses into the answer. Nobody asked to see records. The collection chooser was told how to pick a RELEVANT collection but never told when to pick NONE, and a question about how the system works is answered from code and docs — never from customer data. That rule is now explicit, with “when in doubt, NONE”: a missing table costs a follow-up question, the wrong one exposes people’s personal data.winterTrek and Treks hold zero documents; the records are in CustomPopularTreks. The sampling pass that fills Tables and ERD already knew the doc counts and nothing read them back — the chooser now sees EMPTY (0 documents — cannot answer anything) beside each name, which is the cheapest possible way to stop an empty collection being chosen.reason=ok collections=['CustomPopularTreks'] blocks=1 instead of the placeholder it printed before. Every one of these was a lookup, not a guess. Suite 2485 passed / 1 skipped.await AFTER the prompt was built. user_prompt is an f-string interpolated from context_text; assigning the fetched rows into that variable afterwards changes nothing, because the string had already been copied. So every turn queried Firestore, paid for two model calls to choose collections, and then told the model “no relevant pages found”. The answer that came back — “the provided wiki context does not contain photos or scheduling data” — was correct, which is exactly why it was hard to see.data_blocks had the same bug one level up. The route built them from a detail dict before the coroutine that fills that dict had been awaited, so every turn shipped zero blocks. No question could render a card, a table or a photo from live rows, no matter what the model asked for. Both now read after the fetch, through one shared _blocks_from_detail.[live-data] reason= line was logged in the route, which only ever held the placeholder — five hours of production logs all reading reason=deferred collections=[]. It now logs inside the stream, after the await, with the real reason and the block count._gemini_supports_thinking was a substring test for “2.5”, written when 2.5 was the newest model — so it silently answered false for Gemini 3, the default in the picker. Nothing failed; the capability check had simply aged out. It compares a version now, so 4 and 5 need no edit.GZipMiddleware was registered AFTER the middleware whose entire job was to disable it for streams, and Starlette wraps the LAST-registered middleware OUTERMOST — so gzip ran outside the stripper and read the original Accept-Encoding on every request. The strip could never fire._StreamAwareGZip middleware decides per path — streams pass through untouched and get X-Accel-Buffering: no, everything else is compressed as before. There is no longer a registration sequence that can silently undo it, and a test fails if a bare GZipMiddleware is ever added back.content-encoding: (none), 31 network chunks spread over 2,695 ms, and the screen going “Checking your connected sources…” (133 ms) → “Writing the answer…” (1,122 ms) → text growing 451→937 chars → 3 photos rendered. Assertions that stop at the Python layer cannot see transport, which is exactly where this lived.None is not []: treating “could not ask” as “no tracker is connected” states something false about the user’s setup and sends them to a flow they already finished. Unknown drops the sentence entirely, keeps the boundary, and is deliberately not cached — a transient blip must not hold the degraded answer for the next minute of a conversation._norm_source collapses every repository to github so the Graph’s filter chips stay usable. Merging needs the opposite question. Two repos in one workspace that both described an entities/user.md took the same-source overwrite branch, and repo B replaced repo A with no error, no event, and nothing in any log. Merge identity is now repo:<name>, so those two pages combine into attributed blocks — which is what the marker machinery was built for. Found while answering “does anything break with multiple things ingested?”; the answer was yes._split_frontmatter returns the body still carrying the newline after the closing ---, so the anchored ^# in the note-refresh substitution never matched and the sub silently did nothing. Frontmatter counted every source correctly while the line a reader actually sees stayed frozen at whatever it said after the first merge._db_schema/, and only the SQL introspector ever wrote one — so a Firebase-only workspace saw “No entities yet” three times while chat was answering questions off live rows from that exact database. Firestore has no catalog to read, so the schema is now SAMPLED: columns unioned across documents, types inferred, nullability counted by VALUE (the reader flattens documents into shared columns, so counting keys would call every column non-nullable and say nothing). Relations come from Firestore referenceValue pointers and <collection>Id fields whose target is a collection we actually have — never from name similarity, and flagged heuristic so the ERD’s source filter can switch them off. Every count is labelled a sample, because counting a collection properly means walking all of it./tmp path and no way forward. repos.json stores where a clone LANDED, inside a per-instance temp dir; the file is mirrored durably, the directory is not. So on every pod but the one that ingested — and on every pod after a deploy — FK extraction followed a valid-looking path to nothing. New repos_origin.json records where each clone CAME FROM, is mirrored beside repos.json, and a missing working copy is re-cloned instead of reported.["https://…"] or {"url": "…"}, and the wire-safety step flattened both with str() into ['https://…'] — not a URL. The rows held the images and the block discarded them one line before the wire. Cards now take an image_field; only http(s) reaches an <img src>, because that cell is database content and therefore attacker-reachable on a shared workspace. A dead URL removes the element rather than showing a broken-image glyph that reads as our bug.summary + project_key — while GitHub reads title + repo. The proposal was not mislabelled; it genuinely had no title, and would have been filed that way. The field map now lives in one module both producers import. The ticket body also carries the QUESTION, not just the answer: a description consisting entirely of “I cannot update the database directly” tells whoever picks it up nothing about what was wanted.RuntimeWarning per cached request. Suite 2439 passed / 1 skipped.r.json() with no r.ok check, so a 500’s error body became zero pages and the pane said “No pages yet — ingest a repo” to someone whose wiki is full. Each now separates “nothing here” from “we couldn’t ask”, and says which.flex: 0 0 auto, then verified every placeholder reports real pixels (78×26 pills, 239/195/150×11 list rows) with the shimmer live. Honours prefers-reduced-motion; announced via aria-busy rather than being purely visual.wiki/<ns>/ that nobody added to the durable-mirror allowlist, so it lived exactly as long as the pod. A full sweep of the codebase for namespace-root writes found two more still open — and the new guard immediately found a third I had missed.repos.json (now mirrored) — the repo NAME index. Its stored paths are worthless on another pod, which is fine (every consumer guards with isdir and falls through to the GCS raw store) — but losing the index made _augment_context_with_code return nothing on a cold pod, silently dropping source-grounded code answers for BYOK users while the sources sat safely in GCS._db_schema/ (now mirrored) — the introspected database structure behind the DB Graph and ERD tabs. Read straight off disk with no regeneration path, so both tabs went blank on any pod that had not run the introspection itself: a feature that looks deleted rather than one needing a click._system/ (now mirrored) — a team’s own code-review guidelines. Written through the wiki API (durable) but invisible to the end-of-ingest sweep, which skips underscore dirs. Whether your guidelines survive should not depend on which code path created them.tests/test_storage_durability.py now scans the source for namespace-root writes and fails when one is neither mirrored nor listed in EPHEMERAL_BY_DESIGN with a stated reason. Adding an artifact and forgetting the decision breaks the build at the only moment it is cheap to fix. Suite 2404 passed / 1 skipped.repo_ever_ingested pulls the _state artifact alone onto a cold pod to answer “was this repo ever ingested?” — creating wiki/<ns>/_state/ and nothing else. _ensure_fresh then asked bool(listdir(dir)), saw a non-empty directory, concluded “pages are present”, and skipped the Firestore restore. Meanwhile namespace_has_pages — which correctly skips underscore-prefixed system dirs — reported an empty brain. The log line said it exactly: “Auto-rehydrated 0 wiki pages + 19 graph artifacts”.empty_brain. The per-pod “already rehydrated” marker then made it permanent for that instance.empty_brain.namespace_has_pages. And because artifacts-on-disk-with-no-pages is a state no ingest ever produces, a pod that finds itself in it retries the page restore exactly once — bounded, so a genuinely page-less workspace still costs one Firestore stream, and an already-poisoned pod self-heals instead of staying empty until it recycles._state-only directory does not suppress the restore; real pages still skip it; the incoherent state is retried once, not per request. Suite 2400 passed / 1 skipped.data_blocks event ahead of the text, and the spec just points: {"type":"card_grid","data":1,"title_field":"name","pick":[0,3,7]}. The model contributes column names and row indices — never values — and the client joins spec with rows. pick selects and orders by index, so “show only the winter ones” works without a single retyped cell.line_chart component — hand-rolled themed SVG (no charting dependency): sorted time/number axis, gridline ticks, dot markers. Cells that aren’t wire-safe are stringified and capped server-side before they cross.<state_key>__<branch>.json; the diff loaded exactly that, but the per-domain gate re-loaded with a different key and no branch — a file that does not exist. Empty state read as “first run”, first runs never skip, and every push re-extracted the entire repo on the user’s own key while claiming incrementality two lines up.had_prior_state) — one state read, one answer, nothing to drift. Two tests pin it: the gate contains no second load_state, and no branchless load_state call exists anywhere in the pipeline.ENHANCIAR_JOB_EXECUTOR=cloud_tasks (prod has it), so the job ran in-process on a scale-to-zero, CPU-throttled instance. Every manual run had survived only because the open event-stream tab pinned the instance; the first webhook run — with no request to pin anything — was killed by the first scale-in and sat at “running” forever. Sandbox now dispatches via Cloud Tasks like prod (queue, worker URL and SA were already configured; only the selector was missing), and the orphaned job is marked failed with the reason.package.json, firebase.json and a projectId. The asymmetry read as a detector that guessed. The worker’s database sightings now carry a files field, and the card’s evidence renders whatever the sighting actually holds: the files where the database code lives (checkable), how the app connects, the tables/collections read out of the code, and the env vars involved.mysql://REDACTED@host/db) before it can render, the same house rule the regex evidence already follows: name where a credential lives, never print it back.key_id rode into a stored evidence quote — by policy, not by accident. The scanner deliberately leaves public payment identifiers alone (they ship in frontend checkout code by design, and database auto-detect reads them), so redaction passed it through into _claims.json. But the sidecar is durable, mirrored and rendered, and “a key_id is configured at index.js:12” needs no value. The storage gate now excises Razorpay key ids and Stripe publishable keys on the way in — scanner policy unchanged everywhere else.const AllTreks = () => … parses to zero named declarations, so the coverage sweep’s “substantive” filter skipped it — the one file of 42 that got no page on the verified run. An arrow-function component still calls things (hooks, fetch, map) and the AST records those, so calls joins functions/classes/routes/tables as a substantive signal. The exclusions still hold: a barrel file re-exports without calling; a static data array calls nothing.[[Razorpay API]], [[Material UI Icons]] — and writes case-variants ([[Edit Camps]]) that the graph’s exact-slug soft-edge match silently fails to resolve. A dead link is milder than a missing document, but it advertises a page that isn’t there, and a case-variant quietly LOSES a real graph edge.file_paths and puts exactly the missed files in front of the worker once more — one follow-up call, listing only what was skipped, with a skipped escape hatch for files that genuinely are trivial. Deterministic on both ends: measured before, measured after, and the event stream reports “sweep recovered 21 of 24 file(s)”. Fail-open — a sweep that errors leaves coverage what it was, never breaks the ingest. Verified on a full offline pipeline run before this shipped.confidence: high was the model grading its own homework — 58 of 62 pages on the fresh run, while its own verifier found 19 unsupported and 150 unverifiable claims. The API now serves claim_confidence, derived at serve time from the claim-verdicts sidecar (worst verdict wins: unsupported→low, partial/unverifiable→medium, all-supported→high), and the wiki page header shows it as a colored pill. Absent — not defaulted — when a page was never checked, so “unverified” can never dress up as “high”. Nothing is written into the page: frontmatter stays the model’s words, the audit stays the system’s.file_paths/routes/env_vars unioned (the same component seen from two directories is one entity, and dropping the duplicate’s paths would orphan files from the graph); concepts, flows and relationships deduped by key; the merged JSON is byte-shaped like a single worker’s output, so checkpoints, resume, the integrator and the page-writer needed zero changes.read_single_file for content. Budget default 8000 → 32000 chars (≈8K input tokens against a 1M context).secret_scan, so a repo-committed secret went into the tier-2 critic’s prompt, came back inside an evidence quote, and was stored in _claims.json — a durable artifact mirrored to GCS and rendered in the UI. The tell: the deliberately-public Firebase apiKey did NOT appear in the sidecar while the two real secrets did — the redaction path and the evidence path disagreed on exactly the lines that matter.SourceIndex.read — the only read feeding the critic — redacts at the read, so the model never sees the value and its quotes are quotes of the same redacted text it was shown (keeping evidence-location consistent). And claims._clean_claim — the only gate into the sidecar — redacts quote, text and why, whatever route a record arrives by, including tier-1 AST evidence and any future caller._claims.json in GCS was rewritten through the same redactor; zero hits on re-check, and the deploy recycles any pod still holding the old local copy. The leaked pair is a test-mode key and is being rotated regardless.get(path) or get(basename) lookup site stays correct without change: the fallback misses instead of lying. Same refusal rule the claim verifier already reads by._detected_sources.json during the clone, to the pod that ran the ingest — and that file was never in the GCS artifact mirror. The next restart lost it, and the suggestions route read the absent path back as an empty list. Detection was never broken; the file simply evaporated. It is now a durable artifact like graph.json and _claims.json: mirrored at end of ingest, restored on the first read from a cold pod. Existing brains get theirs back on their next ingest.confidence: high while 38% of claims verified as supported — and one page described environment variables and upload flows for a file that is a static data array. The page-writer prompt let the model pick its own confidence with no rubric and mandated “2-4 paragraphs, go deeper” with no way to decline. Confidence is now earned against a rubric (padded page = low), section lengths follow the evidence, and the fabrication classes the audit caught are banned by name: no invented env vars, no imagined behavior for static data, empty arrays are good answers.env_vars, routes, tables_used and external_services may only contain names literally present in the AST facts or source — a hardcoded config object is not an environment variable — and inferences must be marked “Likely” and kept out of those fields. This is the Karpathy wiki rule (“say the wiki has no confident answer instead of synthesizing”) applied at write time, where it belongs.[object Object]. Two different 409s reach the Ingest button and they are shaped differently: the duplicate-ingest guard sends a plain sentence, while the workspace-key failure sends a structured object — {code, message, owner, workspace_name} — precisely so the UI can tell an owner (who can add the key) from a member (who cannot). The client assigned j.detail straight through and handed it to new Error(), which stringifies an object to [object Object]. The real sentence was sitting in .message the whole time, naming the workspace and exactly where to add the key.workspace_keys on, a TEAM workspace runs on ONE owner-supplied key and a member’s personal key is deliberately never consulted — so an unkeyed team can neither answer nor ingest, and the only way anyone found out was by trying. A banner in the header now names the workspace, and tells a member WHO can fix it rather than sending them to a page that cannot help them. It appears only when the feature is actually on: with the flag off, key resolution is byte-identical to personal keys and the warning would be a lie. Personal workspaces never show it./api/jobs every 3 seconds regardless of state — roughly forty requests a minute to watch a page where nothing was happening. The fast rate is only worth paying for while there is progress to show, so it drops to 20s when no job is active.[object Object] could only have come from the STRUCTURED detail, and the guard sends a string. The data was fine and there was no duplicate — the workspace simply had no key. Suite 2353 passed / 1 skipped.scheme://filter — notion://* for everything, gdrive://<folder> for a subset — and the label was built by splitting on “/” and taking the last segment, so a whole-connector sync rendered as a bare asterisk, which names nothing. It reads “Notion” now, and “Google Drive · <folder>” when a filter is actually set.PRICE_IN 0.00125 / PRICE_OUT 0.005 per 1k tokens — to every call whatever ran it. Replacing that with a per-model table on the page was still wrong, because model_catalog.py has been resolving prices all along: LiteLLM’s maintained catalog (input_cost_per_token, kept current by people whose job it is), falling back to a small curated table, with an explicit free-tier set. The model picker has been showing those numbers the whole time. The page’s copy was the wrong one — its rate for gemini-3-flash-preview had been recalled rather than looked up.GET /api/pricing delegates to the same price_for() the picker calls, so a figure on the Cost page and a figure in the model dropdown cannot disagree. The Cost page needs a lookup BY ID rather than the picker’s catalog, because it prices historical calls whose models may no longer be in the user’s available list. Nothing in the frontend knows a price any more; a test fails the build if a literal rate reappears there.$0.0000 — those look identical and mean opposite things, and the catalog’s rule is explicit: never fabricate a number for an unrecognised id. A total whose mix contains an unpriced model is marked partial rather than quietly under-reported, and unpriced models are excluded from the effective-rate blend rather than counted as zero, which would drag the rate down.gemini-3-flash-preview is in the picker at all — the question that surfaced this. The model list is not hand-written: available_models asks each provider what the user’s own key can reach (/v1beta/models for Google), with a static list as a floor so an outage degrades rather than empties the dropdown. Google’s API lists that preview id even though its public pricing page does not carry it; LiteLLM does, which is where the picker’s $0.50/$3 comes from. So the id is real and callable — the gap was only ever in MY table, not in the product.pipeline:enhanciar_scout calls). The workers died afterwards at instruction-render time, before any model call, so they cost nothing. The money bought real work, and that work is checkpointed: Resume does not buy it twice.uid != caller — so the invited member saw IDLE and an empty event stream beside a Recent Jobs row for the very run they could not open. Watching is now allowed for members of the workspace the job FILLS. It fails CLOSED: a membership lookup that errors denies, because this decides who reads a job’s stream — the opposite direction from the duplicate-ingest guard, which fails open because it only prevents waste.${VAR}, which .format() read as a replacement field: KeyError: 'VAR', every worker dead, 0/117 documents. Escaping it to ${{VAR}} fixed that and broke it again one layer down — .format() turns {{VAR}} back into {VAR}, and the rendered string is handed to ADK as an agent instruction, where {VAR} is a SESSION CONTEXT VARIABLE. Result: Worker error: 'Context variable not found: `VAR`.', every worker dead, 0/117 documents. The same outage, twice, from opposite fixes..format() succeeds — which after escaping it did. Braces are load-bearing to two templating engines in sequence, and surviving the first is not surviving the second. The rule is not “escape the braces”; it is that the RENDERED instruction must contain no brace-wrapped identifier at all. The prose says shell $VAR now, which neither engine touches.{identifier} and fails with the reason. Verified by reintroducing ${{VAR}} and confirming the suite goes red, then restoring — a guard that has never been seen to fail is not known to work.sc-domain:enhanciar.in, so Googlebot crawls sandbox.enhanciar.in as part of the same site. It asked for /robots.txt to learn the rules and the password gate answered 401 — the same as everything else.X-Robots-Tag: noindex on every gated response was doing its job; it never got the chance to add “and don’t bother asking again”./robots.txt is exempt from the gate so they can actually be delivered. Nothing is revealed: the response is a sign on the door, not a key./robots.txt (and its two path aliases) precisely. A startswith rule — which is how every other exemption in that list works — would have quietly opened /robots.txt.bak and anything else sharing the prefix. Tests pin both halves: the file is reachable, and the app behind it is not.git ls-remote against the last-ingested SHA and, when they match, returned before cloning, parsing or spending a single token. But that answer appeared in the event stream AFTER the job started — so the button still read like a bill you were about to incur, and the only way to find out it was free was to commit to it. Same check, moved in front of the click: “Already up to date at a1b2c3d — no new commits, so this run costs no tokens.” A first ingest says so too, since that one really does read everything.unknown and the strip says nothing at all. Claiming a run is free when we do not know is the one wrong answer this feature could give.gdrive://folderA and gdrive://folderB stay independent work, and a Notion sync never blocks a repo ingest.ls-remote; firing one per keystroke in the paste box would be a network round trip per character.unknown rather than guessing.ns == uid, so nothing changes there.repo_source helper, because the picker sends owner/repo and the paste box sends the full address — and a guard that treats them as different repositories is bypassed by using the other box./api/branches recognised only sources starting with http. The repository PICKER puts a bare owner/repo slug there, so it fell through to the local-path branch, found no .git, and returned nothing — and the UI fell back to a hardcoded main. For enhanciar/treak_n_go_final-master, whose default branch is master, the dropdown offered exactly one option, main, and choosing it produced fatal: Remote branch main not found in upstream origin. The ingest pipeline had always promoted the slug correctly; it just did so INLINE, so nothing else could share it.enhanciar/repo_source.py, used by the branch lookup and by the pipeline. Two copies of a regex cannot promise agreement, and this is what disagreement looked like: the picker and the thing that clones held different opinions about the same string the user chose.ls-remote cannot see them, so the list was empty for precisely the repositories the GitHub App exists to reach. The lookup now mints an installation token when the caller’s ACTIVE workspace has an installation that can read that repo — the same visibility rule as the rest of the GitHub surface. A token failure falls through unauthenticated rather than blanking the list, because a public repo still works that way.redact_secrets — the same scrubber the job event stream uses, which knows the x-access-token shape..rstrip('/'), plainly meaning to accept owner/repo/ — but the regex ran first and rejected it, so the strip was unreachable and a pasted fragment with a trailing slash stayed a “local path” forever. The trim now happens before the match, which is what it always looked like it did.foo/bar is NOT mistaken for github.com/foo/bar, the full-name extraction the installation lookup depends on — and, statically, that agent.py has not grown its own slug regex again. Suite 2321 passed / 1 skipped._resolve_workspace_ns the same way. In a personal workspace ns == uid, so nothing changes there._integration_data, which reads the ACTIVE workspace’s document — so a connector connected while a team workspace is active is stored on the team and visible to its members. Slack matches on a stamped workspace_id, so a team install is found by the team; legacy installs without that field surface only in the installer’s personal workspace, deliberately, because folding them into every workspace the installer belongs to would silently widen what a team brain can read. GitHub now behaves the same as both.workspace_keys on, a team resolves its key from the WORKSPACE, so someone who had already configured a perfectly good personal key was sent to a form to paste it a second time — a form that cannot show them what they typed the first time. The likely outcomes were a typo, or a key pulled out of a password manager into a clipboard that did not need to hold it. There is a Use my own key button now.users read is the interesting one: it must NOT be scope-resolved, because resolving through the active workspace would copy the workspace’s key onto itself and make the button a silent no-op. Suite 2302 passed / 1 skipped; eslint 0 errors; build green.installations_for_workspace had long proposed “any installation owned by any member of this workspace”. That is deliberately NOT what shipped, because it leaks: install the App on your personal account for your own use, join a team months later, and your private repos would appear to colleagues without you ever saying so. What is shared is what its owner explicitly attached to the team using the picker on the Installations row — an affirmative act by the person whose GitHub account and LLM key are involved.is_owner so the UI can hide what a viewer may not change.workspace_id entirely, so every installation it adopted came back bound to nothing and its row read “pushes fill your personal brain” while the header said testing team. Re-sync is such a moment too: a button pressed in the app with the switcher on screen. It resolves through _resolve_workspace_ns, so a header naming a workspace you are not a member of still falls back to personal rather than binding someone else’s brain to your pushes.…/enhanciar/?invite=<token> and nothing in the app ever read that parameter — the token was dropped on arrival. It worked at all only because the Workspaces banner matches invites by EMAIL rather than by token, so a new person had to sign up, then discover a pane they had never heard of to find the invitation the email had just promised to accept for them. The token is redeemed on arrival now.sessionStorage, not React state: the un-signed-in case is the entire point of the link, and that case bounces through an auth provider and back, which discards anything in memory. The parameter is stripped from the address bar immediately — a live token sitting in a URL gets pasted into chat, shared in screenshots, and re-fires on every reload — and it is cleared BEFORE the request, so a failure cannot leave something that retries on every render for the rest of the session.status: already_member and the UI says “You’re already in Acme.” The friendly path is not a way in — only a CURRENT member gets it; a different account presenting the same spent token is still refused.AlreadyMember subclass of InviteError, so every existing except InviteError keeps behaving exactly as before and only the caller that cares looks for it first. The membership check happens outside the transaction, so the transaction’s read set does not widen.already_member with the right workspace; a spent token still 400s for an account that never joined. Suite 2295 passed / 1 skipped; eslint 0 errors; build green.null on ANY non-OK response and never threw, so the catch written for it never ran and a GitHub API failure was indistinguishable from a fresh install: the button bounced you to GitHub to install an App that was already installed, and said nothing about why. It now has three outcomes — request failed (say so and stay, because sending someone to re-install over a 502 is how you end up with two installations), linked > 0 (adopt and reload), linked = 0 (genuinely nothing to adopt, go install). The failure message names the status or the server’s own detail._APP_URL was a module constant hardcoded to enhanciar.in. The sender name already read “Enhanciar [sandbox]”, but the buttons in the body did not — so a sandbox invite could only be opened on prod, where the workspace it names does not exist. Links are resolved per environment now: ENHANCIAR_APP_BASE_URL, else PUBLIC_BASE_URL (what each service already publishes about itself), else production. PUBLIC_BASE_URL is read and never written — on sandbox it is pinned to the run.app origin because that is what the Google OAuth redirect is registered under — so the override names the friendly host for mail only.workspaceMembershipChanged event — named a word apart from the existing workspaceChanged (“the ACTIVE workspace switched”) precisely because two near-identical event names are a trap.invited_by as a raw uid, so the banner could only manage “you’ve been invited to this team workspace” — an unsigned request to join someone’s shared brain, when who is asking is the single most relevant fact for deciding. The route resolves inviters in one batched lookup, exactly as it already resolves members, so the UI never receives a bare uid. A deleted inviter degrades to the generic wording rather than printing an opaque identifier, and no lookup runs at all for an unverified invitee, who is shown no invites anyway.POST /workspaces followed by one POST /workspaces/<id>/members per address, stores the new workspace as active, and completes the flow. Suite 2287 passed / 1 skipped; eslint 0 errors; build green.localStorage; the switcher fetches the actual list from /api/workspaces. Nothing compared them. So a workspace that had been deleted, left, or wiped server-side survived in storage indefinitely: the switcher rendered its placeholder because no option matched, headerFor() kept putting the dead id on the wire, and every “into <workspace>” label kept printing the remembered NAME. That is how a GitHub repo card promised an ingest into “testing team” while the switcher directly above it showed nothing selected. The list now clears a stored id it does not contain.'') plus each team workspace, so the placeholder shows only when the stored value matches no option — which is precisely the stale case. With the id cleared the control reads Personal again, which is both accurate and a great deal easier to understand than an empty prompt._resolve_workspace_ns verifies membership and falls back to the personal namespace for a non-member, so the dead id resolved harmlessly. The bug was that the interface named a destination the server was never going to use — quietly wrong, which is worse than loudly wrong, and the same shape as the namespace bugs fixed earlier: written under one identity, read under another.GitHubPanel read the active workspace bare during render, so it would have kept printing the dead name until some unrelated state change happened to re-render the pane — exactly the window in which someone presses “Ingest code”. It holds the value in state and listens for workspaceChanged, the event the switcher already fires.localStorage with a dead id named “testing team”, stubbed /api/workspaces to return a list without it, and rendered the real panel: the card first read “Read it now into testing team”, and after the reconcile the stored id was null and the card read “Read it now into your personal brain”. Suite 2287 passed / 1 skipped; eslint 0 errors; build green.<datalist> on the repository input. A datalist is autocomplete: invisible until you type a matching prefix, so the repos you had deliberately connected were discoverable only by guessing at their names. They are a real picker now, built on the app’s own StyledSelect rather than a bare <select>, searchable past eight repos, listing owner/name exactly as the ingest route expects. Choosing one sets the same source value typing it would, so the branch lookup and the submit path are untouched.clone_repo() takes a plain HTTPS URL with no token, so any PUBLIC repo ingests with no GitHub App installed at all — and the ↻ sample button fills github.com/karpathy/nanoGPT, which is in nobody’s installation. “Repos you have connected” and “repos you can ingest” are therefore different sets, and a picker alone would have removed the only try-before-you-install path in the product. With nothing connected the field is the text box it always was; with repos connected the picker leads and “Paste a URL instead →” is one click away, with “← Pick from your N connected repos” to go back..linkish treatment; its selector was widened from .note-row to cover .ic-note as well, rather than adding a second class for one visual treatment — which is how two link styles start drifting apart.installation.created for an App it has already installed, so the honest-looking button was the one that could not work, and the one that could was labelled with a word describing our internals. There is one button now. It tries to re-link silently; if something orphaned turns up it is adopted and you never leave the page; only if there is genuinely nothing to re-link does it send you to GitHub. Once an installation IS linked there is nothing to re-sync, so the button becomes a plain manage link out to GitHub.window.open after an await falls outside the click’s user-gesture window and popup blockers eat it — the button would appear to do nothing at all, which is a worse failure than the one being fixed. GitHub redirects back on its own via the setup callback, so a same-tab hop is also tidier: no orphaned tab left behind. The “30 seconds” instructions and the empty-repo card were both rewritten to match; they used to say “GitHub opens in a new tab” and “click install to connect”, the second of which was precisely the wrong advice for anyone already installed.flag filter Sidebar already applies to nav items rather than a second mechanism. Once connected it earns the slot: that pane carries installations, repositories, per-repo branch pickers and webhook state, which you return to far more often than any other connector’s detail view. The check re-runs on the header’s Refresh, so an install or a re-link reveals the entry without a reload, and it fails closed — a failed probe hides the entry rather than leaving a dead one.NavIcon id="link" was missing from Sidebar’s ICONS map. That was wrong: link is present, and the grep behind the claim had been truncated before reaching it. The SVG count offered as proof was taken only AFTER the change, so the “seven of eight” it was compared against was an inference, not a measurement. The row was always fine and has been reverted to link, which suits a custom endpoint better than connectors anyway. Every NavIcon id used anywhere in the app has now been checked against the map programmatically: none are missing.localStorage.getItem('enh_tour_seen') — a single unscoped key. localStorage belongs to the BROWSER, not to the account, so the second account to sign in on a machine was told it had already seen a tour it had never seen. This is not hypothetical: wiping Firestore and signing up fresh leaves localStorage completely untouched, so the new account got no walkthrough at all and the feature looked like it did not exist. Anyone reusing a laptop, a shared demo machine, or a second test account hit exactly the same thing. The key is now enh_tour_seen:<uid>, set and read at all three call sites through one tourSeenKey() helper so they cannot drift apart.enh_tour_seen sees the tour once more under their own uid. For the accounts the bug skipped that is the correct outcome and the whole point of the fix; for everyone else it is one repeat of a seven-step carousel with a Skip button on every step. Reading the old flag forward would have preserved the exact silence being fixed.2px square-cornered outline on a full-bleed row, inside a panel with borderRadius: 10 and its own 1px border, with no padding between them. The two edges touched and their corners disagreed, so it read as a rendering fault rather than as focus — worst on a one-option menu (the workspace switcher with a single Personal workspace), where the ring and the panel border became two nested rectangles a few pixels apart. The row is borderRadius: 7 now, so the ring follows the curve and sits concentric with the panel, the list carries 4px of padding so the two edges never touch, and the ring is 1.5px rather than 2px. Shared control, so every dropdown in the app is affected.StyledSelect’s trigger ended in a •-class ▾ character at fontSize: 10 and opacity: 0.6, separated from the option’s mono hint by an 8px flex gap. At that size, that weight, and that distance it read as punctuation on the end of the hostname — aistudio.google.com ▾ — rather than as an affordance, and the whole row looked like a filled-in field nobody could open. It is a real SVG chevron now: 15px, strokeWidth 1.75 to match every other icon in the app, its own layout box, and a marginLeft that separates it from the hint instead of crowding it. It still rotates 180° on open. This is the shared control, so all ~20 call sites get the better caret.auto-fit/minmax rather than three fixed columns — at 375px three columns would be ~110px each and the body copy would break after two words, so it collapses to one column on its own.NavIcon for id="link", and there is no link in Sidebar’s ICONS map. NavIcon returns null for an unknown id — no warning, no fallback, no error — so the eighth row drew a bordered 26px tile with nothing inside it while the seven brands above all had a mark. It uses connectors now, which is what a custom endpoint is. Worth naming the failure mode rather than just the fix: an icon-by-id lookup that returns null for a typo fails silently and looks like a spacing bug, not a missing asset.<svg> — the count that was seven before the link fix. At 375px: the native <select> branch renders with all eight options in “Google Gemini — aistudio.google.com” form, the three key-spend items stack to a single column, and document.body.scrollWidth equals the viewport, so nothing overflows. Suite 2287 passed / 1 skipped; eslint 0 errors, 39 warnings; build green.console.groq.com, aistudio.google.com, “your endpoint” for the generic option). That last column answers the question a first-time user actually has — where do I get this? — before they choose rather than after.StyledSelect gained an optional leading icon and a right-hand hint per option, so the ~20 other call sites get both for free and this screen keeps the full ARIA combobox pattern, arrow/Home/End/typeahead keyboard handling, and the real <select> below 900px. On that native branch an <option> can only carry TEXT, so the icon is dropped — it is decoration — and the hint is folded into the option label after an em dash, because it is a FACT about the option and must not disappear with the viewport. A new proportional flag, defaulting off, is what lets a list of proper nouns be set in IBM Plex Sans while every existing caller (branches, model ids, repos — all machine strings, where a fixed advance is the point) stays in JetBrains Mono. The hint stays mono either way; it is a hostname.providerIcons.jsx, same rule and same provenance comment as connectorIcons.jsx: path data copied verbatim, never redrawn. simple-icons — which the connector icons use — has Gemini, Ollama, Anthropic, Mistral and OpenRouter but no OpenAI and no Groq, verified against a pinned 16.28.0 where both 404. Filling those two gaps from a second set would have put two different optical weights in one dropdown, so all seven come from @lobehub/icons-static-svg v1.94.0 (MIT), an icon set built for AI providers, drawn as one family. OpenAI’s is the current mark, not the retired hex-knot. Every mark is monochrome currentColor on one neutral tile, so a row’s selected and active states stay legible — and so that nothing in the file is a guess about somebody else’s brand colour. There are no hardcoded hexes, and a test enforces that.cost field is off the onboarding provider entries entirely, not merely unrendered, because a field left lying there is an invitation to render it again. Settings → Models & API keeps costNote() deliberately — a different question at a different time, where the user is already set up and actively choosing a MODEL, and the sentence doubles as “here is where that key comes from”.keyHostOf(keyUrl) reads the hostname off the page the “Get a key →” button already opens. Two strings for one fact drift, and this pair would drift visibly: the row naming one host while the link opened another. One literal survives in the table — “your endpoint” for the generic option, which has no host until the user types one.<select> whose options read “Google Gemini — aistudio.google.com” with zero horizontal overflow at 375px, and an untouched plain StyledSelect (label + sub, no icon, no hint) rendering exactly as before. Every save path still holds: base URL absent and scheme-less both refused with zero requests, a remote endpoint with no key refused, a localhost endpoint saving with an empty key, and the Test verdict clearing on a provider switch. Ollama’s llama-head mark is the finest of the seven — it is a multi-path line drawing and at 15px inside the tile it reads a touch denser than the others. It is legible and it is the real mark, so it stays; substituting something cleaner would mean drawing their logo ourselves. Suite 2287 passed / 1 skipped; eslint 0 errors, 39 warnings; build green.POST /api/profile stamped onboarded_at on the FIRST save of any profile field, and onboarding’s step 1 posts the role / team-size / use-case answers the moment you press Continue. App.jsx reads that same flag as “already onboarded, skip the flow”. So the flag was written to mean “answered the first question” and read to mean “finished onboarding”, and a single browser refresh between those two moments dropped the user into the app having never seen the key step — with no key saved, and chat and ingest silently disabled. Anyone who reloads, closes the tab and comes back, or gets bounced through an OAuth round trip mid-flow hit it.onboarded: true and stamps the timestamp only for that; saving profile fields is just saving profile fields. That distinction matters beyond onboarding, because the endpoint is shared: Settings → Account writes role, team_size and use_case through the very same route, so under the old rule an existing user editing their role was re-stamping a flag about onboarding. The first stamp still wins — when somebody actually finished is a fact about the past, and neither re-finishing nor a Settings edit years later rewrites it.OnboardingFlow wraps onComplete and onSkip once at the orchestrator level, so every path that leaves the flow — Save key & continue, Skip to dashboard, Skip for now on step 1 — leaves it marked, and a step added later cannot forget to say so. That also closes a smaller hole in the same place: skipping from step 1 without answering anything saved no fields at all, so nothing was ever stamped and the whole flow reappeared on the next sign-in.App.jsx treats a saved BYOK key as “already set up” alongside the flag, which fixes a different loop: skip onboarding, paste a key in Settings, reload, and get thrown back into the flow. Only the flag’s timing was wrong. And the flag is only trustworthy as a completion signal now that nothing else writes it.onboarded_at set for the wrong reason — but that timestamp is byte-identical to a genuinely finished user’s, and there is no field that tells them apart. Backfilling would therefore mean re-running onboarding for people who completed it. They stay onboarded; the key step is reachable from Settings → Models & API, which is where the skip path already sends everyone.onboarded is a signal and never a stored field, and that the first stamp wins. Suite 2259 passed / 1 skipped; eslint 0 errors, 39 warnings; build green.custom_base_url + custom_key, and that route is where every genuinely free option lives: Groq, Ollama Cloud, OpenRouter. Nobody arriving at onboarding was told it existed. The step now offers eight providers and puts the cost on every chip, not only the selected one — a free option has to be visible without clicking each one to find out.https://api.groq.com/openai/v1”), which is the same auto-fill Settings has done since the OpenRouter/Mistral URLs shipped. The generic “Other · OpenAI-compatible” option is still there, with its own base-URL and model-name fields, for the people who do know.SettingsPanel.jsx. Copying them into OnboardingFlow.jsx would have created the classic silent drift: a URL changes, one screen is updated, the other keeps writing the stale value into the same preference field, and nothing fails loudly. They now live in frontend/src/llmProviders.js together with the curated model floor, and each screen composes its own sentence from the same facts — Settings appends “…field below”, onboarding does not. The auto-fill rule itself is one shared function, including the part that matters: a URL the user typed is theirs and is never overwritten, only one we filled in moves when the model moves.localhost is our localhost and not yours, a constraint the chat model list already records. It stays reachable through “Other”, where the caveat can be stated next to the field.resolve_ingest_model raises when the provider is custom and the base URL is empty, with a message pointing at Settings — so a screen that could save that combination would hand someone a finished onboarding and then a failure on a page they have never opened. The base URL is now checked before the request (present, http/https, has a host), and the model name is required for the generic option and prefixed custom/ when it carries no prefix the backend recognises. The step also saves the MODEL with the key, which it never did: the provider is resolved from the model id, so a saved Groq key sitting under the default Gemini model produced “No Gemini API key saved” at the first ingest. That was true of the old OpenAI and Anthropic buttons too.llm_model.py substitutes the literal "ollama" when custom_key is empty, because an endpoint on your own machine authenticates with nothing. So a loopback base URL relaxes the key field — the placeholder, the error and the button label all change — and a remote one does not: “a key is only optional for one running on your own machine”. Silently accepting a blank key for a remote endpoint would just move the failure to the first ingest.KeyTest was already built as its own component rather than welded into Settings, so it dropped in with the provider set to custom and the resolved base URL passed through. Verifying here is worth more than verifying in Settings, because this is where a wrong key costs you the user. It is remounted per provider so a “Key works” verdict about a Groq key can never sit under an OpenAI one, and the key field clears on switch for the same reason.https://api.groq.com/openai/v1 and the key placeholder becoming gsk_…; the Test button returning a verdict inline and that verdict disappearing on a provider switch; and the four save paths — a Groq key writing {model: groq/llama-3.3-70b-versatile, custom_key, custom_base_url} to PUT /api/account/preferences, an empty base URL and a scheme-less one both refused with zero requests made, a remote endpoint with no key refused, and a localhost endpoint saving with an empty key. On the Settings side, picking the OpenRouter model still auto-fills its URL and the cost line still reads “Free tier · create a key at openrouter.ai/keys”, now from the shared table. Suite 2249 passed / 1 skipped; eslint 0 errors, 39 warnings; build green.GalaxyGraph draws its own “No graph yet — ingest a repo to grow your galaxy.” caption whenever it is handed zero nodes, and the Map pane mounted the canvas unconditionally — so while /api/wiki/graph was in flight it was handed zero nodes, every time. The pane then laid “Mapping your galaxy…” over the same centre point of the same absolutely-positioned box, and the two sentences interleaved into unreadable text.KnowledgeGraphV2 now returns exactly one of three mutually exclusive states — PaneLoading, PaneEmpty, or the populated canvas — and the canvas is only mounted once there is something to draw. Adopting the shared PaneState component for the loading half also means the Map stops being the one pane whose “still working” state was a bare grey line: it gets the same titled, skeletoned treatment as Wiki, Tables, ERD, Skills, Impact, Actions and Reviews. The stats strip, the source chips and the cluster legend lost their !loading guards, which are now unreachable by construction. GalaxyGraph’s caption stays as a last-resort fallback for a future caller, with a comment recording that whoever owns the empty state must own the loading state too.ERDiagram already branched showLoading ? … : showEmpty ? … : canvas on a shared tables.length === 0, and DbGraph guards each of its three bodies (loading / no-connection / connected-but-unread) on the same loading flag — neither can co-occur. BlastRadiusGraph has no loading or empty state at all by design: it returns null without a result and lets ImpactPanel own both. The Map was the only pane that had split the decision across two components./api/wiki/graph in a throwaway Vite harness (deleted before committing): loading shows “Laying out the knowledge map” alone with no stray empty-state text, empty shows “Nothing to map yet” alone, and populated shows the stats strip, the legend and the rail. The force-directed canvas itself painted nothing in the automation browser — but that is environmental, not this change: the pane reports document.hidden === true permanently, which stops GalaxyGraph’s requestAnimationFrame draw loop from ever starting, and mounting GalaxyGraph alone with no Map pane involved reproduces it identically. A plain canvas on the same page paints normally, so it is not a screenshot limitation.wipe_firestore() enumerates collections LIVE (for coll in db.collections()) precisely because the hand-maintained delete list it replaced had drifted and silently left behind google_accounts (OAuth refresh tokens), slack_installations (bot tokens) and conversations (chat PII) — credential remanence. So the ask “wipe, but keep a few things” is implemented as a KEEP list that only ever SUBTRACTS from that live enumeration. The two fail in opposite directions and only one fails safely: a stale delete list means something survives that should have gone; a stale keep list means something is deleted that could have been kept. The second is an annoyance. The first is the bug. A collection nobody names is still deleted, including one added to the schema tomorrow — there is a test that invents one and checks it dies.waitlist, waitlist_meta, waitlist_orders, config, and analytics by default. Each carries its reason in the code, because the reason is what makes the entry reviewable. waitlist_meta is the subtle one: it is the position COUNTER, and keeping waitlist without it resets the next position to 1, colliding with numbers live entries already hold — a test asserts the two can never be listed apart. waitlist_orders is paid early-access orders. config holds the early-access / waitlist-mode gate flags read by enhanciar/waitlist.py; wiping it leaves signup in an undefined state, worst case silently open to the public.analytics_visitors and analytics_events are people who visited the landing page and never signed up — not users, and the only record that they came at all. They are the one part of the list an operator plausibly wants gone, so they are their own checkbox on the Danger Zone page and a --wipe-analytics flag on the CLI the module docstring advertises. Opting out of analytics does not take the rest of the keep list with it; that is also a test.{name: deleted} to {deleted, kept, kept_missing}, and the Streamlit progress log prints a KEPT n docs — <reason> line per spared collection alongside the delete lines. The keep list is also rendered in the Danger Zone UI and printed by the CLI before the confirmation phrase, not after: the operator should see what lives while deciding.wipe_gcs and wipe_auth_users are untouched: GCS holds wiki pages and uploads, all disposable, and auth-user deletion was already a separate opt-in defaulting off.admin/ had zero test coverage, and cannot get CI coverage. The whole directory is gitignored on purpose — it reads cross-tenant data and ships this button, so it is never pushed. 13 tests now exercise the keep set against an in-memory fake client, and they skip cleanly on a checkout without admin/, which means CI is not a guard here and the tests only run on a machine that actually holds the tool. Stated rather than papered over. Suite 2249 + 13.238b68b). Correctly — a scanner cannot tell a fixture from the real thing, which is the whole reason it is worth trusting. The test does need the right shape, since the redactor keys on ghs_ plus length; randomising the characters only swaps github-app-token for generic-api-key. Now built at run time from "ghs_" + "A" * 36: correct prefix, correct length, zero entropy, no literal a scanner can match. The old value is allowlisted as a scoped regex because history is immutable — never a path allowlist, per the note a previous whole-file skip earned itself.f532971) — .firebase/, _claude-sessions/, brand/, AGENTS.md. That is the dangerous middle state, because git add -A stages exactly that set, and a working-tree sweep found 255 findings across them: built bundles and credentials quoted inside session transcripts. The only thing that had prevented it was an accident — staging ~1,800 files fails the push with RPC failed; HTTP 400. A size limit is not a security control.secrets_found stays amber and says found and redacted … never sent to a model; the new secrets_missed is red and says the pre-ingest scanner did NOT catch this, it had already been sent, this is not a block, rotate it. The counts are never added together. A test asserts the two sentences stay different, because merging them would turn a miss into a reassurance, which is worse than not reporting at all.credential_sightings array added to the JSON the worker already returns. Enhanciar is BYOK: an extra call spends the customer’s money, so if it could not be folded in it would not have been built. Collection reads worker output and the clone, and a test greps the module to make sure it never grows a call.{path, line, kind, why}, rebuilt field by field rather than passed through, so a field the model invents cannot ride along carrying a secret. Every string is then scrubbed on the assumption the prompt was ignored — a prompt is not a control — using the provider shapes from redact.py plus the same randomness test the scanner uses. The pinning test hands it a model that quotes the credential in why, in kind, and in three fields nobody asked for, and asserts no returned finding contains a high-entropy string.process.env.X, <YOUR_API_KEY> or our own REDACTED_CREDENTIAL is dropped. A position gitleaks already owns is dropped, because that one was caught and re-reporting it under the scarier headline would be false. .env.example, docs, fixtures, mocks and snapshots are dropped. A why that restates the finding instead of giving a reason is dropped. Stated plainly: a real key committed in docs/ is missed by that filter. The deterministic scanner still redacts it.authBlob, then passed as key_secret): exactly one sighting, right file, right line, value not quoted. The same files with the “do not report” list removed: four sightings, three of them false — a Razorpay key_id, a Firebase web apiKey, and a reCAPTCHA site key whose own why quoted the comment above it saying the key is public. So the exclusions are carrying about 75% of the noise, and two fixes came straight out of that run: published-by-design identifiers (site_key, publishable, client_id, project_id, app_id, bucket names) are now exempt the same way the Firebase key already was, and the “is there a candidate literal here at all” floor was raised from 8 characters to the scanner’s own 16 — at 8, projectId: "enhanciar-web-2d0c7" was enough to keep a whole Firebase config alive as a finding. All three false positives are now dropped by the code alone, with no help from the prompt.apiKey silence a real secret assigned three lines below it — which is exactly where a real one tends to sit. And the Firebase judgement is not a second copy: it calls secret_scan’s own helper, so the two cannot drift into declining to redact a line and then reporting that same line as un-redacted, in the same run, on the same file._secret_scan_gaps.jsonl beside the wiki and logged at WARNING on enhanciar.secret_scan.gaps, where an engineer can turn it into a gitleaks rule or a secret_scan pattern. Positions and descriptions only, no values. Nothing auto-adds a pattern: a model-proposed regex inside a security control is the last thing this wants.repo_tools has redacted .env, service-account JSON and .pem since early on — by filename, and by file-level fingerprints like -----BEGIN PRIVATE KEY-----. A real customer repo ingested yesterday, treak_n_go_final-master, had a live Razorpay key_secret assigned on one line of functions/index.js: an ordinary Cloud Functions file, innocent name, no fingerprint. It was ingested whole and sent to the model provider. The wiki page it produced even remarked that the code “uses hardcoded test credentials” — the model noticing in passing what no control had checked. That is the customer’s secret, not ours.gitleaks — not a hand-rolled regex list, which is the thing that rots. It is already a pinned hard dependency of this repo’s own CI, so it is a maintained, deterministic version of what we would otherwise have written badly, and it now ships in the runtime image at the same version the CI gate uses. Deterministic, no model call, ~3s on a few hundred files. A small builtin pattern floor runs unconditionally underneath it, because a guard that silently no-ops when a binary is missing is not a guard.key_secret: "jYcD…" becomes key_secret: "REDACTED_CREDENTIAL". The line stays, the key name stays — so the page can still say what the service is configured with — the file stays, and the source stays syntactically valid so the AST pass is unaffected. The whole-file stub is still right for a .pem, where the file is the secret; it is wrong here. functions/index.js is that repo’s only backend file, and losing its page to hide line 8 is the worse outcome. The public key_id beside it is untouched.db_detect opens files directly to pull projectId, apiKey and connection strings, and scrubbing the tree would have broken a working feature to fix a different one. Only what leaves for a model, the wiki, an event or a webhook is filtered.apiKey is deliberately not a finding. It is public by design — Firebase documents it, the bundle ships it, and this repo’s own .gitleaks.toml already says so. gitleaks cannot tell it from a real server key, so left alone it would fire on essentially every Firebase repo, and an alert that always fires is one nobody reads. Exactly that shape is dropped: an AIza… browser key in an apiKey assignment. Stated plainly, because it is a real trade: a genuine GCP server key written in that same shape is missed. Any other AIza… string still gets caught.--redact; a finding is {path, line, rule} and nothing else. There is no field for the secret in the event, the audit record, the jobs API response or the outbound webhook, because it is not carried that far. The provider-token shapes also went into redact.py, which every one of those exits already passes through — one list, both directions, so a token this scanner learns to catch in a file cannot still walk out through the event stream..gitleaks.toml from the directory it is scanning — during ingest, the customer’s repository. A permissive allowlist committed there would have disabled the guard that exists to protect them. Every scan is handed our own config instead. There is a test that plants one and checks the finding still lands.redact.py exists because a private clone URL carries a live GitHub token, and every event, job document and outbound webhook that echoed “the source” was publishing it. It has scrubbed that since it was written. But the risk at those same three exits runs in both directions: we read private repositories, and repositories commit third-party keys — AWS, Stripe, Slack, GitLab — which reach the identical choke points by the identical route, inside an exception message, a git error, or a Firestore document written months ago.contains_secret, which is this codebase’s single definition of “leaked” — so every sweep the leak suite already performs over emitted events, job documents and webhook payloads now covers the customer’s keys as well as ours, with no second mechanism to keep in step.AIza… key is deliberately absent from that list. A Firebase web API key has exactly that shape and is public by design; db_detect reads it to identify which database a repo uses. A blanket string filter cannot see the assignment around a value, so scrubbing it here would silently corrupt database auto-detect. It is judged positionally instead, where the surrounding line is available. There is a test that pins the exemption, so nobody “completes” the list later.cart.js and pricing.js had its pricing claims checked against cart.js — and the critic correctly reported that cart.js does not support them. That answer was then stored as a verdict about the claim. The more source a page cited, the more fabricated it looked.wiki/ws_…/; three panes read wiki/{uid}/. They were looking in the caller’s personal brain and truthfully reporting that it was empty.fetch interceptor put Authorization on every /api call — who is asking — while X-Workspace-Id, which brain they are asking about, had to be hand-spread at each of ~190 call sites. Forgetting it does not fail; it silently reads the personal namespace, because “no header” means “personal” by design. And on a personal account the two namespaces are the same string, so the mistake is invisible until someone uses a team. The interceptor now attaches both.GET /api/wiki/graph declared no workspace parameter at all, so no client change could have reached it. Its dead “merge every namespace on disk” branch — a leftover from before the route was authenticated — is now closed by an explicit check rather than by inference.job_create defaults the destination to the uid — so pressing Compile inside a team mined the personal wiki and wrote the skills back there, reporting success the whole way. A default that is right most of the time is the worst kind: it makes the omission invisible.collection("users")-style access; none of these bugs touch Firestore at all — the wiki is a directory tree. Two new rules: a route that reads under WIKI_ROOT resolves the namespace, and a route that enqueues a job names the workspace it fills. Both are lists of reviewed exemptions with a written reason each, and both were checked against the pre-fix source: they flag exactly the reported bugs and the four latent ones.trekngo-f4e81 by reading the user’s actual Firebase config. Saying so is the whole difference between a product that guessed and a product that read your code, and it was the one thing the banner did not say.firebase” in package.json; config file firebase.json; “firebaseConfig” in src/firebase.js; projectId “trekngo-f4e81” in src/firebase.js. The detector previously knew all of this and threw it away: it concatenated every scanned file into one corpus, so a match could not be traced back. It now keeps the file alongside the text.code: /initializeApp\s*\(/ — at a reader who has never seen it and cannot check it against anything; they now quote the text that actually matched. And a committed database URL is evidenced by naming its file, never by quoting the DSN: that string is rendered in the UI and written to disk, and printing postgres://user:password@… to prove we found a password is publishing it./api/connectors/db/auto-connect with no X-Workspace-Id, and that endpoint resolves the destination from exactly that header — so a click made while a team workspace was on screen filled the personal one. Naming a destination and then writing somewhere else is worse than naming nothing, so the header ships in the same change as the label.display:none rather than unmounted — that is what keeps scroll position and stops every pane refetching on every switch. The cost is that a pane with a master/detail split also keeps its detail, and there is no unmount to reset on.paneEntered event when the active tab changes, and the panes with somewhere to return to listen for it. Connectors returns to the grid; Skills closes an open skill and the process-map overlay.openConnector in the same navigation that announces entry, so the reset would have raced it and landed you on the grid. The panel marks the entry as deep-linked and consumes that mark once, rather than relying on the order two window events and a React commit happen to run in.GET /api/jobs?status=active returns every job of yours that has not reached a terminal state, each carrying its source_label (“owner/repo”) and source_type. The card reads that. A per-card “syncing” boolean would have had to be kept true across a reload, a tab switch and a run started from the Ingest tab — three ways to be wrong about something the server already knows.source_label, not source. A code job’s raw source is a clone URL and an issues job’s is the bare owner/repo; comparing the raw field would have matched one kind and silently missed the other.switchTab event — the same mechanism the Actions and Settings panes use for cross-pane links — landing on Ingest › Activity. The toast still names the repo, the destination workspace and the job id; it just no longer ends with an instruction.github_app.clone_url_with_token builds https://x-access-token:<token>@github.com/owner/repo.git so private repos clone without a user PAT, and that whole string was written into the job’s event log — persisted in Firestore, rendered in Ingest › Activity. In a team workspace every member can read that pane, so one person’s installation access token became the whole team’s. The owner saw their own token on screen. Installation tokens expire in about an hour and are scoped to the one install, which bounds it; it does not excuse it.job.source on GET /api/jobs (and so through the “Attached to job…” line), through the ingest.start audit record — and, worst of the set, through the ingest.completed / ingest.failed outbound webhooks, which POST to a URL the customer typed in. That one carried a live token off our infrastructure to a third party. The failure paths leaked it too: git quotes the URL it was handed back at you, so “Clone failed: …” and the job’s error field republished it.enhanciar/redact.py has two functions and the split is the point. repo_label() returns owner/repo — use it when writing a message about a repo, and it cannot leak by construction. redact_secrets() is the safety net for strings that came from somewhere we cannot vouch for: git’s stderr, an exception, a Firestore document written months ago.jobs.job_append_event scrubs every event before it is stored. job_set_status scrubs the error and result. _emit_outbound scrubs at the door. clone_repo scrubs git’s stderr where it is produced — it is also an LLM tool whose return value goes into a model prompt. A fix that lives only at the call sites is undone by the next call site somebody writes.source_label, the repo as a human names it, so no display code has any reason to reach for source at all. The Monitor list and the attach toast read it.jobs.job_public, which also derives source_label for documents that predate the field, and the SSE replay scrubs historical events on the way out. Nothing at rest is falsified; nothing renders a credential.x-access-token or a ghs_ token. Without that assertion this comes back._state/*.json hash maps and repos.json, which are the same records the MCP list_repos tool answers from — so the webhook and the agent agree about what is in a brain. Written by the run itself, so they cannot claim “ingested” for a run that failed. Branch-agnostic on purpose: “is this repo in the brain” is a different question from “is this checkout’s hash map fresh”. The key derivation moved into incremental.repo_key_from_source so the check and save_state cannot drift onto different filenames — a drifted key would silently read “never ingested”._state is absent from local disk. Everywhere else that costs a wasted re-enrich; here it would silently ignore pushes for a repo the user really did ingest — wrong in the direction that looks like the feature is broken. One GCS list, only on the negative.last_push_at is still stamped first: delivery did work — we chose not to act on it — and suppressing the stamp would make the pane report a transport fault that isn’t there.Ingest code and Ingest issues + PRs, matching the product’s own word for the operation (the Ingest tab), with the verb first so the action is unambiguous. Busy states say what is happening rather than a generic “syncing” that was identical on both: reading code… and fetching….X-Workspace-Id, so they fill the workspace you are looking at — while the line directly above them (“re-ingests on pushes to…”) names the workspace the installation feeds. Those are two different answers on the same card, and printing the install’s workspace next to a manual button is how somebody ends up hunting for pages in a brain that never received them. The toasts name it too, and point at Ingest → Activity./api/github/repos/sync mints an install token, clones at the watched branch and runs the full pipeline; /api/ingest/issues routes to _run_issues_job, which explicitly skips the BYOK key swap because the issues pipeline makes no LLM calls. Hence “on your own key” on one and “no key, no tokens” on the other — both are what the code does, not what the previous copy claimed.position:absolute panel pinned left:0; right:0 to its trigger, and all three defects that produced follow from that one line. Fixed in StyledSelect, where the ~20 call sites that inherited them are: a stacking or clipping bug in a shared control is not a bug in the pane that noticed it first.release/2026.08 and feature/very-long-branch-name-here were ellipsised into two options you could not tell apart — a picker that cannot show you what you are picking. The menu is now width:max-content with the trigger as its floor, clamped to 420px and to whatever room the viewport has. Measured on the real pane: 150px → 297px, every branch legible.overflow could clip it, and anything with a stacking context could bury it. .gh-shell is overflow-y:auto, which per spec computes overflow-x to auto as well — a clipping box on both axes. Rather than punch a hole in one ancestor, the menu is rendered into a portal on <body> and positioned fixed against the trigger’s viewport rect, then repositioned on scroll (capture phase, so scrolling ancestors count) and resize. z-index sits above every modal in the app and below the toast layer.wrapper.contains(target), and the menu is no longer inside the wrapper — so every click on an option was an outside click, closing the menu on mousedown before the option’s click could fire. It now counts the portal as inside. The opposite mistake was made and then measured away: a second onKeyDown was added to the portal on the assumption that the filter box’s keystrokes no longer reached the wrapper. React bubbles synthetic events along the React tree, not the DOM tree, so they always did — the duplicate handler ran the whole switch twice and one ArrowDown moved the active option two places. Removed.<select> retired, pane by paneStyledSelect, which the entry below made keyboard-complete first, on purpose: the sweep is only an improvement if the replacement is as usable as what it replaces.landing/tweaks-panel.jsx is a self-contained design-time shell — it ships its own compact control kit and its own stylesheet, activates only when a design host posts __activate_edit_mode, and is never reachable by a customer. Converting it would couple a standalone prototype harness to the app’s component tree and put 26px fields next to 36px ones, for a control nobody using the product will ever open.onChange an event; StyledSelect hands it the value. A call site that keeps reading e.target.value renders perfectly and does nothing at all. Every conversion rewrites the handler, and the ones whose value is a NUMBER (the Lint hour) convert explicitly in both directions — option values are compared with ===, so a number would never match its own option and the control would open showing nothing selected.onFocus-to-load became onOpen-to-load. The Actions target picker fetched its Jira projects / Linear teams / GitHub repos when the select took focus; the popover fires the same fetch as it opens, so the list is loading while the menu is already on screen rather than before it appears.<select> gives you Up/Down, Enter, Escape, Home/End and type-a-letter-to-jump for free. StyledSelect — the themed replacement — handled Escape and outside-click and nothing else. Swapping the product’s remaining native selects for it as-is would have made every dropdown mouse-only: a serious accessibility regression shipped as a polish task. So the keyboard work landed first, before anything adopted it.role="combobox" with aria-expanded / aria-controls; the popover is a listbox of options carrying aria-selected and aria-disabled. Focus deliberately stays on the trigger while the list is open and aria-activedescendant names the active option, which is what lets a screen reader announce the move without DOM focus leaving the control. Escape returns focus to the trigger — closing a list should not strand you at the top of the document.<select>. On a phone the native control opens the OS picker — a full-screen wheel on iOS — which is better than any popover at that size, is what people expect, and comes with the platform’s own accessibility. Both branches take the same props and call the same onChange, so no caller has to know which one it got. Replacing that with a custom popover everywhere would have been a downgrade justified by consistency.github_webhook compared the pushed ref against refs/heads/{repository.default_branch} and returned early otherwise. That covered the common case — two repos defaulting to main and master were both handled — but a repo whose current state lives on a release branch, or whose default is one the owner does not want rebuilt, had no way to say so./repos/{owner}/{name}/branches with the installation token — a dropdown of branch names nobody confirmed exist is a control that looks like it validates and does not, which is the exact defect removed from the Ingest pane earlier today. If GitHub will not answer, the menu says why and falls back to the branches already on record (the default, and whatever is configured) rather than rendering empty.owner/name contains a slash, which Firestore reads as a nested-field separator, and set(merge=True) merges maps key by key — so clearing a branch that way would have left it exactly where it was. Written whole, with update. Both facts are pinned by tests, because both are the kind that look fine until the day someone clears one.github_webhook called job_create(uid=…) with no workspace_id, and that field defaults to the uid. Install the App from a team workspace and the team’s wiki never moved; the installer’s own filled up with a repo nobody had asked it about. The fifteenth instance of this codebase’s one recurring bug, and it shipped for the usual reason — on a personal account the person and the workspace are the same string, so there is nothing to see.X-Workspace-Id — so the setup callback cannot know which brain was meant. The SPA finishes the bind from inside the app shell, where the picker is on screen; that request now sends the header, and the installation document records the answer. Nothing else in the flow had a workspace to offer.installation, installation_repositories) deliberately leave the field alone rather than defaulting it, or adding a repo on GitHub would have moved a team’s install back to personal.workspace_id, a handler nobody entered fails, one that passes nothing fails, and passing the person as the workspace can never be blessed by an entry. Two further assertions pin that the resolver still re-checks membership and that the webhook resolves through it rather than reading the field itself — a second derivation of the rule is how a read and its permission check drift apart.workspace_id field at all is read as personal, which is what it already was.resolve_uid is now a wrapper over the resolver that returns both identities, rather than a second lookup beside it — this codebase has thirteen shipped bugs whose shape is exactly “the read and the write resolved differently”. A key refused for one surface is refused for all of them, so the REST API cannot become the way around a workspace removal. The lookup index deliberately does not carry a copy of the workspace: it is written once at mint and never updated, so a copy there would be a second source of truth that can drift from the key document.uid, ns = _identity() — the PERSON (pays for the tokens, owns a proposal they file, holds the per-person actions beta flag, is the ACL viewer) and the BRAIN (which namespace is listed and searched, which workspace owns the approval queue) — and writes _ for the half it does not need. Twelve tools, twelve declarations, mirrored in a table in the scoping guard: a new MCP tool nobody entered there fails CI, and so does one whose body reaches for an identity its entry does not list. Nothing static can decide which is correct; what it enforces is that somebody wrote it down.acl.is_visible(uid, workspace_id, …) treats membership as a precondition — an ACL can narrow access within a workspace but never grant it across one — so is_page_visible(uid, uid, …) made the precondition trivially true. Now the person is checked against the workspace, on all three page-reading tools.query resolved model preferences without a namespace, so a team query would have been funded by whichever member was holding the MCP token. It now resolves them the way the web chat does, and the answer is metered to the person and the workspace — MCP usage was previously recorded nowhere at all.list_repos read a Firestore path nothing has ever written. users/{uid}/repos appears exactly once in the codebase: in that read. So the tool has returned an empty list to everybody, forever, and it was keyed by the person on top of that. It now reads what ingest actually records for the namespace — repos.json plus the per-repo incremental-state files — and reports branch rather than default_branch, because the state file records the branch that was ingested and no caller can be depending on a key that never had a row under it. The clone’s absolute path stays server-side.query had the same gap the web chat and Slack were fixed for two entries below: an empty namespace produced an answer that read like “I searched and missed”. It calls the same shared module those two do, so the three cannot drift, but through a third surface rather than by reusing the web wording — because the reader here is Cursor, and “open the Ingest tab” is addressed to something with no tab and no hands. The agent wording states the fact, says to relay it to the person who can fix it, and then explicitly closes off the retry: an agent’s default on an unhelpful tool result is to reword the question and call again, and no rewording gets past a namespace with nothing in it. The statement of fact is one sentence in one place across all three surfaces; only the sentences that speak to the reader differ. The response also carries empty_brain: true, so a client can tell the two states apart without parsing prose. Answered before the model, like a greeting, so it spends none of the workspace’s key.0wsEOjKLzhRT4VhKdK2Uf5a3KFR2get_users takes a hundred identifiers at a time, so a thirty-person workspace costs one call, not thirty sequential ones before the first row paints. The Firestore profile fallback is kept but only fires for a uid the directory returned nothing for at all — anyone it knows has an email, which is a perfectly good label — so the per-row lookup does not come back under a different name. The workspace roster resolver was rebuilt on the same batch, since it had the identical problem.greetings.py was extracted, for this same failure. A test fails if either surface stops calling the shared check.setActiveWorkspace writes localStorage and fires a workspaceChanged event, and exactly four surfaces listened for it. Every other pane fetches once in a mount effect and then stays mounted — tabs are kept alive on purpose so returning to one does not refetch — so nothing re-ran. Switch to a team workspace and the app carries on rendering your personal brain, with a header that says otherwise.setActiveWorkspace swallows a localStorage failure (private mode) and relies on the event alone to carry the switch for that page load. Reloading there would re-read the old id and silently undo the click, so the write is confirmed first. That is also why the four in-place listeners were kept rather than deleted as redundant: in that case they are the entire mechanism.window.location.reload cannot be exercised. The tests assert on the source instead — the same approach the clarify tests already use — pinning the no-op case, the mount case, the personal/team distinction, the storage-failure case, and the write-before-dispatch ordering the reload depends on.dh-tabs.css, next to .tab-pane, sets padding: 24px 28px and no cap for every pane class, plus one .dh-pane for panes that had no class. Adding a pane means adding its name to that list, not inventing another number. The inline maxWidth/padding that four components were setting in JSX — which beat the stylesheet — is gone.X-Workspace-Id; a repo or a website went to your own brain no matter which workspace you had open. So the one source a team most obviously shares — the codebase — was the only one they could not share. The frontend had been sending the header the whole time; the route simply ignored it.repos.json now all follow the workspace, resolved from one variable — a page written to one namespace and looked for in another is this codebase’s oldest bug, and six placement sites drifting apart independently is how it keeps happening. Who ran it stays the person: Monitor visibility, webhooks, and the meter’s caused-by identity.job_create said a team id would misattribute the bill, because the pipeline metered tokens by this one key. The meter now records the member and the workspace separately, and the key that pays is resolved for the workspace — so the namespace is free to be the one the user is actually working in.workspace_keys off, key resolution is exactly today’s per-person path and a key already stored on a workspace is inert — so the rollout is a flag flip, and so is the rollback.ns != uid throughout — including one for the case that already worked, written before the fix so it could not be quietly broken by it — and the static guard now fails the build if a connector resolves a credential by hand or hands its resolver a single id.owner_uid pointed at an account that no longer existed. Nobody could change its settings, invite anyone, or remove anyone. Once a workspace runs on an owner-supplied key, that stops being untidy and becomes a team that has quietly stopped working with no way to fix it.ns != uid throughout, plus the personal case pinned in the other direction so nobody can “fix” the team path by breaking the solo one.users/{uid}.preferences, written by Settings → Models & API keys. Three call sites asked for them by workspace instead, which reads a document that exists for a personal workspace (where the id is your uid) and does not exist for a team. No key, no error, no clue.extractor: regex without saying a model had been available all along. Every one of them worked perfectly on a personal account, which is why they shipped.creds_uid decides whose key is spent and whose meter records it. Same split the connector pipelines and the Lint schedule already carry, and the same vocabulary, so there is one idea here rather than three.ns != uid. At ns == uid — every personal account, and the configuration most testing runs in — all three of these bugs pass cleanly. That is the configuration that hid them.no-undef cannot see JSX element names. <Foo /> with no Foo in scope compiles, bundles, lints clean, and throws a ReferenceError the instant that branch renders. The rule that catches it, react/jsx-no-undef, was never enabled — even though the react plugin was already registered and two of its sibling rules were on. Now an error.<NavIcon> in several places with no import. Reaching it required opening one credential form — a path the build walks straight past, because bundling never resolves a JSX name, and one the test suite walked past too.npx eslint src reports 0 errors across every component. Verified the rule genuinely fires by probing it with a deliberately undefined element rather than trusting a clean run — a guard that guards nothing looks exactly like a codebase with no violations.GAP / CONFLICT / STALE — the vocabulary Lint produces. A second surface showing the same findings would be worse than none: two lists that disagree about what is outstanding, and a user who fixes something in one and watches it persist in the other. Lint adds the run, not a second list, and it emits no fifth badge kind.candidates: 0. And a workspace nobody has linted reads as “Never linted — that is not a clean bill of health, it means nothing has looked”, structurally: a run that did not happen carries no findings and no counts, so no surface can render it as clean.gcloud command is written down together with what the smoke test should return and an explicit warning that until a human runs it, Lint is a switch in Settings that never fires.[[cart-service]] while paraphrasing something that page does not say. That has been item M in our own reliability notes since they were written. Every sentence a finished answer states is now adjudicated against the pages that answer actually cited.done either — that would leave the spinner running over an answer already fully readable. The answer arrives first; the annotation lands on it afterwards, and “Checking this answer…” is a visible state so a verdict never appears out of nowhere.claim_verify still stops it, because it is the same critic and a kill that left it running would not be a kill.PUT /api/account/integrations accepted the workspace header and had no ownership check of any kind — so one member could silently break every connector for the whole team, or repoint a database DSN. Disconnect was the same hole one button over, and it takes the ingested pages with it; both now require the workspace owner, as does the one-click database auto-connect that writes the same field.configured booleans the owner gets — so “is this connected?” stays answerable without any credential material. Site URLs, subdomains and account emails are not withheld: they are not credentials and not fingerprints of one. The Google OAuth client secret’s mask leaked the same way and is fixed the same way.ws_ id prefix. A second rule for the same question is exactly how a read and a write drift apart, which is the bug this area keeps producing. A lookup failure fails closed: a Firestore blip must not hand a member the owner’s powers.<NavIcon> without importing it, which throws a ReferenceError on render. no-undef does not resolve JSX element names — that needs react/jsx-no-undef, which is not enabled — so lint saw nothing. The import is added.gpt-4o is true and useless.repr() of the exception would print it. Only the exception type and status code are logged. Two tests assert the key appears in neither the response nor the log.db.collection("users").document(username) by hand instead of through the accessor that decides, in one place, where that data lives. And every one passes cleanly on a personal account, because there the namespace and the uid are the same string. That is why nine of them shipped.symtable guard added a few entries ago. It does not try to decide which document is CORRECT for a given field — nothing static can. It asserts something weaker and enforceable: request-handling code that addresses a scope-keyed document by hand is on a short reviewed list with a one-line reason, or it is a bug.connector_db_auto_connect and asserts the check trips on it; another does the mirror image (reading the workspace document where the writer used the uid), because a guard that only covers one direction covers half the bug.record, record_saved, record_from_event and get_today all take a uid, and /api/usage reads by uid — so a namespace written to it lands in a file nothing ever opens. The cache-savings write in the image tool passed the namespace, which would make “Cache savings today” read $0.00 however many hits there were.workspace_id today — jobs.job_create says so in a comment, for this exact reason — so the namespace and the uid are the same string on that path and the two agree by accident. The fix is for the day they stop agreeing, which that comment already schedules.owner_uid alongside the namespace and publishes it as a contextvar; the tools underneath read it and fall back to the namespace when nobody declared one, so an undeclared context behaves exactly as it did. A contextvar and not a module global on purpose: two concurrent ingests must not be able to bill each other./api/usage to the namespace would silently turn every figure on the Cost page — spend, tokens, monthly usage — from “what I spent” into “what this workspace spent”. That may well be the better product, but it is a product decision with a migration behind it, not a bug fix.N : 1, because both of its 1 : 1 tests read fields that do not existt.indexes, a key the ERD payload has never carried. Guarded with || [], so nothing crashed — it just silently never fired, and a foreign key onto a single-column UNIQUE column was drawn as many-to-one when it is one-to-one. Worse than the missing feature: a wrong fact, stated confidently, on a diagram people design schemas against.primary_keys from a per-column key: “PRI” field — MySQL’s old SHOW COLUMNS shape, which nothing has written since the pane was rebuilt on the real database connector (that one reports primary_key on the table). So both one-to-one branches were unreachable and every single edge read N : 1. The extractor now reads the field introspection actually writes, and still resolves the legacy shape for a schema captured under it.N : 1. Tables whose unique indexes cannot be read report it, and those edges are labelled ? : 1 with a count in the header saying how many and why — never a guess.INCLUDE list, a sort direction or a collation all read as unknown. A partial unique index is refused for a stronger reason than unreadability — UNIQUE … WHERE deleted_at IS NULL does not make the column unique across the table, so taking a column out of it would manufacture a false 1 : 1.“UNIQUE” in definition. That says yes to CREATE INDEX ix ON t (unique_id) — a plain index on an ordinarily-named column, reported as a uniqueness guarantee. It is now the keyword, not a substring.<span> with no handler. The real counts already render one bar lower, computed from the loaded graph, and the layout is a force simulation that is always running — so one was a duplicate and the other was never a mode. Same removal, same reasoning, as the ERD header a few entries ago.indexes must NOT resurrect the guess._ast/*.json and _code_graph/*.json; only the second one survived a restart. The AST shards were in neither artifact list, so on a fresh Cloud Run pod they simply were not there. Nothing failed and nothing logged: the oracle just got smaller, and every claim it would have settled for zero tokens fell through to the paid critic or landed unverifiable. A silent bill, on the user’s own key, for work that was already done once._ast is now in _ARTIFACT_DIRS (mirrored on every backup, restored by the read path) and in the cold-pod ingest restore, because a run only rewrites the shards of the domains it actually processed while the oracle joins the whole workspace — so a page rewritten in one domain, making a claim about a file in another, would have had nothing to check against.code span itself, because that claim is about one identifier and not about the sentence carrying it. They are attached through renderer overrides rather than by rewriting the markdown — the shared renderer deliberately runs without raw-HTML support (page bodies are model output, so that escape hatch stays shut), which also means the marker is rendered beside the text and never spliced into it. Clicking one jumps to its evidence.node claimMatch.test.mjs): a marker attached to the wrong sentence is a verdict about something the reader is not looking at, which is worse than no marker. It pins the near-miss that must NOT match, the hard-wrapped paragraph that must, and the dependency claim that has to match by wikilink as well as by wording — deduping on the target otherwise leaves the second bullet naming it unmarked.stage_total the backend sends, so it grew its eighth card on its own — and the design comp’s long-promised VERIFY card, “re-check every wiki citation”, is now a step that exists rather than one that was drawn.claim_verify flag defaults ON and is a kill switch, not a gate — the emergency behaviour if the verifier is ever found to be wrong. Because it spends the user’s own key, the cost goes on the event stream: calls and tokens beside the counts, metered under its own kind so the cost dashboard separates verifying the wiki from writing it.wiki/<ns>/_claims.json, not in page frontmatter — the freshness precedent, for its three reasons verbatim: the page-merge path drops unknown frontmatter keys, code-page frontmatter is model-written and validated only as “starts with ---” (a poor place to keep the audit of that same model’s output), and mtime is reset by rehydration. It also means verification never rewrites a page body. A test asserts the bytes are unchanged.{} would stamp “0 unsupported claims” onto every page of a workspace nobody has ever verified, which reads as a clean bill of health for work that was never done.{} would drop every verdict for every page the run happened not to touch. Both halves are pinned by tests.claim_verify kind, so the cost dashboard separates verifying the wiki from writing it. There is also a hard per-run call ceiling — BYOK with no caps is not a licence to spend an unbounded amount of someone else’s money on a monorepo — and when it bites, the skipped claims are reported rather than quietly dropped.[[cart-service]] while paraphrasing something that page doesn’t actually say.” Nothing checked the sentence next to the link. A page is now broken into individual claims — the files its frontmatter cites, the symbols it names in backticks, each [[wikilink]] under Dependencies / Used By, each METHOD /path under Public Interface, and each narrative sentence — and every one of them gets a verdict.getMetaDetails” and “does aboutUs/page.js import the file behind [[get-meta-details-service]]”. Tier 1 is a join over shards ingest already wrote — no model call, no key, no tokens — and it carries the evidence with it: the defining file and line, or the literal import statement. On a real 197-page brain it adjudicates every file, symbol, dependency and route claim and leaves only prose for the paid tier.README.md, node_modules/ and PNGs are skipped by rule); a route means nothing on a framework the extractor cannot read (it knows NestJS decorators and Go mux, not Next.js file routing); package.json → .eslintrc.json can never have an import edge; and a [[wikilink]] to a page that does not exist has nothing to check against. Each of those is unverifiable, which is a different thing from false.calls made the config page of the file that reads FIREBASE_STORAGE_BUCKET report it unsupported.stage_total the backend sends — so when the verify stage takes it from 7 to 8, the strip follows on its own. Only its colours were aligned: inactive cards take the page tone rather than white, so the strip reads as a track with one lit cell.POST /api/files/url accepts one field, url, and register_url(workspace_id, url) stores no category; there is nothing to send it to and nothing that would read it. Wiring a select into a parameter no endpoint accepts is exactly the defect four controls were just removed for. Building it properly means a real feature — a user-supplied override of the classification the enrichment pass currently derives — not plumbing, so it is reported rather than faked.<div>, not a second 26px <h1>.<div> rather than a second <h1>, because two H1s on one screen is wrong for a screen reader as well as for the eye. “What you get” likewise stopped being a violet-tinted bordered panel — a third card in a 250px rail that already holds two.16px 32px 0: on a wide monitor the form stretched to the window edge, and each sub-view supplied its own vertical spacing, so the three never agreed with each other or with any other pane.GET /api/jobs returns the job document with the events array explicitly removed, and the document has no stage, stage_index, progress or pct field at any point in its life. So the row is dot + name + kind + status word, and the stage slot carries the source kind. No bar — a bar drawn from a status word is a picture of a number nobody measured.kind; the glyph is drawn beside the text in the colour that kind implies, and the content is only what was said.<option> renders text only, so any icon there is a stray character in the OS font. The backend’s own stage messages are untouched on purpose: the ingest progress heuristic pattern-matches that copy, and changing one without the other would silently break the progress bar.currentColor, 1.7 stroke). One table, so a lock in Billing and a lock in Onboarding cannot drift apart, and nothing scatters a one-off inline SVG next to it. Where a glyph already existed it is reused rather than redrawn: a lightbulb is the Skills icon, a plug is Connectors, a card is Billing, an open book is Wiki.<option> label, because a native dropdown renders text only and a glyph there is a stray character, and the search field’s magnifier, which was inside the placeholder string.font-size: 50px; the size travels with the icon now and the rule only sets colour.--depth 1 --filter=blob:none --no-checkout, then chunk-checked-out, so a large repository downloads its source and not its history. Fifty commits are fetched after the clone, blobless, purely to work out who wrote what. So the option offered as the default is something the pipeline is built never to do, and the other is roughly what it always does. Replaced with that sentence.risk field is gone from /api/impact and from the impact MCP tool, whose docstring now says why. A field kept “for compatibility” is a verdict waiting to be rendered again by the next surface. The criticality score stays on the nodes; nothing interprets it any more. The test that pinned the thresholds now pins their absence./api/files/upload, /api/files/note and /api/files/url registered the document under the caller’s uid and enqueued a job with no namespace. Chat sends the header. So with a team workspace active, the upload chip ran all the way to read and the toast invited you to ask about the file — and the file was in a namespace nothing was querying. The product said it worked; it was not there.headerFor() every other pane uses (from the shared intake module, so the chat composer’s Attach button and the Feed pane cannot diverge — that is why it is shared); the routes resolve the namespace the way every other ingest route does, and pass it to both the registration and the job. Those two have to agree: the connector re-checks that the document belongs to the workspace it is being fetched for, so a mismatch would have turned a silent misfile into a failed ingest.uid is who may watch it — the chip polls the job endpoint as you — and workspace_id is where it writes. Conflating the two is a mistake this codebase has already made once, in the auto-skills path.users/{uid}, for readers. Auto-connect started from that view, appended the newly detected database, and persisted the result to the workspace document — so a single click copied the caller’s personal, encrypted DSNs into a shared document every member’s reader can see.database_connections is merged whole-list, so a workspace list shadows a stranded personal one rather than concatenating with it — there was never anything here that could duplicate.users/{uid}. That fill-in is deliberate — it is what keeps a credential saved before the write moved from vanishing under a connector still using it — but it cannot tell “” from absent. So blanking a token wrote “” to one document, the read treated that as “never saved here”, and refilled it from the personal copy. The form returned 200, re-read, and the old token was back. There was no sequence of actions in the form that could remove the stored copy at all. Emptying the whole database-connection list had the same shape: the deleted connections reappeared.push subscription missing or the dev tunnel down. The one state the banner was there to rule out was the one state it could not see.last_push_at on the installation the moment a push arrives — written for this banner, with a docstring saying so — and the frontend had zero references to it. The endpoint the panel calls already returned the field verbatim, so no new plumbing was needed; the panel simply was not looking.white-space: pre-wrap div. Not a styling gap — no markdown was parsed at all. A ```python fence arrived as three backticks and the word python; **bold** kept its asterisks; a table came through as a wall of pipes; headings kept their hashes. For a product whose entire pitch is “answers grounded in your code”, the code was the single thing that rendered worst.pre-wrap bug and is fixed by the same change.<script> is simply not installed, so embedded HTML renders as the text it is.max-width, so the stream itself never scrolls sideways.<script>. Nothing executed, no element was injected, the page width did not move, the code block scrolled by itself, and the citations still rendered inside the answer card in order.users/{uid} while the live token sat in workspaces/{ns} — so the connector kept authenticating with a credential the UI reported as gone. Fixed first and treated as security-adjacent: a token you believe you revoked and haven’t is worse than no disconnect button at all. Disconnect now clears both documents.wiki/default/ when the caller had nothing of their own — and every workspace’s ingest used to land there, so one tenant was served another’s table names, columns and foreign keys. Two identical fallbacks were removed from DB Graph earlier for exactly this reason; these were the third and fourth. An empty result is the correct answer when you have ingested nothing.users/{uid}, so reads fall back there per field when the workspace document doesn’t define one. It is a read-time migration and stays read-only — writing on read would mint a second copy that then disagrees with the first, which is the bug being fixed. It is safe because disconnect clears both documents, so nothing cleared can be resurrected by the fallback. 31 tests, every one of them with namespace ≠ uid, because a test where they are equal proves nothing here.GET /api/account/preferences raised NameError on every single request. The handler’s body called _integration_doc(username, workspace_header), but workspace_header was never a parameter of that handler and is not a module global. A previous change added the call to this route while adding the Header(…) parameter to only two other routes. The route had a 0% success rate and nothing reported it.users/{uid} — deliberately not the workspace document. Preferences are per-person: timezone, billing country, GSTIN and email opt-ins are nobody else’s business, and the BYOK keys are consumed by llm_model._load_prefs(uid) on every model call. The PUT and all six readers agree on users/{uid}, and nothing anywhere writes preferences into workspaces/{ns} — so simply adding the missing parameter would have swapped a 500 for a silent read of a document no writer populates, leaving every team-workspace user exactly as broken.except Exception returned a generic 500, so a programming error was indistinguishable from a Firestore outage. The response stays generic on purpose — Firestore errors sometimes echo stored values, which here are key fragments — but the exception and its traceback now reach the server log. Swept the rest of the file for the same shape: 15 handlers raised a 500 without logging anything, and all 15 now log. No response body and no status code changed.enhanciar/ with CPython’s own symtable and fails on any name that is read but is neither a parameter, nor local, nor a module global, nor a builtin. It catches this defect at import time instead of in production, and it immediately found a second one: db_query.firestore_read called safe_error(e) without importing it, so every failure on that path raised NameError instead of returning the error it had carefully constructed. Also fixed.currentColor, so a warning glyph is amber because its line is amber.kind from frontmatter, steps counted from the numbered procedure in the body — so a card whose compiler wrote no kind simply has no kind chip rather than a plausible-looking default. The card also names its first source page in mono at the bottom, with the rest on hover: “3 sources” tells you a skill is grounded but not in what, which is the question you ask before trusting a procedure.1 : 1 only when the referencing column is the table’s whole primary key or covered by a single-column unique index. A relationship whose column we don’t know gets no marking. AI annotations show the Summary an ingest already wrote for that table — the same prose the Tables pane reads, not a second generation — fetched only when the toggle is on (?annotations=1), because it costs a wiki listing plus a file read per table. A table with no ingested page shows nothing.rgba(28,82,93,0.20) inside the drawn stroke — so the active row reads as filled rather than merely tinted. The fill is opt-in per call site, so the feature tour, which borrows the same icons outside the rail, is unchanged.#0E1113. It was the same violet→blue gradient as the logo mark sitting 10px to its left, so the two competed; and gradient-clipped text at 14px renders muddy on a non-retina screen.↻ character, which took the font’s weight and baseline and therefore sat differently from every icon beside it. The label stays.attachment field. That field is only read on the ADK path; production answers every question through the customer’s own key, where it is silently dropped. Wiring the button there would have produced a control that looks like it sends a file and doesn’t — worse than no button./api/query has no source-filter parameter: a control that narrows retrieval has nothing to narrow it with. It needs a sources field on the query API first; until then the scope row is the honest version of it.currentColor; the sidebar’s NavIcon drew its paths — copied verbatim from the comp — at 2. The rail therefore read heavier than every icon beside it, which is the sort of mismatch you feel before you can name it. Normalised to 1.7 across the nav rail, the source-kind icons, the Settings tab strip, the password-visibility toggle, the payment-method glyphs and the onboarding link icon.hashes, manifests, freshness, persist) had no card. So the preview said five steps, you started a run, and the header correctly said “Stage 3 of 7”. Same bug as the run header, in the card next to it.GET /api/pipeline/stages serves the real steps, generated from the same tuples stage_meta() numbers its events against — so the Nth card and the header’s stage N are the same thing by construction. A test fails if the component ever grows its own copy again, or if a stage is added without a label.stage_index the backend stamps on every stage event. A stage the run never announced shows as skipped, not done — hashes only runs on an incremental pass and keeps its slot either way. An old job replayed without stage numbers renders no strip rather than five grey cards implying a five-step run.thinking / thinkingOpen state keeps its name, which is accurate for what it holds, and the reasoning panel’s own “Thinking” label stays — that one is the feature.is_greeting() was wired into the Slack bot and into gap clustering, and into nothing else — so typing “hi” in the app went through retrieval, matched nothing, and came back “The provided context does not contain information to answer your query.” That was the first sentence a new teammate ever saw the product say. /api/query now answers it before the workspace namespace is even resolved, so it touches no retriever, no model and no tokens.done event carries an empty signal_id, because a greeting is not a retrieval — there is nothing to thumbs-down and nothing to cluster as a gap. That matches what gap clustering already did on the read side.greetings.py as a shared constant; Slack and the web chat build from it and differ only in the worked example, because only the example is surface-specific — you cannot @-mention anything in the web composer, and there is no thread to answer in. Two copies of a self-introduction is exactly the drift that module exists to prevent.invoice_pdf.py derives it for the document itself, so the row and the PDF cannot disagree. The Method column stays — the design has no such column, but dropping a column that carries real data to match a mockup is not a correction./api/manifests returns a markdown blob, and the repos sidecar is a name-to-path map — so the column is absent rather than filled with a plausible date. It needs an ingested_at per source first.EST. tag, the design’s marker for a figure with no backing field, and a tooltip saying what the pipeline actually reports.var(--bg-primary, #fff) — the fallback was written as white but the token resolves to the shell tone #fbfbfc, so the fallback never applied and the card differed from its background by nothing but a 1px border. The stream now sits on the canvas tone and the card is genuinely white.databases starts as an empty array, so every user saw a flash of “nothing here” before the fetch landed, whether or not they had data.3h ago, Yesterday — read from the created_at the job runner already writes on every record. A review saved before that field existed shows no time rather than a fabricated one.GAP / CONFLICT / STALE / OWNER badge, using the finding_kind field added earlier today. Anything filed before that field existed, or whose kind could not be determined honestly, renders no badge rather than a guessed one.1fr / 320px grid, so the five stage cards were laid out at the terminal’s width — the pipeline strip, the one element that wants horizontal room, was the narrowest thing on the page. It is a full-width row above the split now, and the split collapses to one column under 900px.2d ago, 5d ago — and have all along: created_at and finished_at were already in the payload and simply never rendered. A job with no timestamps falls back to the status word rather than printing “NaN ago”.<a> with no href and no click handler. The rail looked like a table of contents, styled like one, even had an .active rule waiting in the stylesheet — and clicking any line did nothing at all. It is real now: headings get stable ids derived from their text, entries scroll to them, and a scroll listener marks whichever heading last crossed the top of the article column. The id is slugged by one function used on both sides, because deriving it twice is how a contents rail ends up pointing at anchors that do not exist.Notion / concepts / Title.COMMENT above it when the schema’s author wrote one — that beats anything generated. Generating a second description at list time would have been a worse answer bought twice.filter_id existed but is a substring — “everything under this project” — which cannot express an arbitrary set, and would drag orders_archive along when you asked for orders. The pick is a closed set, matched exactly.{i, total}, the code pipeline sends {processed, total} — and the reducer only read i. So a code ingest showed 0/N throughout unless a message happened to carry a [i/total] marker in its text. Reads both now.finding_kind (gap / conflict / stale / owner) — derived from what extractors already say, not asked for. An unrecognised value stays empty rather than being guessed: no badge is better than a confident wrong label on someone’s queue.last_run_started has been written on every run since the feature shipped and nothing ever read it. 9 tests.status: running with a progress bar frozen at 30% — indefinitely. Nothing notices a job whose process is gone, so the UI showed a run that had not existed for minutes.last_event_at heartbeat and staleness is judged by silence.#14161a terminal with per-event text colours set inline. Its hover rule used var(--bg-secondary), a near-white, so pointing at a line painted a pale band underneath pale text and the row vanished exactly when you tried to read it. A translucent white lift is the right hover for a dark surface. Two more light-theme values were sitting on that same dark panel: the default row text (near-black, only ever invisible because the inline colours happened to override it) and the agent pill’s light chip background.files stays out deliberately: that is the Feed files tab, not a source you point an ingest at.•••••• saved as a fact, with an explicit Replace for the rare case you want a different string. The save button says Save changes rather than “Save & connect” — you are already connected — and a line appears while dirty saying nothing is applied until you save, because the • on a row is a pending edit, not an immediate disconnect.users/{uid} unconditionally; every reader resolved by NAMESPACE. With a team workspace active you could add a database connection, watch the badge turn green, open the connector page and be told “Available to ingest · 0 — nothing from Database is shared with Enhanciar yet”. Both statements were true of different documents: the badge was computed from the user doc, the connector looked in the workspace doc, and the connection sat in the first. Confirmed against Firestore — the DSN was stored, encrypted, and unreachable./api/connectors judges configured-ness from that same document, so the badge and the connector cannot disagree./api/connectors once, on mount, and never again. App.jsx keeps a visited tab mounted for the life of the session, so the grid kept showing whatever it saw the first time you opened it. Remove a database and it still read Connected; connect one and the Ingest button did not appear until a full Refresh. Verified against Firestore that the backend was right all along — database_connections was [] and the API correctly reported not-configured. The pane simply never asked again. Every path that changes a connector now announces it, and the grid re-reads.--success, --danger, --accent-on, --border-strong and --bg-subtle appear across Workforce, Actions, Tables, Billing and the blast-radius graph. Because none existed, every call site silently fell back to its own literal — roughly the right colour today, guaranteed not to follow any future token change, and in two cases the fallback was transparent, which is not a colour anyone chose. Defined as aliases of the primary tokens, so they now track them.#e74c3c / #2ecc71 / #f39c12 / #6c5ce7) in Code Reviews, Impact, the graphs, toasts and empty states; a GitHub-dark set (#3fb950 / #d29922 / #f85149 / #58a6ff) in Skills and Feed files — hues tuned for a dark canvas, which read washed out on white. 61 literals swapped for the app’s own red, green, amber and violet. Same roles, one palette.rgba(255,255,255,0.06) for surfaces and borders, rgba(0,0,0,0.4) for panels, and white text at 20-45% opacity — on #f0f1f3. The footer hint was white at 25% over a light grey canvas, i.e. invisible; the table-detail column headers were barely better. All of it now reads from tokens, and the FK edge uses --accent-amber rather than a one-off orange.<h1> in fourteen panes — a de-facto token that was never a token. Bumped in one sweep. It should be an actual class, and that is worth doing next time one of these panes is touched.database_connections, speaks Postgres and MySQL, and decrypts the DSN. The DB Graph pane used a second, weaker path: raw pymysql, MySQL only, credentials retyped into the request body every time, and a hard requirement that a password be present. A saved Postgres connection could not be opened by that pane at all. Verified end to end against a public read-only MySQL: the real connector returns 64 tables with correct foreign keys, which is exactly what the graph draws edges from.DEFAULT_DBS, a hardcoded empty array — so the one control for getting a schema in offered zero rows. New /api/db/connections lists your saved connections by name (never the DSN — a DSN in a JSON response is a DSN in a browser cache) and /api/db/introspect introspects one by name.wiki/default/_db_schema for every caller, and /api/db-graph fell back to exactly that folder when a workspace had no graph of its own — so one workspace’s table names, columns and foreign keys were served to any workspace that had connected nothing. The same fallback existed on the per-table schema route. Both fallbacks are gone and the new endpoint writes under the caller’s namespace. An empty graph is the correct answer when you have connected nothing.row_estimate; the pane read row_count — so row counts rendered as nothing. It also read sample_rows and engine, which the connector does not produce, which is why that sample-rows grid has been permanently empty. Renamed at the boundary. A composite foreign key is now flattened to one entry per column pair rather than dropped.+N chip so a workspace with a dozen connectors cannot push the model picker off the row.margin-left: auto, which does nothing on its own — auto margins absorb free space, and there was none: .gh-status is itself a flex container, so its inner wrapper was a flex item sized to its own content and the row had nothing left to distribute. Giving that wrapper flex: 1 makes it claim the row, and the auto margin finally has something to push against.firebase.json sets cleanUrls: true, so /features resolved to the file whether or not anything linked to it, and every URL already indexed stayed live. The build now deletes it from the published tree after the copy step, at both / and /enhanciar/; the /features API route is gone, since serving it from the backend would have restored the exact URL we just removed; and it is dropped from the no-cache header pattern. The file stays in frontend/public/ and still gets an entry on every change — including this one. It is our log, not a published page.TRIAL_DAYS defaults to 0 and no deploy sets ENHANCIAR_TRIAL_DAYS, so a new account lands unsubscribed and every LLM route 402s — the trial was never granted to anyone. Pricing, Terms and Refunds all already said, correctly, that there is no free trial; onboarding was the lone outlier, and the first screen a new signup sees. The final step also said “Free tier active”, and there is no free tier.daily_token_cap when the caps came out, and the formatter falls back to zero — so the order summary offered a paying customer nothing per day, and the payment-success page congratulated them with “your daily cap just jumped to 0 tokens”. Ten sites in all, including the plan feature lists, the Settings plan strip, the sandbox plan switcher, and two error messages still telling people to wait until tomorrow for a cap reset that never happens.ENHANCIAR_TRIAL_DAYS, so enabling a real trial does not break the build. It caught one leftover (monthly_token_cap in Settings) that the manual sweep missed, which is rather the point./docs guide: what Enhanciar is, quick start, BYOK, one section per real connector, the Slack bot, ingest-vs-auto-sync, a tour of every sidebar tab, actions, MCP, workspaces, billing, FAQ.SLACK_INGEST_DEBOUNCE_SEC, the action list against the executor registry, Gmail being drafts-only against the scope. Where the code and the UI disagreed, the claim was dropped rather than guessed: onboarding advertises a 14-day trial while billing.py defaults TRIAL_DAYS=0, and the payment flow still shows a “daily token cap” field for caps the backend no longer enforces — so prices and trial length point at the Billing tab instead of being restated. Both of those are real inconsistencies worth fixing.onViewChange('source') — the sub-view the banner is already displayed on. The answer to a knowledge gap is almost always “connect the source that has it”, so it goes to Connectors now. The gap count is also badged on the Ingest nav item, because a panel is only useful if you know it has something in it.flex:1 in a container that wraps. Pinned right with margin-left:auto. It already navigated to the Ingest tab with the connector pre-selected; that part was working.1785591344.239279. The Slack connector never set DocRef.title, so the preview fell back to the last segment of the doc id — a Slack message ts. That is the one screen whose job is to tell you what a run will pull before you spend your own key on it, and it was answering with an internal identifier. Now #channel: first line of the thread._derive_title renders a proper page title, but it has the full thread and resolved display names — both of which cost API calls, and listing must not pay per thread under Slack’s ~1 request/minute history limit. The preview label uses only what the listing response already carries. A thread with no text (a file share) falls back to the channel, and having neither leaves the caller’s existing fallback untouched./api/connectors resolved Slack by uid, so any install by that user lit the badge green; ingest resolves by workspace, and the install was stamped to a team. Both are now workspace-scoped, so they cannot disagree.headerFor() exists precisely to send X-Workspace-Id, and every connector and ingest call omitted it — /documents, /available, /preview, /count, /browse, the connector list, and every ingest entry point (connector, code, GitHub issues, WhatsApp). Only GoogleByoPanel had ever wrapped its fetches in a helper that added it. So with a team workspace active, the backend saw “personal” on all of it.vite build compiled all three clean and no-undef failed them. Exactly the class of error that promotion to error was for.254 / 50,000 today while daily token caps existed. Removing the caps correctly removed the denominator — and left a bare number with no unit, which reads like a count of something you are supposed to recognise. Now “254 tokens today”. The hover text already gave the in/out/calls breakdown; the visible label was the gap.index.css correctly set --font-sans to IBM Plex Sans, the face index.html actually loads. dh-tabs.css, imported one line later and therefore winning, overrode it to Inter — which is never loaded anywhere. Every browser fell through the stack to -apple-system and rendered the system font, on every screen, while the correct face was fetched on every page load and used by nothing.DbGraph hardcoded 'Inclusive Sans' on its SVG labels too, so its text disagreed with every other label on screen; fixed the same way._ds/modernist-…/styles.css token file — red #ec3013, beige surfaces, every radius zero. It is a different design system attached to the project, not this app’s. Porting it wholesale, which was one instruction away from happening, would have turned Enhanciar red and square-cornered. The real values are inline in Enhanciar App.dc.html.{false && …} — an email-notifications block whose toggles persisted to Firestore that nothing read (we send no email), and a beta-features list whose flags gated no code path. Both were written, shipped, and unreachable. Deleted rather than left as a comment promising a someday: the git history holds them if either backend lands.SAMPLE DATA badge and a “coming soon” card, so the chats it shows are not the user’s. The component and its routing stay untouched; only the way in is gone, so restoring it is one line once the connector is real./invite @Enhanciar, then @Enhanciar why do we retry payments twice? — and says answers come back in-thread with sources, from the same brain as the Chat tab.apps.uninstall, which pulls the app out and revokes the token, before dropping the local record.auth.test check sees account_inactive and the badge flips within the 5-minute status cache. The reverse direction simply did not exist.uninstalled, and when it is false the toast says so and tells them to remove the app in Slack — reporting a clean “disconnected” would leave that silent bot behind with nothing pointing at it. An app that was already gone (account_inactive / token_revoked / invalid_auth) counts as success, since the desired end state holds and sending the user to remove a bot that is not there is a false alarm. The cached live-status verdict is cleared on the way out. 6 tests.sample_rows appears nowhere in the backend — the schema endpoint returns columns, indexes, foreign keys, row count, size and engine, and nothing else. That grid has been permanently empty since it shipped while looking like data simply hadn’t loaded. Two more in the same file: the PK/FK badges checked c.is_pk/c.is_fk, fields no producer sets, so they never rendered (real data uses key === 'PRI'); and a non-OK response from the tables endpoint is swallowed into the empty state, so a 402 is indistinguishable from “no tables”.→ ref_table.ref_column, unique, indexed, default … — and left blank when there is nothing true to say. The row-count pill appears only when row_count exists, marked ~ because MySQL’s TABLE_ROWS is an estimate.••••undefined. The “Use”, “Add a method” and “Manage in Razorpay” buttons are all omitted — there is no API to set a default instrument and no portal URL we hold, and a button that goes nowhere is worse than no button. Also fixed a pre-existing bug: the payment-history header declared five grid columns to the body’s six, so “Invoice” wrapped to a second line and every header sat one column left of the data it labelled.#1a1c26 card, #e5e7eb text) from when that canvas sat on a dark surface — on the light canvas they read as holes punched in the page.≈ Nk tokens · 7 model calls and shows no currency figure: the endpoint returns a token count with no model id, and a dollar number would be invented. “31 accepted” is not rendered at all, because no such count exists anywhere in the API.ACCEPT_EXT constant itself and the size limit from a single constant, so neither can drift from what the code actually enforces. “Stage 2 of 5” is omitted: different pipelines emit different stage counts (code = 3, connectors = 2), so a fixed “of 5” would be fiction.PaneLoading and PaneEmpty now cover Wiki, Map, Impact, Tables, DB Graph, ERD, Skills, Actions and Code Reviews, with per-pane copy lifted verbatim from the design rather than paraphrased.rgba(255,255,255,0.3) — white text left over from when that canvas was dark — so on the light canvas “no schema loaded” was invisible, and an empty graph looked exactly like one still loading. The Map had no empty state at all: zero nodes rendered as a blank canvas under a faint “Mapping your galaxy…” that never resolved, which is indistinguishable from a hang.prefers-reduced-motion: a pulsing wall of six rows is precisely what that preference exists to stop, and the layout still reads as unloaded without the movement.data / loading / empty) with no failure copy, so inventing one would mean inventing a visual language. Panes keep their existing error handling; raised as a design gap instead.:has()..dbg-table styles for exactly this had been sitting in dh-tabs.css unused, rendered by nothing. Drawn as foreignObject inside the zoom/pan group rather than absolutely-positioned divs on top, so cards inherit the transform for free and cannot drift away from their edges mid-drag. Two forces had to move with it: the collision radius is now the card’s half-diagonal (at the old 10px, cards piled into an unreadable stack) and link distance roughly doubled, because edges shorter than the things they connect read as one clump. Databases and code models stay circles — they are landmarks, not schemas, with no columns to show.enforce_token_cap, meter.check_cap, _caps_for, billing.get_caps, and the daily_token_cap/monthly_token_cap fields on every plan.DEFAULT_PLAN is free with caps of 0, so enforce_token_cap’s 402 was the only thing standing between "signed up" and "full product". Removing it naively would have made Enhanciar free for anyone who created an account. Replaced with an explicit require_active_subscription asking the one question that actually matters: do you have a plan? It reads through effective_plan_id, the resolver that honours the sandbox test-plan override and downgrades expired subscriptions, so a lapsed plan correctly lands back on the gate. Still a 402 — that routes the UI to Billing, where a 403 would read as "not allowed" with no next step.Depends(enforce_token_cap), which returned the uid and therefore stood in for Depends(current_uid). Deleting it rather than substituting would have turned each route’s username: str into a FastAPI query parameter — unauthenticated and impersonatable by anyone appending ?username=. A test now pins exactly that. The GitHub webhook’s two inline cap checks got the same treatment; the push branch returns 402 rather than 429 because GitHub retries a 429 with backoff, hammering us over a state that will not resolve on its own./api/usage/* and the admin Costs page are all built on it, and a BYOK user still wants to see what they spent. The meter is now a meter rather than a budget: no denominators, no progress bars, no ∞ sentinel. One trap on the way — the Cost dashboard used usage.caps as its "real data arrived" marker, so dropping the field from the API would have left it permanently blank; the marker moved to a field that still exists.unlimited_quota flag — which is not dead, since it still lifts both of those and is force-enabled on sandbox. Its description no longer claims to bypass a paywall it can no longer reach. 14 new tests; suite at 1357.X-GitHub-Delivery via the same atomic Firestore create() the Slack mention de-dupe uses. Claimed after signature verification, deliberately: otherwise a forged request could burn the id of a genuine delivery and suppress it, turning a replay guard into a denial-of-service. It fails open — the opposite of the Slack ingest debounce — because a dropped push means the wiki silently goes stale with nothing to point at, while a duplicate costs one redundant ingest the user can see and re-run.domain: matching resolves a uid to an email, and that lookup was cached for the process lifetime — including its failures. One transient Firebase error stored "no email" permanently, and that user silently stopped seeing documents they were entitled to, with nothing to point at. Failed lookups are now not cached at all (the next check retries), a genuine "this account has no email" still is, and the whole cache gained a 60-second TTL to match the membership cache — because emails do change, and a stale one silently decides access.domain:gmail.com, one shared "anyone with the link" as public:*. Those labels mean "everyone on this team" where they came from; read across a tenant boundary they mean everyone on Earth. Worse, the fallback only fired when you lacked a page of that name — the normal case — so it was the easy path, not the hard one. Fixed in two independent layers: the cross-namespace scan is deleted (a page you can’t see in your own workspace is now a flat 404), and acl.is_visible makes workspace membership a precondition rather than one option among several, so an ACL can only ever narrow access within a workspace and never grant it across one. Neither layer alone is load-bearing. A test that used to assert public:* was "visible to anyone" now asserts the opposite for a non-member — it had encoded the bug.enhanciar/net_guard.py resolves the host and refuses if any address it resolves to is private, loopback, link-local or reserved. Three details make that real rather than decorative: it resolves rather than pattern-matching (blocking the string "169.254.169.254" is useless when an attacker can point their own domain at it), it checks every answer (judging the first is a coin flip they get to re-toss), and it re-checks after redirects (a public URL may 302 inward). Crawls re-check every followed link, since each hop is a fresh user-influenced URL. DNS rebinding is explicitly not covered — that needs connect-time pinning — and is documented rather than implied.run_sql_read returned the raw driver error, which distinguishes "connection refused" from "not a database" from "timed out" — exactly enough signal to map the internal network one guess at a time. The real error is now logged and the user gets a generic one. Related: kind was validated against a whitelist but never compared to the DSN itself, so {kind:"postgres", dsn:"sqlite:////app/data.db"} was accepted and read local files. The declared kind and the DSN scheme must now agree, and unlisted dialects are refused outright.X-Workspace-Id rejected, owner gating consistent, no action executable without approval, BYOK secrets encrypted and masked on every read path, ingest redacting secrets by filename and content, all internal endpoints OIDC-gated, no dangerouslySetInnerHTML anywhere. 25 new tests; suite at 1331.since parameter, a per-connector watermark, a partial-listing guard — and the Slack connector always accepted since. The Slack pipeline simply passed None, one line, which means "give me everything". Slack-event and auto-refresh jobs now carry the last successful watermark, so a new message costs one thread instead of a month of every channel. A human clicking "Ingest now" still gets a full pull, because that is what pressing that button means.last_list_partial (page cap, thread cap, API error, and an unreachable channel list), and the pipeline withholds the watermark when it is set. Finding nothing, by contrast, does advance it — otherwise every quiet period would re-sweep from the same stale point forever.message event enqueued a re-ingest — and that job takes no since, so it re-swept ~30 days across every connected channel and LLM-enriched each document it touched, on the user’s own key. Per message. The code had been there a long time but was dead: the Events API was switched off at the Slack app, so no event had ever arrived. Turning Events on for the @mention bot brought this to life at the same time, which is exactly the kind of thing that only shows up as a bill.SLACK_INGEST_DEBOUNCE_SEC; set 0 to restore per-message). The window is claimed by bucketing time and letting the first message in each bucket win it via the same atomic Firestore create() trick the mention de-dupe uses — the doc id is the window, so exactly one writer can win and there is no read-modify-write race to lose. A message landing either side of a bucket boundary can trigger two sweeps rather than one; that is the deliberate price of not needing a transaction, and two was never the problem.support@, security@, privacy@, legal@, hello@, billing@ and beta@ appeared across the terms, privacy, refunds, security and API-docs pages, the landing footer, the pricing and payment enterprise CTAs, the beta-features card and the invoice supplier block. The mail account has exactly one mailbox — support@enhanciar.in — and is at its plan cap of 1.*@enhanciar.in → support@) was quietly delivering all six of the others. That is a fine safety net and a bad contract: it is one toggle away from silently swallowing vulnerability reports and data-deletion requests, and it invites replying from an address the recipient can never usefully reach. Everything user-facing now names the mailbox that actually exists.@enhanciar.in in connectors/base.py, which is an example of ACL domain matching, not a contact address. ENHANCIAR_SUPPLIER_EMAIL keeps its env override; only the fallback changed, and that variable is set nowhere, so invoices now carry support@ too.chat.postMessage have all been in production since the action layer shipped. What was missing was narrow: the webhook only ever looked at message events (to enqueue an ingest) and returned early on everything else — so an app_mention, which is not type message, was silently discarded. The new branch sits above that early return, and a test pins the ordering, because if it ever slips below it again every question is dropped with no error anywhere.mcp_server.query calls (_gather_wiki_context → _byok_stream), so the app, MCP and Slack cannot drift into three different answers to the same question. Sources come back as Slack links; markdown is converted to Slack’s mrkdwn dialect (**bold** → *bold*), and a long answer is trimmed with a link to the full one rather than dumped into a channel.--cpu-throttling, so anything still running after the response is written gets starved. The webhook therefore acks immediately and enqueues a Cloud Task to a new OIDC-gated /devhive/internal/slack/answer, which arrives as its own request with a full CPU slice. Cloud Tasks unconfigured (local dev) falls back to running inline.event_id. Without an atomic claim, one slow answer becomes three identical posts in the thread and three BYOK bills. Claims use Firestore create() so concurrent retries can’t both win — and fail open if Firestore itself is down, because a broken dedupe store should degrade to "might answer twice", never to "silently never answers".uid, so a shared team bot could only read the installer’s personal brain — backwards for exactly the customers who want one. installation_set now also stamps workspace_id, resolved at OAuth issue time by the membership-checking _resolve_workspace_ns (so the callback can trust it and nobody’s consent can be attached to a workspace they don’t belong to) — the same pattern the Google install route already used. Two ids travel from the webhook to the worker and they are not interchangeable: ns is which brain to read, uid is whose BYOK keys pay for it, because keys live on the user document and looking one up by a team workspace id finds nothing and turns every answer into "no LLM key on file". The ACL viewer stays the installer, since a Slack identity is not an Enhanciar one. Legacy installs have no workspace_id; they fall back to the personal namespace, which is exactly where they pointed, and are matched by uid only for that workspace — folding them into every workspace the installer belongs to would silently widen what a team brain can read. In-flight Cloud Tasks enqueued by the previous revision carry no ns and fall back the same way rather than being dropped mid-deploy. The catch that would have made all of it inert: the Connect button never sent X-Workspace-Id, so the backend would always have seen "personal" no matter which workspace was active — fixed in both OAuth entry points.app_mentions:read to the install — without it Slack never delivers the event at all, so existing installs must reconnect or the bot looks silently broken. Behind a new slack_bot flag, default OFF because every mention spends the workspace owner’s BYOK tokens; sandbox forces it on. Answers are grounded in the brain of whoever installed the app — Slack identities are not Enhanciar identities. 25 tests._enforce_member_cap read the owner's stored plan field directly instead of going through billing.effective_plan_id — the one resolver that both honours the sandbox test-plan override and downgrades expired subscriptions. Reading the raw field got it wrong twice over.team subscription was read straight out of the document with no expiry check, so a workspace whose plan had already ended still admitted ten members. That one was quietly giving away paid seats.is_configured() returns False and the send is a no-op. Sandbox now uses the same mail credentials as production, with the sender name marked Enhanciar [sandbox] so a test invite is distinguishable from a real one in the recipient\'s inbox.in / out / spend are now Tokens (in) / Tokens (out) / Spend (est.).at, model, kind, in, out. Adding the column would need the recorder to persist a per-call cache figure, and would only populate going forward. An empty or invented column is worse than no column.connectorIcons.jsx has carried the real brand marks all along and four other panels already use them — Settings was simply the one surface that never did. Each row now leads with its service's own logo.KindIcon drawn in the same single-stroke style as the nav, so a row mixing a GitHub logo and a "Docs" icon reads as one set. The tour reuses the nav icons directly, keyed by the tab each step points at..icon in that file found only the definition — but the value was destructured and rendered 160 lines further down. Renaming it blind would have silently emptied that glyph on every history row.currentColor and dim with the label when a tab is inactive.weight="duotone" when active. With the Settings rail now drawing the comp's single-stroke icons, that left two icon languages one panel apart. The whole set is now the comp's ICONS table, copied verbatim, rendered through one small NavIcon component; active state comes from the button's own colour rather than a per-icon weight prop. Phosphor is no longer imported here.map and ingest; the app calls them graph and input. The icon keys use the app's names — renaming panes would have churned routing and deep links for a presentation change.Enhanciar App.dc.html): the activation checklist keeps the left, and a new rail on the right carries Recent sources (drawn from the manifests Home already fetches) and Ask a starter question. Above both, a stat row. Collapses back to a single column under 1040px so the rail stacks instead of crushing the checklist.HomePanel has carried a rule since v1 — nothing here invents numbers — so only tiles with a real source behind them render: wiki pages, questions asked, sources. The Stale pages tile was deliberately dropped: there is no aggregate endpoint for it, and a hardcoded 7 would be a lie rendered in the product. It comes back when the endpoint does.App → AgentChatV2 that doesn't exist yet; passing an argument onNavigate silently ignores would have looked wired without being wired.#1C525D, #0E1113, #E4E1D6, #fbfbfc), its fonts (IBM Plex Sans + JetBrains Mono), its shell and its nav groups are already what the app ships — the comp even notes its brand icons were copied verbatim out of connectorIcons.jsx. So the remaining work across the other 20 panes is per-pane structure, not a design-system migration.enhanciar.in is served by Firebase Hosting, not Cloud Run: Hosting serves every static page and the built SPA bundles itself and only forwards a few prefixes (/devhive/**, /enhanciar/api/**, …) to the Cloud Run service. CI deployed only Cloud Run, so any change to a static page, the landing bundle, or firebase.json's rewrites and headers needed a manual firebase deploy --only hosting on top of the push — which is exactly the kind of step that gets forgotten. Shipping /security proved it: the deploy went green and the URL still 404'd until Hosting was pushed by hand.firebase deploy --only hosting. Auth reuses the existing Workload Identity Federation step (firebase-tools reads Application Default Credentials), so there's no long-lived FIREBASE_TOKEN secret to rotate or leak; the CI service account was granted roles/firebasehosting.admin, which it did not previously hold.enhanciar.in — while sandbox.enhanciar.in is a custom domain on the sandbox Cloud Run service, not a second Hosting site. A sandbox push that deployed Hosting would therefore overwrite prod's static content from an untested branch. The if: github.ref == 'refs/heads/main' on all three steps is the only thing preventing that, and the workflow says so in a comment so nobody relaxes it without giving sandbox its own Hosting target first./security page. We ask people to connect their codebase, their Slack and sometimes their inbox, and until now the only answer to "what can this thing actually do with my data" was a four-tile band on the landing page. The new page answers it per connector: what each scope reads, and what it categorically cannot do — gmail.compose creates drafts only and does not grant send; Slack has channels:history but deliberately not users:read.email; Drive is read-only; anything that touches the outside world is proposed, approved, then executed exactly once. Plus where data lives (Firestore + bucket in Mumbai, compute in Singapore), how keys are encrypted (Fernet at the application layer, never logged, never readable back), the subprocessor list, and how to delete everything. It also states plainly that we hold no SOC 2 or ISO 27001 today, because a small team's security page earns trust by being legible, not by implying an audit we haven't had. Served at a clean /security URL (Cloud Run route mirroring /privacy, plus the Firebase cache-control entry) and linked from the landing footer.enhanciar/model_catalog.py + GET /api/models/available asks each provider what your key can actually reach — Google's v1beta/models, OpenAI's and Anthropic's /v1/models, and any OpenAI-compatible custom endpoint (Groq, Ollama Cloud, OpenRouter, Mistral, a self-hosted gateway) — and merges the answer over the curated list.groq/llama-4-90b, not llama-4-90b) because _provider_for falls through to Google for anything unrecognised — a bare Groq id would have been sent to Gemini with a Groq key. And the response cache is keyed on a hash of the credential, never the credential itself, so rotating a key invalidates its own entry without us tracking rotations, and no key material sits in the cache dict. 27 tests in tests/test_model_catalog.py.doc_id + body_sha frontmatter, and a resolver re-links existing pages to their source documents retroactively (URL first, stored ids second, slug reversal third — and it refuses to guess rather than mark a stranger's page). Found and fixed on the way: GitHub-issues pages wrote no source: at all, making them invisible to the source filter, and action/relation backlinks pointed at un-slugged page names that don't exist on disk.## Changelog is excluded (it names every symbol trivially).last_verified on a connector page, and a page with no record scores full confidence forever — so a six-month-old Jira page read exactly as trustworthy as one written today. Every pipeline now stamps the page it just wrote (writing is verification — it was rendered from the source seconds ago), keyed on the on-disk slug, flag-gated, and never able to break an ingest.agent_task/ with no id, because the id was read under a key the writer never set; and the count of actions found was written to the record after it had already been saved, so it reached nobody. The id is now minted before filing and both values land in the single append-only write.freshness flag (default off). Ranking penalties, the "may be out of date" note in answers and the wiki badges shipped in part 2 above; the re-verify action follows. Event-based staleness currently covers code pages; connector pages get age-based decay only, because per-document content hashes do not exist yet — a deliberate cut, recorded as one.MobileChatApp.jsx froze its session_id for the life of the mount (useState(genSessionId)) and the "+ New chat" button only reset the message list, never the id. The server keys its pending-clarify document on (uid, session_id) and permits one outstanding round per session, clearing it only when the user's choice comes back or after a 24-hour TTL. Mobile's stream reader handles only text and trace, so it silently drops action_needed and can never echo a clarify_choice — meaning the first clarify round asked on that session was orphaned by construction, and the cap then suppressed every later round for 24 hours across every "new" chat in the tab.genSessionId was copy-pasted in both chat surfaces; it now lives once in frontend/src/api.js with a comment recording the invariant it exists to protect — hold it in a ref, regenerate per thread, never freeze it per mount — so the two surfaces cannot drift again.gemini-2.5-flash and gemini-2.5-flash-lite both return HTTP 404 — "This model is no longer available to new users", and gemini-2.5-pro returns 429 (no free-tier quota). gemini-3-flash-preview and gemini-3.1-flash-lite return 200. Because our own server key lives in an older, grandfathered GCP project, the app looked healthy to us while every new BYOK customer hit a 404 on their first ingest or first chat message.gemini-3-flash-preview is now the default everywhere a Gemini default was hardcoded (models.py GEMINI_MODEL/FALLBACK_MODEL, llm_call.py, llm_model.resolve_ingest_model, server._resolve_user_model_prefs and the prefs GET, media_analysis, media_vision, workforce), and gemini-3.1-flash-lite is the cheap/lite tier (LITE_MODEL_FOR_PROVIDER['google'], resolve_lite_model's hosted fallback, the wiki-tools model ladders, and the page-writer's ENHANCIAR_PAGEWRITER_MODEL default). The models.py defaults carry a comment with the verified 404/429 matrix so nobody "tidies" them back.openrouter/ model auto-fills https://openrouter.ai/api/v1 and a mistral/ model auto-fills https://api.mistral.ai/v1, exactly like the Groq / Ollama Cloud auto-fill, so you only paste the key. Pickers gained openrouter/deepseek/deepseek-chat-v3.1:free (free) and mistral/mistral-small-latest, and the lite-model resolver knows a cheap variant for each. The prefix→provider lists in llm_model.py and server.py were collapsed into one shared CUSTOM_PREFIXES tuple so the two can no longer drift.gemini-3* ids still resolve to the google provider (they fall through the gpt-/claude-/custom-prefix checks) and still satisfy the frontend's isGemini batch-eligibility test — both now asserted in tests rather than assumed.tests/test_lite_model.py updated to the new lite id and extended with OpenRouter / Mistral lite variants plus an explicit _provider_for regression guard for gemini-3-flash-preview, gemini-3.1-flash-lite, and the two new prefixes.action_needed line the previous entry added renders under the finished answer as a row of chips with a per-kind lead-in ("Needs live data —" / "Missing a source —" / "Which one?"), the recommended option in violet, and a "Not now" dismissal. Connect PostgreSQL on an auto-connectable database connects it in one POST and re-asks your original question for you; everything else deep-links into the Connectors tab with that connector pre-opened. Unknown future kinds render nothing rather than a dead chip.chat_history flag. The last 4 turns ride along with each question as a bounded === PRIOR TURNS === block (re-truncated server-side to 4 × 2000 chars; the client is not a trust boundary), and a question too short or too back-referential to retrieve on its own words ("what about refresh tokens?") gets the previous user turn prepended to the retrieval string only — never to the question the model is asked to answer, which would make it answer the previous turn. Default off; sandbox forces it on via ENHANCIAR_FF_FORCE_CHAT_HISTORY=1.USER CLARIFIED: prompt line.clarify_chat off. Both are now gated server-side on chat_history (gating the client would not have counted — an old tab keeps sending history either way), and the two dark-launches are independent: each is provably a no-op on its own.session_id, which the client froze at mount. Consuming the round in one thread silently starved every other thread for 24h, and reopening the first thread and tapping its stale chip resolved against the other thread's options — pointing the live query at the wrong database. The id is now derived per thread (from the chat id once saved, a fresh id for the unsaved first turn) and re-derived on every new/open. The Firestore doc id also carries the caller's uid now, so two scripted clients in a shared workspace can't collide on session_id: "1".clarify_rounds: 1 and clarify dead for 24h. The latch is gone; the server holds the state, so the server holds the cap.ingest_source chip now opens Connectors, not Ingest (it is only offered for a connector that isn't connected, so the Ingest view failed its /available call on arrival). A stateless API client with no session_id no longer gets an unanswerable clarify chip on every single turn. Network-blip messages are tagged isError so they stop becoming conversational context, and a clarify turn keeps the real question in history instead of a bare Use "prod". Chips no longer render under an error banner. Clicking a chip no longer wipes a half-typed draft. Token metering now counts the history / clarified / detected blocks — for providers that report no usage at all (the custom OpenAI-compatible endpoint) that was up to ~8KB of prompt per turn billed as zero.tests/test_clarify_chat.py grew per-flag control in its harness plus: history flag off ⇒ empty prompt block and an un-expanded retrieval string, on ⇒ both; the two flags proven independent; no clarify event without a session_id and a correct one with it; the chat-state doc id scoped per caller; and the metering estimate growing with the new blocks. tests/test_a5_live_data.py adds the ambiguous-set-with-no-code-schema case (and a guard that the Firestore branch still wins). Frontend invariants (per-thread session id, no clarify latch, guarded continuation, locked composer) are asserted on the source, since the repo has no JS test runner.action_needed event built from the databases the ingest scan already detected in your committed code, rendered as a "Connect PostgreSQL" chip. It fires on two hard misses we can see before answering: a live-data question with reason == no_db (from the A5 outcome codes), or retrieval returning nothing at all._load_detected_sources → per-source suppression) was factored into one _connector_suggestions_for(username, ns) helper now shared by GET /api/connectors/suggestions and the query path, so the chip and the "Suggested" section can never disagree.clarify_chat catalog flag (default off, sandbox forced on). With the flag off no action_needed event is emitted and the detected-sources prompt block is empty. Scope of that claim: it covers everything clarify_chat gates, and nothing else — conversational history shipped afterwards and rides its own chat_history flag, so "both flags off ⇒ byte-identical" is the accurate statement (see the entry above). The event type is additive — both chat UIs switch on ev.type and ignore unknown types, so older clients simply render nothing. At most one action_needed per stream, ever.tests/test_clarify_chat.py (offline): flag-off stream parity, hard-miss ordering (the event precedes the first text chunk), soft-miss gating on citations + the shared IDK regex, the live-data intent classifier, option invariants (≤4, exactly one recommended), and the event key order._gen_sql_from_question) and its Firestore twin (_gen_collection_from_question) hard-imported google.genai and silently returned nothing without a Google key — so an OpenAI- or Anthropic-only user asking "how many orders yesterday?" got a wiki-only answer even with a database connected. Both now run through llm_model.lite_complete, the same public lite-model hook chat-title generation uses, which resolves the user's own cheapest model across providers (Gemini Flash-Lite / GPT-4o-mini / Claude Haiku / Groq 8B / gpt-oss / custom). The SELECT-only prompt and the read-only validation downstream are unchanged; an empty/NONE completion still means "no query," exactly as before._pick_sql_connection(conns, hint) helper (shared by query_user_db, user_db_schema and resolved_schema via an optional connection_hint) picks by (1) the connection name appearing in the hint, (2) exactly one connection whose name tokens overlap the question, (3) a single SQL connection, and otherwise (4) returns an ambiguous_connection sentinel — with the candidate names — instead of querying the wrong database. With no hint it still returns the first connection, so every existing caller is byte-for-byte unaffected; the chat query path now threads the user's question through as the hint._maybe_live_data used to return a bare string, so callers couldn't tell "no DB" from "no rows" from "ambiguous target." It now returns (text, reason) with reason ∈ ok / no_db / no_schema / no_sql / not_read_only / no_rows / ambiguous_connection / error. The call site is unchanged in behaviour (it still uses only the text and falls back to the wiki answer); the reason is wired up for the upcoming connect-DB / "which database?" chat chips.tests/test_a5_live_data.py (offline): SQL/collection generation via a monkeypatched lite_complete for a no-Google-key user with google.genai poisoned so any import would fail; None on empty/NONE; _pick_sql_connection across all four selection branches; query_user_db picking the right DB from two connected sqlite DSNs and returning the sentinel on ambiguity; and the _maybe_live_data reason mapping. New file plus the db-query, A0 and route-registration suites green._detected_sources.json) under the active workspace namespace, but GET /api/connectors/suggestions and POST /api/connectors/db/auto-connect both read the personal uid path — so a team brain never got the "we noticed you use Firebase, connect it" nudge and one-click auto-connect found nothing. Both routes now resolve the namespace with the same _resolve_workspace_ns helper the rest of the connector API uses and read through one shared loader.suggestions = [] if db_connected else detected hid every detected source the moment any one database was connected. It now filters per source: a detected entry is hidden only when a live connection actually covers it — SQL entries matched on DSN host + database name (creds/port ignored), Firebase on projectId, with a label-vs-connection-name fallback for secret-needing SQL entries that carry no DSN. Connect Postgres and your detected Firebase is still suggested.message event — including the bot's own messages and edits/deletes Slack echoes back — enqueued a full team re-ingest. The handler now early-returns when the inner event carries a bot_id or a subtype of bot_message / message_changed / message_deleted, before enqueuing; HMAC verification and the url_verification challenge echo are untouched. Genuine user messages still trigger ingest.tests/test_a0_fixes.py (offline, fake Firestore): team-namespace resolution surfaces the detected file the old uid path missed; one connected Postgres + one detected Firebase keeps Firebase suggested while suppressing the matching Postgres; and the webhook ignores bot/edit events while still ingesting a real user message. Connector, webhook, route-registration and db suites green.none / solo / team / unlimited — let a tester on the password-gated sandbox impersonate any plan to exercise plan-gated features and token quotas without a real charge. The active tier is highlighted; the card renders only when GET /api/billing/me reports test_plan_switcher: true, so customers never see it.GET/POST /devhive/api/billing/test-plan endpoints raise 404 unless the deployment carries the ENHANCIAR_TEST_PLAN_SWITCHER=1 marker (added to the sandbox env in .github/workflows/deploy.yml; the sandbox password gate is a fallback signal). Prod never sets it → the endpoint effectively does not exist and get_test_plan always returns None.ENHANCIAR_FF_FORCE_UNLIMITED_QUOTA=1 at the top of the flag chain. Quota resolution (usage._caps_for) now consults the per-user override first, so picking Solo yields 200k/day·5M/mo, Team 5M/day·100M/mo, and none the planless 0-cap paywall — while no pick leaves the forced Unlimited default (and prod) byte-for-byte unchanged. unlimited keeps ∞ and behaves like the best plan (Enterprise) for feature gates.unlimited_quota force/flag → real billing plan caps. Plan/feature gating: billing.effective_plan_id() returns the impersonated plan key (none→free, solo→solo, team→team, unlimited→enterprise) and is surfaced as plan in /api/billing/me. Writes users/{uid}.test_plan; switching drops the per-user flag cache and a page refresh re-reads the caps into the header token meter (the UI says so).tests/test_test_plan_switcher.py: marker on/off gating (available on sandbox, absent/raises on prod-like env), override round-trip + clear, unknown-value rejection, effective_plan_id mapping, and quota resolution honouring the override incl. beating the FORCE flag — plus the no-override case keeping forced Unlimited. Backend suite + npm run build green.DELETE /devhive/api/workspaces/{ws_id}. The personal workspace is never deletable (400); a non-owner gets 403; unknown id 404. Because a team workspace's namespace is ns == ws_id, its brain lives everywhere a personal brain would, so deletion purges the lot: the workspace doc + its members array (every membership vanishes at once) + all pending invites, plus best-effort purge of the brain — on-disk pages under WIKI_ROOT/<ws_id>, the durable GCS copy under <ws_id>/, and the Firestore users/<ws_id> doc + its ingested-content subcollections (wiki_pages, proposed_actions, retrieval_signals, chats, agents, agent_tasks, meetings) — and detaches any Google accounts shared into the workspace (their tokens stay on the owner's personal workspace). Best-effort per step: failures don't abort, they're returned in errors with status partial. The frontend Delete button (on owned team cards only) opens a red type-the-workspace-name confirmation modal spelling out exactly what is lost; if the deleted workspace was active the UI snaps back to Personal.0wsEO…KFR2 · owner). GET /api/workspaces now resolves each member uid → display name + email server-side (Firebase Auth get_user, falling back to the Firestore profile's display_name) and returns members_detail; the panel renders "Name (you) · owner" with the email beneath, never a uid.DELETE /devhive/api/workspaces/{ws_id} to the route-registration check and four workspace tests: delete_workspace removes the doc + invites; the route enforces owner-only / personal-not-deletable / 404; and an owner delete purges membership. frontend build + backend suite green..hv-stage wrapper that doesn't exist (the element is .hero-visual) — so the rule never applied. On a phone the cards squished into ~347px, overlapped each other, clipped their own text (parsing src/api/… cut off), and left a tall band of dead space. The visual is now hidden below 880px and the hero column forced to a single track; the headline + lead carry the hero, as the task permits. No page-level horizontal scroll remains at 375px.features.html scrolled sideways on mobile. Long unbreakable tokens in the changelog — inline code like ENHANCIAR_EARLY_ACCESS_PRICE_USD and bare paths in prose such as /api/impact?target=…&depth=… — pushed the document to ~509–647px wide with no way to shrink. Added overflow-wrap: anywhere to code and an inherited overflow-wrap: break-word on body. Page is now exactly 375px wide, zero overflow.background-clip: text) can have its slanted glyph edge clipped by the h1 box or the hero's overflow:hidden; added trailing padding so it never touches the visual column at any width. Tap targets, type scale and grid stacking on the landing were re-verified at 375 / 768 / 1280; the mobile menu button is now 40px. privacy, terms, refunds and benchmarks.html were audited and already passed (their tables/code blocks scroll inside their own wrappers).enhanciar.in routed through the Cloud Run domain mapping into the FastAPI service, which served everything — including plain static HTML. With min-instances=0 a bare GET / cold-started in 14–26s (median ~22s), and there was no CDN, so every byte came from Singapore. A real visitor waited ~24s for the landing page.hosting block to firebase.json (preserving the existing firestore + storage rules) with rewrites that send /enhanciar/api/**, legacy /devhive/**, and /…/mcp to the enhanciar Cloud Run service (asia-southeast1), and an /enhanciar/** SPA fallback. Hashed assets get an immutable 1-year cache; HTML revalidates. /__/auth/* is now Hosting-native for the project (no backend proxy needed).base: '/enhanciar/', so pages reference /enhanciar/assets/… while the HTML files sit at the dist root — the backend papered over this with request-time URL normalization, which Hosting has no equivalent for. A new idempotent postbuild step (frontend/scripts/build-hosting.mjs, wired into npm run build) assembles frontend/dist-hosting/ whose on-disk shape matches the real URLs: HTML + collateral at the root (clean URLs), a full copy under /enhanciar/ (assets, dh-track.js, favicons, SPA shell), and the root index.html overwritten with the landing page so bare / serves marketing, not the app. The Vite build and the backend's own serving are untouched — Hosting is purely additive./privacy, /terms, /refunds, robots.txt, sitemap.xml, llms.txt), the SPA at /enhanciar/, SPA deep-route fallback, and immutable asset caching all serve correctly. The DNS cutover to enhanciar.in (add custom domain in Hosting, then remove the Cloud Run domain mapping) is a deliberate later, manual step documented in docs/planning/CUTOVER.md — nothing about production DNS or Cloud Run was changed. Note: Hosting rewrites do not proxy WebSockets, so any /enhanciar/ws feature must reach Cloud Run directly after cutover./early-access/info response issued five sequential reads of config/early_access — measured at ~250–360 ms of its ~550–650 ms. One cached read (45s TTL) now serves them all; admin writes invalidate it so the panel still feels instant. Verified with a counting fake: 5 reads → 1, and 0 across the next 20 resolutions./api/brand deleted from the landing's first paint. It was a whole Cloud Run round trip for a value the server hardcodes ("Enhanciar"). Brand now rides on the /api/app-open probe the landing already makes, so it costs nothing: 3 API calls on first load → 2. The endpoint stays for older clients, and the landing still defaults to "Enhanciar" so a failed probe never blanks the header (verified with the backend down).Cache-Control on the landing HTML. Browsers heuristic-cache an HTML document with no directive, so after a deploy a stale copy could reference hashed assets that had been purged — a broken page for returning visitors. Now no-cache (always revalidate; ETag still makes that a cheap 304). Hashed assets keep their immutable 1-year caching — only the entry document revalidates.require_user verified the identical token again — two blocking calls on an async path, on every authenticated request, plus an uncached Firestore invite lookup. The middleware now stashes its verified claims on request.state for require_user to reuse, and the allow/deny verdict is cached per credential for 60s (an invite going live a minute late is fine — feature flags already accept that staleness). The cache is keyed on a SHA-256 of the credential, never the raw token.payments collection, but subscription payments are written to users/{uid}/payments, a subcollection — a top-level query matches nothing, so it read "no payment events" even with real data. Now a collection_group query sweeps every user. (2) Early Access Pass purchases were never going to appear there at all: the buyer has no account yet (they're just a waitlist email), so the money is recorded in waitlist_orders. The Billing page now has an Early Access Pass purchases section (passes sold + gross collected + per-order rows) reading that collection./.well-known/apple-developer-merchantid-domain-association — plain text, no extension, at the domain root. Added a route that serves it from the build (drop the downloaded file in frontend/public/.well-known/; Vite ships it to dist). Caveat worth knowing: Apple Pay and PayPal are international-only on Razorpay — they only appear for non-INR orders. While the pass charges INR, neither will show in checkout however well-verified the domain is; that's a currency consequence, not a configuration bug.ondismiss when the checkout popup closes — including immediately after a successful payment — and that handler cleared the busy flag. Verification takes a few seconds (measured 3.8s), so for that window the buyer saw the modal again with a live "Get the Early Access Pass" button, and a second click started a whole new checkout (the first real test purchase left 3 stray unpaid orders). Payment capture now latches a ref: ondismiss only re-enables the button if no payment was taken, any busy state disables it, and the button reads "Confirming your payment…" while verifying. A failed verification still re-opens it so the buyer can retry. (No money was ever at risk — the server already rejects a second order for an email that holds the pass, confirmed live with a 409.)mark_early_access hardcoded credit_inr = EARLY_ACCESS_INR, so a ₹1 test purchase recorded a ₹1,999 credit toward the first subscription — and any future price change would have had the same effect for real customers. The credit now derives from the amount actually captured (cross-checked against the amount recorded on the order at creation), stored as credit_minor + credit_currency; a USD payment is expressed in rupees via the plan's own USD:INR ratio, so a full-price pass still credits exactly the list price while a $1 test credits ~₹83. Callers that pass no amount fall back to the list price, so nothing legacy changed behaviour.ENHANCIAR_EARLY_ACCESS_PRICE_USD test-price overrideENHANCIAR_EARLY_ACCESS_CURRENCY env → USD. The price box is read in the active currency (so "1" means $1 on USD, ₹1 on INR), and the API now returns a server-rendered price_display string so the modal's symbol always matches the currency Razorpay will actually charge — no more hardcoded "$".config/early_access.price_usd in Firestore, which the endpoints resolve per request). Drop it to $1 for a real test payment, then Reset to default. Resolution order: admin override → ENHANCIAR_EARLY_ACCESS_PRICE_USD env → solo plan → $24 — one number drives the displayed figure, the Razorpay charge, and the server-side amount check together, so they can't drift.early-access/verify compares the payment against the amount stored on the order at creation (amount_paise) rather than the current price — so an admin price change between a buyer's order and their payment can't reject a legitimate charge. 2 tests added; full suite green.dh_ API key, and hit the whole API + MCP despite waitlist_mode=true. A new middleware in server.py now gates every authenticated call to /api/* and /mcp: when the waitlist is ON and the caller isn't invited/admin it returns 403. It only inspects requests carrying an Authorization header (so public endpoints are untouched), keeps a small allowlist for the endpoints a parked user still needs (waitlist status, account delete/export, flag state), fails open on any resolve error (the route's own auth still applies, so a transient can't lock out a real user), and is a strict no-op when waitlist_mode is off — so the sandbox is unaffected. 6 tests: blocks uninvited key, allows invited, no-op on sandbox, invalid key passes through, no-auth never blocked, exempt/normalise.sandbox branch is a genuine pre-prod test rig: push to sandbox → it deploys devhive-sandbox (sandbox.enhanciar.in, password-gated) at cpu=1/1Gi/max-1 with every feature flag force-ON and the waitlist forced OFF; verify there, then merge to main → prod enhanciar (cpu=2). Prod and sandbox run the same code and differ only by env "keys". (A brief experiment that deployed both services from one branch was reverted — it removed the staging buffer.)WaitlistForm (an inline form left over from the modal refactor, never rendered and not waitlist-aware — a footgun). Made the remaining static landing prose (hero subline, founder note) runtime-aware so the sandbox reads "open the app" instead of pre-launch waitlist copy.Pricing hardcoded WAITLIST_MODE = true — so the sandbox, whose whole point is to test the working app, kept dead-ending users into a waitlist modal. CTAs now read the public /api/app-open probe once on load: waitlist ON → "Join the waitlist" (opens the modal); waitlist OFF → "Open the app" (goes straight to /enhanciar/app), and the pricing tiers show their real "Start now" checkout instead of the waitlist button.PrimaryCta component + useWaitlistActive() hook back every CTA (nav, hero, mobile menu, pricing, the closing band, Actions/Code-review/Demo sections), so there's one source of truth. The default is waitlist ON until the probe resolves, so prod behaviour is byte-identical if the probe is slow or fails; only an explicit waitlist_mode:false flips to app mode. Verified prod still renders "Join the waitlist" with no regression./devhive/ in the URL/devhive/api/sandbox/unlock, so after a submit the address bar showed the internal enhanciar prefix. The form action now uses the public /enhanciar/ alias (UNLOCK_ACTION); the existing /enhanciar/* → /devhive/* middleware maps it back to the real route, and the exemption still matches on the normalized path. A test asserts the unlock page contains no enhanciar string for any normal navigation.admin/pages/14_Keys.py) that reads the platform secrets straight off the deployed Cloud Run services — both prod and sandbox — via gcloud (the same auth the panel already requires). It lists the Razorpay id/secret/webhook, master encryption key, embedding/vector key, server Gemini fallback, metrics token, sandbox password and early-access currency, side by side for the two environments, showing for each whether it's set, its length, and a SHA-256 fingerprint. A matching fingerprint across prod and sandbox means the same value is set in both.gate_allowed the moment they bought — i.e. buying your way straight into shared prod data with no invite. That is reverted: a paid holder is still gated until actually invited, exactly like a free joiner. What the money buys is position — list_entries now floats paid, still-pending holders to the front of the queue (a priority flag rides on each entry), so the admin invites them first. Copy across the waitlist modal, the pricing disclaimer and both FAQ answers was rewritten from "get in right away / refundable before your first sign-in" to "front of the queue, invited first / refundable any time before you're invited."EARLY_ACCESS_CURRENCY/EARLY_ACCESS_CHARGE_AMOUNT, USD cents by default), pinned to USD on both deployments via ENHANCIAR_EARLY_ACCESS_CURRENCY. The order-create, the server-side signature+amount verification and the info endpoint all read the same pair, so the advertised price can never drift from the charged price. A domestic-only Razorpay account can still fall back to INR via the env override; an unknown currency fails safe to USD rather than to something Razorpay rejects. (Charging USD requires International Payments enabled on the Razorpay account.)list_entries — plus USD-default and INR-fallback currency tests. Full suite green (618)./enhanciar/benchmarks.htmlfrontend/public/benchmarks.html — with both columns side by side (keyword-only baseline vs query_routing + relation_triples on), the commit they were measured at, and the two commands that regenerate them. Every figure was re-run from scratch before publishing rather than copied out of a plan document: the baseline reproduced byte-identically, and the flags-on run matched the recorded table exactly.lookup/conceptual vector weights are unmeasured because the vector path never fires; that the Phase-4 usefulness prior contributes exactly zero on a corpus with no usage history, verified rather than assumed; and that we wrote both the fixture wiki and the 60 questions, which is a conflict of interest a reader should weigh.tests/test_benchmarks_html.py parses the score table out of the HTML and compares every cell against the committed runs (tests/eval/baseline.json and the new tests/eval/routed.json), failing with the exact cell that disagrees; on top of that it pins a SHA-256 of each run's scoring content, so a change the cell check could miss — a new question type, a shifted n, a re-measurement at a different commit — still fails, and the failure message prints the digest to paste. The clock field is excluded from the hash so an identical re-run doesn't fail for no reason. Both layers were verified by mutating a baseline score and watching them fire.frontend/public/ served exactly like features.html, plus the sitemap entry. 27 new tests (plus one added to the feature-catalog guard), full suite green (591).[[category/name]] handles out of the finished answer — which of those the model actually cited. The gap between those two lists is a free, honest relevance label, generated by normal usage, with no extra LLM call and no one being asked to grade anything.0.5/(K+1) ≈ 8.2e-3 and asserted it was smaller than the rank-1→rank-2 gap. It is about 31× larger — that cap would have let a rank-2 page with a good history outrank the best match, which is precisely the failure the cap exists to prevent. The prose was right and the constant was not; it was rederived and the rejected value is now pinned in a test that fails if anyone reintroduces it.done line and leave the user watching a spinner forever, so every signal write is defensively wrapped and a dead Firestore costs a statistic and nothing else. Signals are stored per workspace under the same namespace as the rest of the app (queries truncated to 500 chars, never logged), and the prior can only re-score pages that already passed the ACL check — it never adds a candidate, so a page you can't see can't be resurrected by being popular with someone else.retrieval_feedback flag, default OFF: flag off means zero Firestore reads, zero writes and byte-identical ranking, confirmed by re-running the eval with all six scores at +0.00. With the flag on, the eval is also unchanged — a cold corpus has no usage history, so the prior is empty and contributes exactly nothing. That is the correct result and it is reported as such rather than dressed up: this phase's value shows up after real usage, not on a benchmark. 30 new tests, full suite green (563).query_routing flag, default OFF. Flag off, or any failure in classification or traversal, reproduces today's retrieval exactly — verified by re-running the Phase-1 eval with the flag off and confirming all six scores moved by +0.00. 27 new tests, full suite green (533).[[wikilink]] or an AST import told us A relates to B and nothing more, so "why did we pick Postgres over DynamoDB?" had no structure to traverse. Enrichment now also returns a relations array of subject→predicate→object facts the document actually states, each carrying the exact sentence that supports it. That evidence quote is the point: an LLM-extracted edge is noisier than a link, so every triple can always be made to show its work.relations is one more field on the enrichment JSON we already send — the same zero-marginal-cost trick action_items shipped with — so there is no second LLM call, no new key, and no change to the BYOK model. Prompt, normalizer, merge-dedupe and the post-write hook follow that existing shape line for line.depends_on, owns, decided_by, chosen_over, replaces, blocks, caused_by, documents, implements, mentions, reports_to, part_of). An open vocabulary would make the graph unqueryable, so anything else coerces to mentions — and is logged, not silently discarded, because the only way to review the vocabulary quarterly is to know what the model kept trying to say. Chunked documents dedupe by the (subject, predicate, object) triple, so a fact stated twice is filed once._index/triples.json — the directory Impact and vector search already mirror to GCS — so durability, atomic writes and the mtime-keyed cache are inherited from vector_index.py rather than reinvented. ACL is checked at query time against the live file, never a cached permission, and the check fails closed: a permission tightened after extraction takes effect on the very next query, and a triple pointing at a deleted page is dropped as a stale row. There is a test proving a restricted page's triple reaches its owner and never a stranger.links key in the same shape (plus typed, evidence and source_page), so the Map renderers and every downstream consumer are untouched. They are emitted as soft on purpose: impact.py walks only hard/code edges, so a model's guess about a relationship can never quietly change a blast-radius answer someone is about to ship against.relation_triples flag, default OFF — with the flag off triples.json is never created. Malformed, missing or junk relations mean zero triples and an ingest that still completes normally; the persist hook swallows and logs exactly like the action-filing path. 28 new tests, full suite green (506), and the Phase-1 eval re-run to confirm all six retrieval scores moved by exactly +0.00 — Phase 2 extracts and stores, and does not touch retrieval until Phase 3 routes to it.scripts/run_retrieval_eval.py runs the real _gather_wiki_context over a committed fixture wiki (tests/eval/corpus/ — 40 pages of docs, Slack-style thread digests, tickets and code entity pages for one invented company) against 60 golden questions (tests/eval/golden.jsonl), and reports recall@5, MRR and citation precision per question type.impact-question MRR (0.65, the worst bucket — "what breaks if…" answers are spread across service pages that share no vocabulary with the question). Phases 2–4 will be judged on those two, and that reading is written into PLAN_GRAPH_MEMORY.md so a later phase can't quietly move the goalposts.retrieval-eval CI job runs on pull requests with continue-on-error and always exits 0: a retrieval regression should be visible on the PR, not a red X that trains people to re-run CI until it goes green.WIKI_ROOT constants (wiki_tools's and the copy server.py binds at import time), so it can never read or write a real wiki. 7 new tests, including one proving an acl:-restricted fixture page reaches its owner and never a stranger. Full suite green (478).landing/ActionsSection.jsx): a transcript card morphing into an approval queue with a Jira and a Linear card and one violet Approve CTA. Copy leads with the differentiator — "nothing happens without your approval." Hero lead gained the clause "…and it drafts the tickets you approve"; two FAQ entries added ("Does it act without asking?" → no; "Which tools?" → Jira/Linear + GitHub/Slack/Calendar). llms.txt updated.main until the actions flag is turned on for real prod users — otherwise the marketing would advertise a feature prod visitors can't use (the exact overclaim class the July copy pass removed). Kept as its own commit so it's easy to hold back from a prod merge.list_proposed_actions() and propose_action() — let an external agent see the queue and file proposals, but approve and execute are deliberately not exposed: a human always approves in the app.ENHANCIAR_ACTIONS_DAILY_CAP, default 100/day) on executed actions — a guardrail against a runaway loop mass-creating tickets; a 429 tells you to approve the rest tomorrow. Lifted when unlimited_quota is on (sandbox/comped).sanitize_payload): strips NUL/control bytes and caps string lengths so nothing can smuggle control characters into Jira ADF, Linear/GraphQL, or Slack mrkdwn. Every route already resolves the workspace ACL; secrets never enter previews or audit details.docs/actions.md (invariants, status machine, how to add an executor, ops/guardrails). 9 new tests. Full suite green (462).source="workforce", linked to the run's ledger entry) — the Weekly Brief / Docs Steward can now hand you one-click work, not just prose. Deterministic parse (bold prefix = title, dash = title/description split), no extra LLM call; best-effort so it never turns a good run into a failure.enhanciar/actions/filing.py (file_items): flag-gated, dedupes against existing active proposals, defaults to the connected tracker, and swallows store errors — the reusable path for any producer of action items.docs/FUTURE_FEATURES.md): a SKILL.md is a human playbook, not a typed action DSL, so mapping free-text steps to executors reliably needs an intermediate representation — out of scope for v1. 7 new tests. Full suite green.gmail.create_draft): creates a draft and never sends — sending stays a deliberate human click in Gmail. Added the gmail.compose scope (drafts only, not send); accounts linked earlier reconnect once, and a 403 surfaces a clear reconnect message.notion.create_page): creates a NEW page under a page/database you pick (nothing overwritten); markdown body → paragraph blocks. A 403 explains the integration needs the "Insert content" capability and the parent shared with it.zendesk.internal_note): adds a PRIVATE note (public: false) to a ticket — the requester is never emailed; a public reply stays a human action. Closed tickets (422) and role/group limits (403) surface clean messages.github.create_issue): new github_app.create_issue resolves the repo's installation token and POSTs the issue; targets() lists your connected repos. Needs the GitHub App's Issues (Read & Write) permission — existing installs must re-accept; a 403 surfaces a clear "re-accept permissions" message.slack.post_message): new slack_app.post_message (chat.postMessage) posts to a channel; targets() lists non-archived channels. Added chat:write to the bot's default scopes — existing installs re-OAuth to grant it (a missing scope surfaces a "reconnect Slack" message).gcal.create_event): wraps the existing connectors.calendar.create_event so transcript follow-ups ("book a sync next week") queue like everything else instead of firing from the composer; resolves the workspace's Google account the same way schedule_assist does. Calendar write scope was already granted.ActionProposal and each story a child (parent_id → the epic proposal), source="prd". The Actions tab already renders these indented under their epic and approves them as a unit.approve-batch creates the epic first, then injects its freshly-created key into each story before creating it — Jira's parent field (epic link) or Linear's parentId — so the tracker hierarchy matches the plan. New parent_key arg on jira.create_issue.tests/test_actions_prd.py (sniff, breakdown normalize/reject, tree filing, Jira epic-link passthrough). Full suite green (438).frontend/src/components/ActionsPanel.jsx, under Ship). A queue of proposal cards: the tracker icon (Jira/Linear), the editable title + description, the exact evidence quote in mono (per DESIGN.md), and a target picker (project/team) fetched on demand from /api/actions/targets — so the card can't be approved until you've chosen where the ticket lands. Approve is the one violet CTA; Dismiss is a ghost button. "Approve all (N)" with a count confirm. PRD trees render as an indented epic→story group. Post-execute the card flips to its created state with a mono deep link (ENG-142 ↗). Designed empty / loading / error states; it polls only while something is mid-create.AgentChatV2) that files a pre-filled proposal (source="chat") and toasts a "Review" shortcut to the Actions tab. No auto-detection in v1 — an explicit button, zero false positives./api/feature-flags/state resolves actions truthy, so prod (flag off) never shows a tab that would 404; the sandbox (flag forced on) shows it. New api.js helpers (actList/actPropose/actApprove/actDismiss/actEdit/actApproveBatch/actTargets). Frontend build green.generic_pipeline._enrich_document already LLM-reads every ingested document; its JSON schema now also returns action_items: [{title, description, owner_hint, due_hint, evidence_quote, suggested_target}]. The map-reduce chunk path merges + dedupes them by title exactly like decisions.actions flag is on and the source is docs-like (files/notes/PRDs, Notion, Drive, Confluence), each action item becomes an ActionProposal (source="ingest") linked back to its wiki page, with the evidence quote attached and the target tracker defaulting to whichever of Jira/Linear the workspace has connected. The job result gains actions_found and the stream emits an actions event so the UI can show "N action items found — review".tests/test_actions_ingest.py. Full suite green (423).enhanciar/actions/jira_actions.py and linear_actions.py are thin ActionExecutor adapters: targets() lists the pickable destinations (Jira projects / Linear teams), render_preview() shows the exact field-by-field payload the tracker will receive, and execute() creates the artifact and returns its external id + deep link.create_issue (POST /rest/api/3/issue) + list_projects + a _markdown_to_adf converter — the inverse of the connector's ADF→markdown reader (headings, bullet/ordered lists, fenced code, bold/italic/code/links; everything else degrades to plain text). Linear: create_issue (GraphQL issueCreate, description passes through as markdown) + list_teams, with parentId support for epic→story trees.GET /api/actions/targets so the UI can prompt the connection instead of failing mid-approve.tests/test_actions_jira.py, tests/test_actions_linear.py): mocked HTTP/GraphQL, ADF round-trip, missing-creds errors, executor validation + result shape, targets. Full suite green.enhanciar/actions/. base.py defines the ActionProposal shape (workspace, action_type, editable payload, source, source_doc_id, evidence, parent_id for PRD trees, dedupe_key, status, result/error) and the ActionExecutor protocol; __init__.py is a lazy string-keyed executor registry (same pattern as connectors); store.py does Firestore CRUD on users/{workspace}/proposed_actions/{id}.mark_executing is a transactional approved → executing compare-and-set, so a retried or raced executor can never move the same proposal to executing twice (the "Early Access duplicate-charge" bug class, pre-empted). Illegal status transitions raise instead of silently passing.actions flag (404 when off), rate-limited and audited: GET /api/actions (filter by status/source), POST /api/actions/propose, PUT /api/actions/{id} (edit payload pre-approval), POST /api/actions/{id}/approve (approve_only or approve+execute), POST /api/actions/{id}/dismiss, POST /api/actions/approve-batch (PRD trees — parents before children), GET /api/actions/targets (per-executor destinations, surfacing capability gating). Execute fans out action.executed/action.failed to outbound webhooks. New flag actions in the catalog (default off); new action.execute rate-limit bucket.tests/test_actions_store.py — transitions, dedupe collision + terminal exemption, transactional double-execute guard, edit/retry; tests/test_actions_routes.py — flag-off 404, propose/list/dismiss/edit, duplicate 409). Full suite green.deploy.yml. The deploy workflow now computes a different env set per branch: main → prod (gated + capped as today); sandbox → an internal test rig with every user-facing feature flag force-ON via ENHANCIAR_FF_FORCE_* (verbose traces, graph keyboard nav, vector search, PDF/DOCX uploads, MCP card, workforce, code review, signups) and the waitlist forced OFF. Uses the force-override tier (checked before the shared Firestore global), so none of this touches prod. verify_plan is deliberately NOT forced — it exposes a real ₹10 Razorpay charge path and the sandbox shares prod data.ENHANCIAR_FF_FORCE_UNLIMITED_QUOTA=1 lifts the daily AND monthly token caps at every enforcement point (usage._caps_for short-circuits to ∞ before the free-plan 0/0 paywall), so unpaid test accounts can ingest and chat freely. ENHANCIAR_REQUIRE_VERIFIED_EMAIL=0 lets test accounts in without a real inbox.unlimited tier in ratelimit.py: any user with unlimited_quota (i.e. everyone on the password-gated sandbox) bypasses the per-minute request bucket, so heavy end-to-end testing never trips a 429. Prod is unaffected — only genuinely comped users have the flag there. 3 new tests in tests/test_ratelimit_unlimited.py; full suite green.feature_flags.is_enabled: ENHANCIAR_FF_FORCE_<FLAG>. Checked right after the kill switch and before per-user / the shared Firestore global / the plain env var. The waitlist gate is a single Firestore doc read by BOTH the prod and sandbox Cloud Run services, and the global sat above every env var — so the sandbox was gated whenever prod was, with no way to override per-deployment. This force tier lets deployment identity out-vote shared-database config: the sandbox runs with ENHANCIAR_FF_FORCE_WAITLIST_MODE=0 (waitlist OFF, so you can actually use the app to test) while prod stays gated by the same shared global. The kill switch still beats the force override — it's the emergency off.waitlist_mode's code default flipped False → True. The only thing keeping prod gated is that one Firestore doc; if it were ever deleted, the site would silently open. Now the safe default is "gated," and the sandbox opens itself explicitly via its force env var. No prod behavior change (prod is gated by the global either way).tests/test_feature_flags.py (force beats global/env/per-user, kill switch beats force, fail-closed default, value coercion); full suite green (374 tests).enhanciar/sandbox_gate.py + middleware in server.py. The open sandbox deployment (devhive-sandbox Cloud Run, mapped to sandbox.enhanciar.in) shares PROD Firestore/GCS and runs with the waitlist off, so it must not be world-open. Every browser-facing request now requires a shared access password before anything loads./api calls with Authorization: Bearer <firebase-token> (frontend/src/firebase.js). A Basic-Auth challenge lives in the same header, so the browser's stored Basic credentials get dropped the moment JS sets a Bearer header — every API call would 401. A signed cookie (HMAC-SHA256(key=password), stateless across instances) rides alongside the Bearer header untouched. Unlocking shows a self-contained violet unlock page; a correct password sets an HttpOnly/Secure/SameSite=Lax cookie and redirects back./devhive/internal/*), and signature-verified inbound webhooks (GitHub / Slack / Razorpay) never carry the cookie and enforce their own auth. Browser navigations get the HTML unlock page; XHR gets a bare 401 JSON so the SPA's fetch() sees an error instead of parsing HTML. ?next= is sanitized against open-redirects.ENHANCIAR_SANDBOX_PASSWORD is set on the deployment — prod never sets it, so the whole block is a strict no-op there (no environment-name coupling to get wrong). Sandbox responses also carry X-Robots-Tag: noindex, nofollow so the open twin can't outrank or duplicate enhanciar.in in search. 29 tests in tests/test_sandbox_gate.py (token forgery/rotation, exemption matrix, alias normalization, open-redirect defense, plus a real-server subprocess integration run); full suite green (360 tests)._call_llm, _litellm_completion, _gemini_completion, _parse_skill_json) from enhanciar/skills.py into a new enhanciar/llm_call.py; skills.py re-exports them for compatibility.enhanciar/jobs.py: job bookkeeping (create/get/status/events/subscribe, review store, executor singleton) stays in jobs.py; the job-execution runners (_run_job and the per-source pipelines, connector cursors, auto-skills debounce) moved to a new enhanciar/job_runners.py.generic_pipeline.py now checks the connector's last_list_partial flag after listing: when a listing stopped early (rate limit / page cap — Zendesk sets this), the run's synced_at watermark is withheld so the next incremental run resumes from the same point instead of silently skipping everything the capped run never listed. Zendesk's full-backfill path also now marks itself partial when it hits its page cap, not just on a 429.updated >= / lastmodified >= stamps now subtract a one-day safety margin (re-listing is idempotent; skipping is not).limit lower — now paginates on the API's own _links.next signal.after:YYYY/MM/DD is interpreted in the mailbox's local timezone; the watermark is now passed as exact epoch seconds (after:<unix>).since was used as timeMin — an event start-time filter — so a meeting created today for next week was invisible until the window crawled up to it, and edits to past events were never re-ingested. Incremental runs now keep the full ±30-day window and filter with updatedMin (a modification-time filter) instead._list_tables_mysql indexed result rows with lowercase keys, but MySQL 8's data-dictionary information_schema returns uppercase column names regardless of the SELECT's case — every MySQL 8 database ingest died with KeyError: 'table_schema'. Now uses the same case-tolerant lookup the table-introspection helper already had.demo_data.py), the internal DemoConnector and its registry entry, the POST /api/demo/ingest route, the demo-aware branch on GET /api/connectors (no more demo: true / all-connectors-connected), the demo entry in the generic job sources, and the demo_data feature flag from the admin catalog. Connector status now always reflects real credentials only.WHATSAPP_DEMO_MODE=on env opt-in (the per-user flag hook was removed with the flag).AgentChatV2.jsx parsed the /api/query NDJSON stream with a per-read chunk.split('\n') and no carry-over buffer, so any JSON event whose bytes straddled a TCP chunk boundary failed JSON.parse on both halves and was silently skipped — words/sentences went missing from long answers, and a split error event showed the user nothing. Now uses the same trailing-partial-line buffer idiom as DbGraph.jsx / the ingestion SSE reader (buffer += chunk, split, keep the last partial line for the next read), with a final flush on stream end.send() aborted any prior in-flight stream, but the sidebar's openChat and "New chat" (newChat) did not — so a still-running answer kept appending tokens into "the last agent message" of the newly opened thread, and the turn-finish persist effect then saved that polluted array under the wrong chat id. Both handlers now call abortRef.current?.abort() before switching threads, same as send().PaymentFlow.jsx's startCheckout caught subscription-endpoint failures with an inverted string match (!e.message?.includes('Subscription HTTP')): a backend 5xx without a JSON detail was swallowed and fell through to the one-time /order path — the user authorized a 30-day one-shot instead of the auto-renewing mandate they picked, with no indication — while genuine network errors (which the comment claimed should fall through) were re-thrown. Rewritten to branch on the actual HTTP status: only a 400 ("no Razorpay Plan ID configured") falls back to the one-time path; every other failure (5xx, network) surfaces as the "Could not open checkout" error.enhanciar/vector_index.py. Chunks wiki pages at heading boundaries (~512 tokens, frontmatter kept on chunk 0 only, fenced-code-aware so a # inside a code block never gets treated as a heading), embeds server-side with one Gemini model (gemini-embedding-001, 768-dim, L2-renormalized — MRL-truncated vectors aren't pre-normalized), and stores a per-workspace vectors.npy + chunks.json manifest under the existing wiki/<uid>/_index/ directory — the same one impact.py already reads and wiki_durability already mirrors to GCS, so this gets durability for free with zero new mirroring code. Write-to-temp + os.replace + a per-uid lock means a query never reads a half-written index.upsert_page is only the user-edit path — the four ingest pipelines write via write_wiki_page directly, with bulk durability afterward. So: a fast single-page hook after the user-edit endpoint's upsert_page call, plus a content-hash-diffed reindex_workspace reconcile inserted into the post-ingest durability block in jobs.py (between the wiki backup and the artifact mirror, so the fresh index rides the same GCS backup). Both idempotent — unchanged content makes zero embedding API calls. A third call site reindexes after a connector is disconnected, since delete_pages_for_source removes files directly and bypasses the per-page delete hook._gather_wiki_context in server.py keeps its exact signature and return shape — so all six call sites (chat, REST API v1, MCP query/search_wiki, workforce, code review) get hybrid retrieval with zero edits elsewhere. Internally it now runs the existing IDF keyword scorer (extracted verbatim as _keyword_rank_pages) alongside a vector-similarity search when the vector_search flag is on and the workspace has ≥50 pages, and fuses the two ranked lists via Reciprocal Rank Fusion (k=60). Context is now assembled from the best-scored chunks of a vector-winning page instead of the whole page body — fixes the exact "one 20KB page eats the whole context budget" waste the old whole-page approach had.acl.is_page_visible check the keyword path already uses, before its content can reach chat context — an ACL tightened after indexing takes effect on the very next query, and a page deleted since indexing is silently dropped rather than erroring.vector_search feature flag (already existed, previously dormant/unwired) — default off. New self-serve POST /devhive/api/wiki/reindex-vectors endpoint for manual backfill/retry, same shape as the existing /wiki/backup. 26 new tests in tests/test_vector_index.py (chunking edge cases, idempotent re-embed, deletion/orphan-pruning, cache eviction, RRF ordering, ACL-on-vector-hits, and the full fallback ladder) — full existing suite (324 tests) still green.GOOGLE_API_KEY (or ENHANCIAR_EMBED_API_KEY) is currently set on Cloud Run — without one, every embed call silently no-ops and every workspace stays on pure keyword search regardless of the flag. Rollout is phased: this lands and can soak inert (flag off); Phase 2's dark-launch is a per-user flag override on a few internal/friendly workspaces; Phase 3 is flipping the catalog default to True once verified. See PLAN_VECTOR_RAG.md for the full design doc and phase breakdown.status: P. Confirmed with the product owner it's actually live — flipped the catalog entry to status: S and restored Confluence in the Features "Sources" card, the ingest FAQ answer, and the landing.html FAQPage JSON-LD. WhatsApp stays "shipping soon" (still genuinely unconfirmed).status: P in this catalog, yet all five are already marketed as fully live throughout the landing page (IngestViz, FAQ, Sources card) without any hedge — and nobody had flagged that before this pass. So in this catalog's actual usage, P does not appear to mean "don't market it," just "not yet battle-hardened internally." Left as-is since only Confluence's status was explicitly confirmed; worth a deliberate pass to reconcile what P vs S should mean for connectors specifically.oauth.py:74), even though the ingestion side already spans Slack, Gmail, Notion, tickets and connected databases. Verified two things in code before writing copy — no invented claims: (1) enhanciar/impact.py's compute_impact walks graph.json links across the whole knowledge graph, not just the code graph, so impact analysis genuinely traces docs/entities/concepts, not only functions; (2) enhanciar/db_query.py executes real natural-language-driven, read-only SQL against a connected database, so "what's driving this month's revenue" is a real, working query path.landing.html FAQPage JSON-LD.landing.html (h1, intro, bullets) updated to match — including meta/OG/Twitter descriptions and the Organization/SoftwareApplication JSON-LD, all previously said "codebase" specifically.karpathy/micrograd repo) and the Steps/CodeReview illustrative mockups remain code-flavored — rebuilding a second live demo backed by non-code sample data is a larger project, not a copy fix, and is called out here rather than silently left as an unstated gap.status: P (not yet proven with real users). All three now read "Confluence & WhatsApp shipping soon" instead. The IngestViz chip list was already correct.landing.html updated to match (anti-cloaking: that block must mirror what users see).$19 flat vs ~$50/seat at incumbents, and 1 graph grounding every answer/review/impact-trace.position: 0). It no longer changes their queue position or status at all — a payer joins (or stays in) the exact same waitlist as a free signup and is invited in the same batch order.early_access_discount_expires_at is stamped at purchase and enforced at the actual checkout/subscription-creation step (razorpay_billing.create_subscription) via a new shared billing.early_access_discount_pct() helper — not just in copy. The ₹ credit toward the first subscription and the roadmap vote have no expiry.billing.PLANS["solo"]["price_inr_monthly"]) instead of a separately hardcoded number, so the two can never drift apart.POST /api/waitlist/early-access/order now rejects (409) a second order for an email that already holds Early Access — previously nothing stopped the same email from paying twice for no additional benefit.X-Enhanciar-* → X-Enhanciar-*), the crawler user-agent (EnhanciarBot → EnhanciarBot), the orphan brain3d page, and all docstrings/comments. The /api/brand endpoint now always returns "Enhanciar" and the dead brand_enhanciar sandbox-brand flag was removed.enhanciar/ and /devhive/api routes (already aliased to /enhanciar/), DEVHIVE_* env-var names, and the Firebase/GCP project id devhive-2d0c7 (that's the real cloud project — it can't be renamed without migrating clouds).MOCK_SOCIAL_PROOF) switches it back to the real /api/waitlist/recent feed./api/billing/me). When they subscribe, the server lazily mints a 10%-off Razorpay plan variant (Razorpay has no offer-creation API, and fixed-price recurring plans can't be discounted at runtime — a discounted plan is the only API-driven way), caches its id in config/ea_discount_plans, and routes the subscription to it. Falls back to full price on any error so a sale is never blocked.GET /api/waitlist/early-access/info, POST …/order, POST …/verify. Orders persisted to waitlist_orders; the paid section only renders when Razorpay is configured. Both paths dual-fire analytics (early_access_click / early_access_paid).waitlist_mode flag is on, every app route (/login, /app, mobile) renders a full-screen "Enhanciar is in early access — join the waitlist" screen before any auth, with no Google sign-in button at all. The SPA decides this from the new public GET /api/app-open (fails closed, so a flaky read never exposes sign-in).waitlist_mode is now a first-class feature flag (added to the catalog, admin-toggleable) and has been armed in production. Flip it off from the admin Feature Flags panel to reopen public sign-up.?founder=1 once — it persists in localStorage and restores the normal sign-in flow so you can get in and test. Obscurity-only: the real protection is the backend per-user waitlist gate, which still parks anyone not marked invited even if they bypass.waitlist_click (submit pressed) and waitlist_join (server accepted) through the existing dh-track pipeline; the admin analytics funnel now reports page_view → waitlist_click → waitlist_join with conversion rates.GET /api/waitlist/recent (60s server cache): "Someone joined the waitlist 5 min ago — #12 in line". No fabricated names/places, dismissable, respects prefers-reduced-motion, silent when the list is empty.enhanciar-67009 property (G-88V2VYNLBQ), kept separate from the in-app devhive-2d0c7 property; the waitlist form dual-fires waitlist_click/waitlist_join to GA alongside dh-track. First-party dh-track/Firestore stays the source of truth (dev audiences ad-block GA heavily); GA adds the Realtime view, channels and geography for free.FAQPage JSON-LD for AI-citation/GEO, and a price-anchor line under the pricing header (incumbents ~$50/user/mo + 100-seat min vs $19 self-serve).POST /api/waitlist (per-IP throttled, idempotent by email). Joiners get a position-numbered confirmation email via the existing SMTP sender; entries live in a Firestore waitlist collection with source/referrer attribution.waitlist_mode feature flag is on (ENHANCIAR_FF_WAITLIST_MODE=1), signing in without an invite parks you on a calm "you're on the list" screen (desktop + mobile). Rollout = flip the entry's status to invited (admin endpoint POST /api/admin/waitlist/invite also sends the invite email) — no deploy. Admins always pass; the status probe fails open so a flaky read never locks a real user out.#root; React replaces it on mount so browsers see the app unchanged./llms.txt added — the AI-crawler site summary convention; canonical plain-text description of Enhanciar./api-docs (404) removed, real lastmod dates.@app.get doesn't auto-answer HEAD; crawlers got 405 on every public page. Stacked @app.head on /, /privacy, /terms, robots, sitemap, llms.txt.ENHANCIAR_GDRIVE_FIRSTRUN_CAP), and the ingest confirm dialog gains a "Files to pull this run" selector (100 / 250 / 500 / 1000, newest first — re-run to reach older files). The preview stays a fast 100-row peek by design; the ingest no longer shares its limit.source: gdrive/gmail/gcal (+ drive_path), so after your next ingest you can isolate the Drive constellation — and its interconnections — with one chip.Projects/Purplle / release.apk) instead of flattening your whole Drive into one anonymous pile. The preview shows the path per row, and ingested wiki pages record it (drive_path metadata + a location line the summary model sees) — so search and summaries know where a file lives. Resolution is memoized per listing (one lookup per unique folder, not per file); "My Drive" root is omitted and failures degrade to the flat name, never a broken listing.onDark variant there, and an audit fixed the same miss in five more gradient spots: the thinking indicator, onboarding + payment headers, and both mobile brand marks.cache-from/to: type=gha) and Cloud Run deploys the pushed image — replacing gcloud run deploy --source, whose ephemeral Cloud Build workers reinstalled npm + pip from scratch on every one-line change (~6.5–7 min/deploy). With warm caches the dependency layers are reused, so typical deploys land in ~2–2.5 min.pip install → uv pip install in the Dockerfile (5–10× faster resolution) so even cold builds shrink. Images push to the same Artifact Registry repo, already governed by the keep-10/delete-30d cleanup policy.store-vision/, avatar-studio/, graphify-out/ gitignored so a manual --source deploy can never tar 2.5 GB of local working dirs into the upload.scrollIntoView() return a Promise (scroll-completion spec). Our auto-scroll effect used a concise arrow — useEffect(() => ref.current?.scrollIntoView(…), [messages]) — which returned that Promise, so React stored it as the effect's cleanup and crashed the whole Chat tab ("TypeError: … is not a function") the moment the conversation updated. No deploy broke this — the browser changed underneath us. Fixed by bracing the effect body (nothing returned); audited every other concise-arrow effect in the app (the rest correctly return unsubscribe functions). Diagnosed by source-mapping the production stack + capturing the React component stack live in the browser.DocRef.mime → /connectors/gdrive/available). Preview-only: the chips filter what you're looking at, never what gets ingested.--no-cpu-throttling (2 always-on vCPUs billed whenever an instance is awake) — added so background in-process ingests wouldn't stall. Internet bots (robots.txt, WordPress scans, /.git/config probes) hit the public URL in ~19 of 24 hours, each knock keeping the instance billing ~15 idle minutes — ≈₹150/day serving nobody./internal/run-job worker, so the pipeline runs inside a request and CPU throttling can't stall it. Added a retry guard so a re-dispatched task never re-runs (and re-bills LLM tokens for) a job that already finished.--cpu-throttling (+ --timeout=1800 to match the task dispatch deadline). CPU is paid only while serving; bot hits cost fractions of a paisa and fall inside the always-free tier. Idle-month Cloud Run cost: ~₹0.send_workspace_invite_and_wait)./enhanciar/ in prod. After registering the new redirect URI in the Google BYO client and the Slack app (old /devhive/… kept as fallback), the Cloud Run env vars GOOGLE_OAUTH_REDIRECT_URI and SLACK_REDIRECT_URI were flipped to /enhanciar/… — zero-downtime. The connector setup guide now shows the branded callback URL.enhanciar-<uid>.zip), the invoice PDF (enhanciar-invoice-*.pdf), and the metrics endpoint named in the knowledge doc (/enhanciar/metrics). Internal route decorators and the DEVHIVE_* env/token names stay as-is (never surfaced to users; the alias serves the branded paths)./api/google/install and OAuth-callback routes now gate on a new can_connect() check (own client required). Token refresh for already-connected accounts is deliberately untouched — that path keeps its shared fallback, so no existing connection breaks. Escape hatch: GOOGLE_ALLOW_SHARED_CLIENT=1 re-enables the fallback (local dev / a fully-verified client). The Connect button was already disabled without BYO; this closes the direct-API path too./enhanciar/. The legacy internal prefix /devhive/ no longer appears in anything a user (or third party) sees: frontend API calls (billing, impact), the Google/Slack OAuth redirect-URI defaults, Stripe checkout/portal return URLs, the CSP report-uri, and the process-map "compile skills" hint. The enhanciar_alias middleware already rewrites /enhanciar/* → /devhive/* internally, so both prefixes keep working — old links, bookmarks and the OAuth redirect URIs registered in Google/Slack stay valid. Production OAuth redirects have since been flipped to /enhanciar/… on Cloud Run (Google + Slack), with the old /devhive/… URIs kept registered as a fallback.React.lazy + a Suspense boundary in TabErrorBoundary) and loads its own small chunk only when you first open that tab. Initial JS to parse dropped ~1.29 MB → ~495 KB (gzip 372 KB → 151 KB); the biggest offender — the ER-diagram panel (68 KB gzip) — now loads only on that tab.2:00 PM → ✓ Ekta free / ● Ekta busy.1:00 PM · 3:30 PM) — click one to book it. Availability uses Google freeBusy (busy/free blocks only, never event details) via each teammate's own token, so it only works for people who've connected their Google account to the workspace.calendar.events scope (sensitive → still no CASA; existing accounts reconnect once to grant it), free_busy / create_event / find_free_slots tools, a enhanciar.schedule_assist orchestrator, and POST /api/calendar/schedule (books only your own calendar, rate-limited, confirm-before-send).claude-* / ollama-* prefix logic; no backend change needed.resolve_lite_model() maps each provider to its cheapest/fastest tier (Gemini Flash-Lite / GPT-4o-mini / Claude Haiku 4.5 / Groq 8B / gpt-oss 20B), independent of the (possibly heavy) model you picked for chat replies — so a title or a keystroke-detection never spends Opus/480B tokens. Falls back to a hosted lite model when you have no BYOK key. The Google path disables thinking (thinking_budget=0) for the biggest latency win; every path is token-capped and fail-soft.POST /api/compose/detect (rate-limited 20/min, fail-soft). Actually creating the event via the Calendar connector is the next step (TODO left in place).max_members.workspace_ids array) while tokens stay owned by them. The connectors iterate every member's account, so a team brain pulls everyone's Gmail / Calendar / Drive. The OAuth state carries a server-verified workspace id — a victim's consent can't be redirected into an attacker's workspace.oauth_client_ref) so refresh uses the same one. The personal path is byte-identical to before.workspace_invites collection + accept/revoke routes, /api/google/accounts/{sub}/share, and workspace-aware /api/google/*. 22 new tests (invite email-match / single-use / expiry, multi-account merge + dedup, share-flag ACL, creds routing).enhanciar.acl.X-Workspace-Id: Gmail / Calendar / Drive / Jira / Notion / Linear / Zendesk / Confluence / Slack / Files / Database can ingest into a shared team brain, funded by the triggering member's own credentials (creds-uid decoupled from the wiki namespace). GitHub-code / web ingest stays personal for now (that pipeline meters LLM tokens by namespace — a deferred split).max_members (Solo 1 · Team 10 · Enterprise ∞) with a clear 402 — no more sharing one flat-priced brain with a whole team.gcal). Rides the same bring-your-own-OAuth Google client as Gmail/Drive — one added scope, calendar.readonly (a sensitive scope, so it needs no CASA: Calendar isn't on Google's restricted list). Each event becomes one searchable, cited wiki page — when, where, organizer, attendees + RSVPs, join link, and the description. Recurring series are expanded into individual instances; cancelled events are skipped./api/ingest/gcal route + a "gcal" branch in run_google_pipeline, mirroring Gmail/Drive (days/max_items bounded server-side).calendar wiki category; monitor + wiki-explorer labels; and a CALENDAR_EVENT document type. 11 new pure-function tests (event→markdown, all-day vs timed, attendees/RSVP, recurring-instance doc_id round-trip, chunking)./api/ingest/gmail (or gdrive) route directly and points you to Ingest → Activity to watch.days becomes a since cutoff, max_items caps the batch (both bounded server-side)./api/connectors/{source}/available endpoint; Gmail/Drive list_documents now carry a preview title (thread snippet / filename).gcloud services enable… command) could surface in Chat to end users who have no GCP project — replaced with a plain "temporarily unavailable, try again" message.google_app.get_fresh_access_token now detects the invalid_grant (revoked/expired) case, flags the account needs_reconnect, and raises a clear GoogleReconnectRequired with a fix-it message; a successful refresh clears the flag. The Gmail/Drive panel shows a ⚠ reconnect needed badge and a Reconnect Google → button on the affected account. 3 tests./projectcreate (forcing a brand-new project); it now opens the console home so you can select an existing project from the top project-picker or create one.media_vision module sends the bytes to the workspace's Gemini model (native image + video, inline within the 15 MB cap) and turns them into markdown: description + verbatim OCR for images, summary + transcript + on-screen text for video. That text flows through the normal enrich → wiki → graph → chat pipeline, so a screenshot or recorded demo becomes cited, searchable knowledge. Hooked into files.fetch(); falls back to a clear placeholder (never fails the ingest) if no vision model is configured. This is the ToS-clean version of "save a post to the brain" — the user supplies the media, we never scrape. 3 tests.ConfluenceConnector (the audit's #1 requested source) ingests every space/page the credentials can see: Confluence storage-format XHTML → markdown via a stdlib parser (headings, lists, links, emphasis; <ac:…> macros degrade to text), CQL-driven incremental sync, cross-links to Jira/GitHub/Linear/Notion, and the standard list→fetch→enrich→wiki path. It reuses the same Atlassian API token as Jira (confluence_site / confluence_email / confluence_api_token, encrypted at rest), with its own connector card, credential form and setup guide. Wired end-to-end (registry, generic sources, ingest routes, cred fields). 4 tests.gdrive._fetch_content returned _[PDF file — extraction deferred]_ as the page body for every PDF/Office file — silent content loss. It now downloads the bytes and runs the SAME pypdf / python-docx extractors the Files connector uses; files with no extractable text (images, spreadsheets, legacy .doc) are skipped rather than materialized as empty/placeholder pages.payment.captured webhook no longer defaults to the legacy 5M-cap "pro" plan. On an orphan capture (no order notes, no pending_plan) it now infers the tier AND period from the amount actually paid (annual → 365 days, not a flat 30), falling back to the cheapest real paid tier — never over-granting, never denying a paying user.TablesBrowserV2 parsed error responses as JSON, so a 402/5xx rendered a silent empty "no databases" state; it now surfaces a real message (and a subscribe hint on 402).AgentChatV2's SSE reader had no AbortController — a tab-switch mid-answer left the fetch running and writing into a backgrounded component. It now aborts on unmount / new send.ConnectorDetailPanel. An unreachable-but-live demo path (SAMPLE DATA badge, "Ingest sample data" button, and 9 slugify-themed sample arrays) was a footgun that could leak canned strings to a paying user if ever re-enabled. Deleted the prop, every branch, and the sample data.whatsapp-web.js sidecar deployed (a separate always-on service — deferred for launch). Rather than looking connectable, the card is badged coming soon, muted, moved to the end of the grid, and its CTA reads "Coming soon".intent=subscribe resume flow (App.jsx) and the landing pricing grid (Pricing.jsx) opened Razorpay Checkout with a success handler that just redirected to /app?billing=success — it never called /api/billing/razorpay/verify-subscription, so activation depended entirely on the async subscription.charged webhook. A customer coming from the marketing page could pay and land fully gated. Both handlers now verify the first charge synchronously (like the in-app PaymentFlow) and fall back to the webhook only if verify can't confirm.POST /api/code-reviews/submit-pr was gated by require_user only (no enforce_token_cap), so a free/expired user could run unlimited LLM PR reviews for free. Added the cap dependency like every other LLM route.usage._caps_for fell back to the generous env-default caps (200k/day) — handing any user, including free/expired, a paid-tier quota and silently punching a hole in the hard paywall. It now returns (0, 0) on error (402 until billing is readable again).billing._ACTIVE_STATUSES included Razorpay's authenticated/created (mandate set up, not yet charged); with a future charge_at written as current_period_end, a user got paid caps before paying. Removed both — access now waits for a real active charge.whatsapp_app.demo_mode() auto-returned True whenever no WHATSAPP_SIDECAR_URL was set — so a production deploy (no sidecar) served the fake "nanoid maintainers / OSS Triage / Release Crew" chats, badged "SAMPLE DATA", to real paying users. Removed the auto-fallback: demo content now requires an explicit opt-in (WHATSAPP_DEMO_MODE=on or the admin demo_data flag). With neither, WhatsApp reports configured:false and shows an honest "coming soon" state instead of fake chats.unlimited_quota feature flag defaulted to True, and prod had no override — so usage._caps_for returned unlimited for everyone, enforce_token_cap never 402'd, and the entire no-free-tier paywall (free plan caps = 0) did nothing. Flipped the default to OFF: caps now enforce, so unsubscribed users are actually gated. Use unlimited_quota per-user to comp yourself for testing, never globally.verify plan (₹10) + a verify_plan admin flag. Turn the flag ON → a "Verify (₹10 test)" tier appears in Billing so you can run a REAL end-to-end payment and confirm money → active plan → paywall lifts; turn it OFF so customers never see it. Uses the one-time order path by default (no Razorpay Plan ID needed); set RAZORPAY_PLAN_ID_VERIFY_MONTHLY to also exercise the subscription path.subscription.charged webhook — a delayed/misconfigured webhook left a paying user gated. Added verify_subscription_signature + confirm_subscription (razorpay_billing.py) and POST /api/billing/razorpay/verify-subscription: the client verifies the first charge on checkout success and the plan flips immediately (idempotent with the webhook; payment records merge by payment_id). Frontend also polls /api/billing/me as a fallback before declaring success./terms + /refunds, and the false "one-time 30-day pass — no recurring charge" line is replaced with honest auto-renew/cancel-anytime copy.ratelimit._PLAN_LIMITS and falling to the free 30/min default — now 180/min like Pro./api/admin/analytics failed OPEN when ENHANCIAR_ADMIN_UIDS was unset (any authed user could read cross-tenant funnels) — now 403s unless the caller is explicitly allowlisted.notion_token, linear_api_key, jira_api_token, zendesk_api_token and database_connections[].dsn (which embeds DB user:password) were stored in Firestore plaintext — now Fernet-encrypted on write (set_account_integrations) and decrypted on read in every connector (notion/linear/jira/zendesk/database + db_query). decrypt() passes legacy plaintext through, so existing rows keep working. 6 tests.Content-Security-Policy-Report-Only (zero enforcement). Default flipped to enforced Content-Security-Policy; instant revert via ENHANCIAR_CSP_REPORT_ONLY=1. (Dropping unsafe-inline/unsafe-eval via nonces is a tracked follow-up — needs an auth/payment test pass.)/api/demo/ingest now cap-gated (enforce_token_cap) like every other ingest route.google_app.consume_state read-then-deleted non-transactionally (a state-replay race); now a Firestore transaction makes each CSRF state token truly single-use.wipe.py now enumerates collections live (was a stale hardcoded list that left OAuth/Slack tokens + chat PII behind), and the Google-accounts loader no longer surfaces encrypted token blobs. Suite 223 green.POST /api/overview/regenerate was gated only by current_uid, not enforce_token_cap — so a free/over-cap user could run LLM overview synthesis for free (unmetered), and with provider=google + no BYOK key it fell through to the operator-paid Vertex path. Now gated by enforce_token_cap + enforce_rate_limit like every other LLM route, so it 402s for unsubscribed users.enhanciar/usage.py) only wrote per-user counters to wiki/_usage/<uid>.json on Cloud Run's ephemeral disk — reset every deploy, unreadable cross-tenant, so the admin's per-user cost view always showed 0. meter.record() now also mirrors into a durable Firestore usage/{uid} doc (running totals + by_month + by_model, via atomic Increment). Fire-and-forget on a daemon thread so it never adds request latency or breaks metering; toggle with ENHANCIAR_USAGE_FIRESTORE (on by default). Covered by 3 tests (tests/test_usage_mirror.py). The admin Costs page reads it to show real per-model cost per user (BYOK tokens counted as user consumption, distinct from server-paid Vertex).TRIAL_DAYS=14). Since Enhanciar offers no free tier, the default is now TRIAL_DAYS=0 — new users land on the free record (all caps 0). The existing token-cap gate (auth.enforce_token_cap) already returns a 402 when caps are 0, so ingestion and chat are blocked until the user subscribes. (An operator can still enable a trial via ENHANCIAR_TRIAL_DAYS.)AgentChatV2), the connector-ingest path (InputPortalV2), and the GitHub/website ingest path (App.jsx). Settings + Billing stay reachable throughout.free caps record as the pre-subscription fallback — display only was removed; say the word to also revoke unpaid access.)ConnectorsPanel): connectors now reflect only real connection status, never sample data. Dropped the demo state, ingestDemo, and the unused Sparkle import.google_app._resolve_client(uid) uses the workspace's own client if set, else the shared env client; authorize/exchange/refresh all thread the uid so per-tenant clients (and their refresh tokens) work. Creds stored on users/{uid} (client_id plain, client_secret Fernet-encrypted). New endpoints: GET/PUT/DELETE /api/google/oauth-credentials; install/callback/accounts now gate on is_configured_for_uid.GoogleByoPanel walks the user through a 6-step Google Cloud setup (enable APIs → consent screen Testing/Internal → scopes → create client → copy the exact redirect URI → paste creds), then runs the normal consent flow against their client. Reference doc at docs/google-byo-oauth.md.tests/test_google_byo.py): cred encryption round-trip, BYO-vs-env resolution, per-uid gating, authorize_url uses the BYO client. Verified the panel UI in an isolated browser preview..flow, height:100dvh; overflow:hidden) with its own brand header + "back to app" — nested inside the app that already has sidebar/tab chrome, so it double-stacked headers and forced a 100vh block inside a tab. It also always opened on a "You've outgrown the free tier" marketing hero, even for paid users.BillingOverview in PaymentFlow.jsx): a Current plan card (name, price, Active/Free badge, renewal or "access until" date, token caps + GitHub installs), a Change plan / Upgrade grid (current tier marked, next tier up tagged "recommended"), and the payment history — all rendered like the Settings tab (.settings-shell/.bill-section), no full-screen takeover.since=None). Added a per-source watermark (users/{uid}.connector_cursors.{source}): run_generic_pipeline now threads a since, connectors list only docs changed after it, and the watermark advances on every successful ingest (manual too, so the first auto-sync is already incremental). Daily cost is proportional to what changed, not the whole workspace.POST /devhive/internal/connector-sync-due reuses the existing Cloud Tasks OIDC gate (require_cloud_tasks_oidc) — Cloud Scheduler hits it daily with an OIDC token; it finds opted-in workspaces, enumerates connected sources, and enqueues one incremental job each. A workspace with no LLM key just fails its jobs fast — no key is ever consumed silently.docs/cloud-tasks-setup.md (§9). Covered by 6 tests (tests/test_auto_refresh.py): source enumeration, since threading, cursor round-trip, incremental vs manual job paths. Full suite 205 green._router_entities: drop generic nouns like “user/api/database”, drop the connector's own name, dedupe, cap 6) and promoted to entities: frontmatter. generate_manifest shows ≤3 per router line and builds an inverted Entity Index (proper noun → pages), skipping non-discriminative entities that appear on >½ the corpus.search_wiki now scores the entities: frontmatter at weight 6 (same as tags), so a named question (“Razorpay”, a person, a service) routes straight to the right page instead of relying on a weak body match — the single highest-leverage retrieval change.updated:), so the current ticket beats a stale one.graphify-out/ graph is 100% code (AST) with zero connector docs, so it structurally can't retrieve Slack/Jira/Notion; the runtime graph's edges are 95% shared-token “related” edges that return the same pages as keyword search. Not Obsidian-value at our scale (dozens of docs + ~1M ctx). Kept the summary+entity manifest routing as the spine; graph community_id re-rank deferred as optional.tests/test_router_index.py); full suite 199 green.ENHANCIAR_ENRICH_INGEST=1), which uses your model and costs tokens (no key ⇒ the run fails fast). The Ingest panel now says so, and the enrichment-model picker is restored (per-run override plumbed through /api/ingest/<source>). Also fixed the stale endpoint comment that caused the confusion._manifest.md, which only listed code entities/concepts — connector pages (Notion/Slack/Jira) were counted but never named or summarized, and it read the wrong frontmatter key (sources: vs the connectors’ source:). generate_manifest now reads both keys and emits a “Connector Documents” section listing each connector page + a one-line summary (from the enrichment). This is the no-embeddings retrieval win: the summaries already existed; now they’re indexed so questions route to the right source/pages.spellCheck=off/autoComplete=off/aria-labels, disclosure toggles get aria-expanded, icon buttons get aria-labels, async previews get aria-live.GET /api/connectors/<source>/preview), so you can eyeball a Notion page before pulling it in.POST /api/connectors/<source>/remove + wiki_tools.delete_pages_for_source; covers API-key connectors, Database, Slack and WhatsApp. 3 tests.gmail.readonly, drive.readonly) that can’t be opened to the public until a full Google verification + CASA security audit clears. Rather than show connectors that access_denied for everyone but a handful of test users, they’re hidden from the Connectors grid and the Ingest rail. One-line revert (a hidden flag) once verification lands.GET /api/connectors/<source>/available lists documents read-only (no fetch/ingest); DocRef gained an optional title, populated for Notion from its search results.GET /api/connectors/<source>/documents scans the workspace wiki for pages tagged source: <name> in frontmatter (already written by the connector pipeline) and returns them, workspace-aware and cold-pod-safe. Helper list_pages_for_source in wiki_tools; 2 new tests.ConnectorCredentialForm) — no more bouncing to Settings. Settings shows a short “moved to Connectors →” pointer instead.ntn_ token) and links to notion.so/developers/connections.model override through /api/ingest → job → resolve_ingest_model(uid, override).batch_progress events: done/total + state) rather than token streaming. Gemini-only; other models auto-fall-back to individual. New enhanciar/gemini_batch.py (submit inline requests, poll batches.get, order-preserving results, per-item failures → null). Choose it per-run in the form or set a default in Settings → Models.tests/test_gemini_batch.py (6 — ordering, progress, per-item failure, padding, Gemini-gating). All prior suites green..env used to have that key material read into the wiki (the AST extractor previews the first 500 chars of every .json). New guard in repo_tools.py (_is_secret_filename / _redact_secret_content) applied across both file-reading paths (read_files_in_directory, read_single_file) and the AST extractor (ast_tools.py): *firebase-adminsdk*.json, *service-account*.json, .env (but not .env.example), *.pem/.p12, id_rsa are skipped, and any file whose content carries a -----BEGIN PRIVATE KEY----- / "type":"service_account" fingerprint is redacted. Verified: zero key material in the wiki after a full ingest.extract_firebase_config used to grab the first literal projectId in the repo — which is often .vercel/project.json's prj_… id. It now derives the id from the unambiguous authDomain/storageBucket, skips Vercel prj_ ids, and reads the browser-public NEXT_PUBLIC_/VITE_/REACT_APP_ Firebase env vars (public by design) when the config is wired through process.env. On trekngo it now resolves trekngo-new + the public web apiKey."Firestore: Trek Cards"; _clean_collection_name strips that so the REST read hits the real id, and file-shaped mislabels (trekDataFallback.json) are dropped from the schema.firestore_query against the detected project returns real rows (TrekCards → name, price, difficulty, slug, …). 3 new tests lock the extractor + normalizer (9 in tests/test_db_merge.py).data_sources block (DB type, connection hint, env-vars, and the schema — collections/tables + fields — read straight from the code it's analysing). merge_detected() in enhanciar/db_detect.py enriches matching types with that schema, adds LLM-only types, and never duplicates or clobbers regex data. Security: the worker captures connection metadata only — it's instructed to never copy a private key, service-account JSON, password, or API secret; it refers to them by env-var/file name.code_schema()/resolved_schema() in enhanciar/db_query.py union the fields the workers found and store them in _detected_sources.json. This is the only way to know Firestore collections/fields (Firestore has no live introspection), so the query agent finally knows what it can ask for.flash-lite step) and reads it via the public Firestore REST API using the committed projectId/apiKey — read-only, no service account. SQL DSNs (Postgres/MySQL) keep the SELECT path. Not-connected still short-circuits to the wiki-only answer.tests/test_db_merge.py): regex floor preserved, LLM enriches matching type with schema, LLM-only type added, no duplicate types, worker data_sources block parsed, code-schema fallback when no live DSN, and the Firestore path uses the config (not SQL). All prior suites still green (db_query 5, dedup 9, uiSpec 8).enhanciar/db_query.py: when a database is connected (Settings → Database, stored as database_connections), the Query Agent fetches live rows relevant to the question and prepends them to the answer context — so "show me winter treks" reflects current data, not just the wiki summary. Strictly read-only: a guard refuses INSERT/UPDATE/DELETE/DROP/stacked statements; every query gets a LIMIT.gemini-2.5-flash-lite step translates the question + the live DB schema into one SELECT (or NONE) — gated behind a connected DB, so it never runs or costs anything otherwise.tests/test_db_query.py: read-only guard, SELECT rows, write refused, LIMIT enforced, schema introspection). Live end-to-end (“winter treks → cards”) activates once you paste a DSN in Settings; combines with Generative UI to render the rows as a card grid/table.```ui JSON block (type: card_grid / table / stat_row / bar_chart) — same LLM call, no extra request, so no added cost. Wired into both query paths (BYOK stream + server-Gemini) via a shared _GENERATIVE_UI_PROMPT.frontend/src/components/GenerativeUI.jsx + pure parser uiSpec.js: the frontend extracts the block, strips it from the prose, and renders a typed React component (no model-authored HTML). Falls back to plain markdown when there's no block or it's malformed. Wired into desktop + mobile chat.uiSpec.test.mjs).enhanciar/db_detect.py deterministically scans an ingested repo (package manifests, config files like firebase.json/prisma/schema.prisma, connection strings, ORM/driver imports) and identifies the databases it uses — Firebase, Postgres, MySQL, MongoDB, Supabase, Prisma, Redis, SQLite, DynamoDB. Saved to wiki/{uid}/_detected_sources.json during the pipeline (skipped for web scrapes). Verified: correctly flags Firebase in the trekngo repo from its deps + firebase.json/.firebaserc.GET /api/connectors/suggestions returns detected-but-unconnected DBs. The Connectors page shows a "Detected in your ingested code — connect to query it live" banner with each DB + its evidence + a Connect button; the Connectors sidebar item shows a notification badge with the count. This is the bridge to real-time data: connect the DB the code already uses, then the brain can query it live.postgres://user:pass@…, DATABASE_URL=…). Those show a one-click "Connect (auto)" that saves the connection (POST /api/connectors/db/auto-connect); databases whose secret is (correctly) gitignored show "Add credentials". Firestore reads use the config via REST. So for repos that carry their own creds, live queries work with zero typing.Processing X/Y files · N% label and green/red end states, above the event feed.trekngo.com · Website, 📦 owner/repo · GitHub repo, 🔗 Skills/connectors) and status; robust to the old mislabeled jobs (detects web: sources regardless of stored source_type). Raised the fetch window 8 → 25 so older sources still show.get_generation_config() now sets thinking_budget=0 (override with ENHANCIAR_THINKING_BUDGET).gemini-2.5-flash-lite by default on Gemini (override ENHANCIAR_PAGEWRITER_MODEL; BYOK non-Gemini models are left untouched). Extraction/scout still use full Flash./src/Containers, another at /src, etc. The AST pre-pass parsed each domain's whole directory, so a parent-path domain and its children all parsed the same files, and every worker independently extracted the same entities. Cross-domain dedup then cleaned up the mess after the fact._domain_file_sets) is now actually enforced: extract_ast_facts_for_directory takes an include_files allow-list scoped to the domain's exclusive file set, and a domain whose files were all claimed by a deeper/first-seen sibling is skipped entirely (no wasted LLM call, no hallucinated entities). The worker prompt gained a matching “only these files” scope note.axios.js/reducer.js/StateProvider.js each a single page). Also faster + cheaper — 3 redundant workers skipped and ~135 fewer page-generations. Covered by fixture tests for the disjoint partition + AST scoping.core-frontend-logic and ui-components-pages both got /src); each worker extracted the same files under slightly different names, so the Integrator wrote them under different slugs — reducer.js and StateProvider.js ended up as 4 pages each, firebase.js/axios.js/utils.js/index.js as 3 each (129 entities where ~60–70 were distinct).…___<branch>/ stripped) before any page is generated. Colliding entities merge deterministically: longest details wins, list fields (dependencies, aliases, keywords, …) are unioned. src/index.js vs functions/index.js correctly stay separate; concepts/flows dedup by slug; web-scrape items are unaffected.axios.js), absolute (/var/.../src/axios.js), or import-style (../../firebase). A pre-pass learns which basenames appear in more than one directory: unambiguous ones key on the basename (so bare axios.js ≡ src/axios.js merge to one page), while a basename in multiple dirs keeps its directory (so the three real index.js files — src/, functions/, url-alphabet/ — stay separate). Import-refs fall back to slug keys. Result, verified live: true primary-file duplication went from 33+ → 12 → 2 across refinements (reducer.js ×4→1, StateProvider.js ×4→1, axios.js ×3→1); the remaining “2” are legitimately-distinct files. Merge ratios climbed each run: 244→127 (merged 117).tests/test_dedup.py. Page-gen concurrency lowered to a gentler default 3 (ENHANCIAR_PAGEGEN_CONCURRENCY) so the parallel write burst doesn't trip free-tier BYOK keys into rate/quota blocks.failed with the message), and preserve the worker checkpoints so re-running the same ingest resumes instead of redoing the expensive extraction.max_output_tokens had been lowered 32K→8K as a Groq free-tier workaround. But Gemini 2.5 Flash is a thinking model — reasoning tokens draw from that same budget, so on real repos the scout's domain-plan JSON and large worker extractions were cut off mid-string (Unterminated string), collapsing ingests into a single full-repo fallback domain / 0-page domains. Restored to 32K via ENHANCIAR_MAX_OUTPUT_TOKENS (env-overridable). Diagnosed from real job-event scout outputs: 916c/1327c truncated vs 2.3–3.5K clean.json.loads(strict=False) to tolerate literal newlines/tabs in string values, so a stray control char no longer drops a whole domain.website in the jobs panel (were mislabelled github_code — the ingest endpoint never set source_type for web: sources).https://ollama.com/v1, so you only paste a free key from ollama.com/settings/keys (no credit card). Chosen because they are strong tool-callers — unlike Groq's Llama, which frequently errored with "Failed to call a function" during the agentic ingestion loop.ENHANCIAR_LLM_CONCURRENCY env caps parallel workers independently of the token/min throttle — set to 1 for Ollama Cloud (free = 1 concurrent model) without invoking the prompt-trimming machinery those big-context models don't need. The earlier ENHANCIAR_LLM_TPM sliding-window token limiter (for Groq's 12k tokens/min) remains available and off by default so production Gemini is untouched.https://api.groq.com/openai/v1) when a Groq model is chosen, so you only paste the free key. Timely because Google cut the Gemini free tier 50–92% in Dec 2025.sources_all per node, and the Galaxy Map gained source-filter chips (Github / Web / Slack…). Selecting one dims everything except nodes that source contributed to — and a brain-merged entity (e.g. Payment Box Component from both github and the website) stays lit under both chips. The node detail panel lists all a node's sources with a "· merged" marker.write_wiki_page merges them into a single memory: unified frontmatter (all sources + file_paths, an enh_sources list) and per-source attributed sections. Re-syncing one source replaces only its own slice (idempotent), so no duplication. A same-source update still overwrites in place; merge failures fall back safely and never break an ingest.edges (import relationships) and file_tree (functions/classes, language): a plain-English one-liner, Defines / Language / Imports / Used-by cards, a "What's inside" symbol list, and clickable Imports / Used-by file links. Tree emoji swapped for Phosphor file/caret icons.App.js) used to fetch a wiki page that doesn't exist — the encoded slash in ids like file::functions/index.js hit the SPA fallback (HTML, not JSON) and showed a red error. Now file nodes skip the fetch and show their real graph metadata: connections, module, repo, criticality and file paths, with an "Open in Files" action.prefers-reduced-motion and pauses when the tab is hidden.Galaxy | Constellation | Force with Galaxy the default.workforce, default ON): your agents as hubs on the shared night-sky constellation, their runs as chain stars (done runs ring in the agent's accent). Empty state = papercraft robots + a "Seed your first two agents" CTA that creates Docs Steward (reviews recent wiki pages, flags stale + duplicate entities — e.g. 'App Component' vs 'app-main-component' — and drafts merge suggestions) and Weekly Brief (what changed in the brain this week, as a shareable brief).PUT /api/workforce/agents/:id), append-only run ledger with rendered markdown reports and honest outcome_notes (never fake metric deltas), and a "Run now" button — one bounded BYOK model step per run, no cron.[[category/name]] citations. Stage outputs stored as subcollection docs; a rough token estimate is shown before you convene. New /api/workforce/* endpoints (agents, seed, run, ledger, meetings, council), all flag-gated.#0c0d10) with a drifting particle core ("the brain"), your graph communities as color-accented hubs on a radial ring, and their top members fanning outward as constellation chains ("+N more" expander per cluster). Click a hub to zoom into its cluster (giant ghosted watermark behind), ‹ › carousel cycles clusters, double-click resets. Deterministic layout (no live force sim) + d3-zoom on a single <g> for 60fps panning. Empty brains get a designed telescope empty-state with an "Ingest a source" CTA./app/graph, /app/wiki etc. — deep-linkable, refresh keeps your place. IBM Plex Sans replaces Inter.frontend/public/assets/. OG share image now uses the papercraft hero._spa_redirect() helper (ENHANCIAR_SPA_BASE, default /enhanciar) instead of a bare /app. Combined with new last-tab persistence, connecting a source lands you back on the Connectors tab, not Chat./enhanciar/… to bare /app//login, breaking refresh and deep links in dev. New appPath()/stripBase() helpers in api.js; all 7 replaceState sites fixed./enhanciar/login; every "14-day trial" claim replaced with honest BYOK copy (there is no trial).sessionStorage (real per-tab URLs land with the upcoming IA restructure).aliveRef pattern silently dropped every fetch result after the dev double-mount; fixed in SkillsPanel, AgentChatV2 and UploadPanel./devhive/api/* (Impact, billing) so those panels work on localhost; connector setup copy shows branded /enhanciar/api/… URLs (both prefixes served).enhanciar/web_scraper.py as a Python port of the open-source Firecrawl pipeline (we cloned and analyzed the actual source). Same public interface (scrape_to_directory), completely new internals; the Scrapling dependency is gone.#main force-keep escape hatch, srcset/lazy-load image resolution, and its post-processing (skip-to-content link removal, base64 image stripping). Because the ingest chunker splits on markdown headings, scraped pages now chunk semantically instead of by 4000-char windows.Sitemap: entries and /sitemap.xml (index recursion, gzip, lenient XML parsing for real-world broken sitemaps), then BFS-extends with on-page links using Firecrawl's filter order (protocol → depth → extension blocklist → same-origin with www-stripping → fragment rules → robots.txt). Cross-host entry redirects (apex → www) rebase the crawl like Firecrawl's originUrl rewrite.waitUntil: load, ad-domain blocking) before accepting nav-only fallback text. Verified live: trekngo.com's client-rendered /treks grid now yields all trek cards; the old scraper got nav + footer only.git ls-remote (one cheap network call, no clone) and compares the remote commit SHA to the last-ingested SHA (stored in the incremental state). If they match, it skips the clone entirely — "No new commits, brain already current." Big saving for webhook re-ingests / repeated syncs that previously re-cloned just to discover nothing moved. New remote_head_sha() in repo_tools.py; save_state now records head_sha.graph.json + one LLM call — it never needed the source. New POST /api/wiki/overview/regenerate rebuilds it from the existing brain (no clone, no re-ingest), using the user's BYOK key. generate_overview() now accepts an explicit key for serving-time calls.product-overview page from the extracted entities — leading with the product type and domain (e.g. "Trek n Go is a trek/tour-booking website that …") instead of only code structure. New generate_overview() in wiki_tools.py, run at the end of ingest (and cheaply on incremental re-ingests). The chat agent is told to read it first for "what is this / what does it do / overview" questions, so the brain answers the obvious question well.--no-cpu-throttling (applied live and added to deploy.yml so it sticks). Ingests now complete and generate their entity/concept/flow pages, so chat actually has content to answer from.users/{uid}/conversations/{id}) via new GET /api/chats, GET /api/chats/{id}, POST /api/chats/save, DELETE /api/chats/{id}. The Chat sidebar now lists your past conversations (click to reopen), "+ New chat" starts a fresh thread, each turn auto-saves, titles come from the first question, and the last-open thread is restored on refresh (via localStorage + Firestore). Threads are trimmed to the last 100 turns to stay under Firestore's 1 MB doc cap.[[impact:<target>]] marker that the chat replaces with a live visual blast-radius graph (new ChatImpactGraph.jsx → /api/impact → BlastRadiusGraph) plus a risk/counts header — not just a text list.live_search(source, query) agent tool that hits the live Jira / Linear / Slack API for the CURRENT state (the wiki is a snapshot). The agent calls it only for freshness-sensitive asks ("current status of PROJ-12", "latest on…"), reusing each connector's stored credentials, and labels results "🔴 Live from …". Jira = JQL/key lookup, Linear = GraphQL issue search, Slack = message search (needs a user token with search:read; otherwise falls back to the snapshot). New enhanciar/tools/live_connectors.py, wired into the query agent.GET /api/impact/targets + impact.list_targets), instead of free text you had to remember. A "⌨ type a target manually" toggle keeps free entry for power users.blast_radius / callers_of / callees_of tools — so you can ask in plain language and get callers/dependencies (as text). Visual graph-in-chat is a possible follow-up.{"type":"progress","data":{"total":117,…}}) because they carry no message. The feed now renders them as plain sentences — e.g. "Parsing files — 64/117 (55%)", "Parsed all 117 files" — and never shows raw JSON for any event.{…} object when the model wraps its JSON in prose, so multi-domain plans are used more reliably and the warning is rarer.github_installations mapping, but the App stays installed on GitHub. A recreated account then showed "0 repos / Connect" and clicking install hit GitHub's "already installed" wall with no way back.POST /api/github/installations/resync lists the App's installations (App-JWT) and re-links any orphaned one (no owner) to the current user — never one already owned by another active account, so it can't hijack a connection. New github_app.list_app_installations + a "↻ re-sync" button on the GitHub connector (shown when you have 0 installations).--depth 1 checkout as one all-or-nothing network op under a hard 120 s limit, so the ingest failed before parsing a single file.clone_repo now does a blobless partial clone (--filter=blob:none --no-checkout — pulls only refs+trees, fast even for multi-GB repos) and then materialises files via a non-cone sparse-checkout that excludes ~60 media/binary extensions (mp4/png/zip/woff/onnx/…). Git fetches blobs only for files we actually parse, so a media-heavy repo downloads just its few MB of source.ENHANCIAR_CHUNK_SIZE (default 300), each chunk its own git checkout under ENHANCIAR_CHUNK_TIMEOUT (default 180 s) with retries. No single network step can stall the whole ingest; a slow chunk retries (or is skipped) while earlier chunks stay on disk — resumable, not all-or-nothing. The metadata clone has its own ENHANCIAR_CLONE_TIMEOUT (default 600 s). Safe fallback to a plain shallow clone if chunked checkout can't run.source — github, github issues, slack, notion, jira, linear, zendesk, google drive, web, … (from connector frontmatter; code/file nodes default to github). New _norm_source normaliser in wiki_tools.py.KnowledgeGraph.jsx) — appears when a brain has >1 source. Picking a source shows those nodes plus anything directly linked to them, so you can isolate "everything from Slack" or "everything from GitHub" and still see what it connects to.type: references, cross_source: true), not just incidental shared-keyword overlap. So a discussion connects to the code it's about. (Only fires when a brain actually has non-code sources alongside a repo.).md wiki pages to GCS/Firestore — it never persisted the code-graph artifacts Impact reads (_code_graph/*.json caller/callee edges, _index/cross_index.json, graph.json). On Cloud Run, the pod that runs ingest is almost never the pod that serves a later read, so on any fresh pod those artifacts were gone. Result: every file "resolved" to just itself with 0 callers / 0 callees, and bare filenames like app.js came back "Not found" — Impact looked broken until the repo was re-ingested on that exact pod.backup_user_artifacts / restore_user_artifacts in wiki_durability.py mirror those artifacts to gs://<bucket>/<uid>/_artifacts/… and rehydrate them. Wired into the post-ingest backup (jobs.py), the lazy cold-pod rehydrate (_ensure_fresh), and the manual /api/wiki/backup·/restore endpoints. The Impact route now calls _ensure_fresh before computing, so a cold pod restores the graph first. Best-effort + additive: no-ops cleanly when no GCS bucket is set.owner/name box is now a themed, searchable dropdown of your connected GitHub repos (new StyledSelect.jsx — a light-theme popover with a filter, replacing the unstyled native <datalist>), and once a connected repo is chosen the PR # becomes a dropdown of that repo's open PRs (#n — title · @author) instead of a number you have to look up. Selection is click-only by default; subtle "⌨ review an external repo" / "type a PR number" toggles preserve free-text entry for repos outside your installations. New GET /api/github/pulls?repo=owner/name (resolves the owning installation, mints a token, lists open PRs via github_app.list_open_prs) + ghInstallations/ghOpenPRs in api.js.frontend/src/components/BlastRadiusGraph.jsx, honors the light theme via CSS vars.prefers-reduced-motion. Limited to the /login panel only.aria-label/aria-pressed).CodeReviewPreviews.jsx — four self-contained, on-theme animated preview mockups (pure SVG + CSS microanimations, no runtime dependency): an indexing knowledge-graph (hero), code files parsed into the graph (Step 01), a key wiring to Claude/GPT/Gemini provider pills (Step 02), and a live PR-review card revealing a ⚠ finding then a ✓ suggestion (Step 03). All three step cards now carry a visual; the static cr-hero.png/cr-review.png are gone.prefers-reduced-motion (jumps to final state, no motion). Frontend build green.— placeholder stats (no fabricated live numbers); recent-ingests uses the real data source + existing empty state. Built with the app's CSS theme vars (no hardcoded hex); all ingest behavior/handlers unchanged. Build green.GET /api/connectors returned credentials_configured: null for GitHub, Slack and Google (their connection state lives in installations/OAuth, not the user doc) — so the Connectors grid + Ingest "Connected sources" showed "Connect" even after the user had installed the GitHub App (e.g. 44 repos) or connected Slack/Google. Now it resolves real state: GitHub via installations_for_uid, Slack via its installs, Google via accounts_for_uid.SetupGuide block on every connector's detail page — numbered steps + a prominent external link (new tab). GitHub's "not configured" state now shows a 6-step "How to install the GitHub App" guide with a Register a GitHub App → button (github.com/settings/apps/new). Links: Slack (api.slack.com/apps), Notion (notion.so/my-integrations), Linear (linear.app/settings/api), Jira & Zendesk (Atlassian/Zendesk API-token pages), Google (console.cloud.google.com OAuth client). Database/WhatsApp/Files get inline guidance./api/jobs)./api/connectors and lists every connected connector (Slack, Notion, Jira, Linear, …) with its real brand icon; clicking one jumps to the Connectors page to ingest it. No more Ingest-vs-Connectors mismatch.Ingest, Feed and Monitor primary tabs are now a single ⚡ Ingest tab with a segmented control — Add source · Feed files · Activity — since they're really one journey: add a source / drop files → watch it process. New IngestHub.jsx wrapper; all three children stay mounted (display-toggled) so the Monitor's live stream and job-poll never tear down on sub-view switches.switchTab('monitor'|'feed') caller (Connectors "Ingest now", onboarding, demo ingest) was redirected to the right sub-view. Frontend build green.💥 Impact tab + GET /api/impact?target=…&depth=… + an impact MCP tool. Type a file, symbol or wiki entity → direct callers/callees, every affected file and test, transitive reach, and a low/med/high risk read, computed from the code graph (engine in enhanciar/impact.py, never raises). Verified on a real brain: signer.py → medium risk, 4 callers, 12 callees, 7 affected files, 19 transitive.source: slack + acl: frontmatter so Slack answers carry citations and query-time ACLs like every other source. Runnable for dev via SLACK_BOT_TOKEN (no OAuth dance), with a clear actionable error when not connected. Bounded rate-limit backoff (honors Retry-After) + volume caps (30 days, ≤5 history pages/channel, ≤200 threads/channel), all logged + env-overridable. Scopes: channels:read/history, groups:read/history, users:read, team:read.tests/test_impact.py + tests/test_slack_connector.py added; full backend suite green at 156 passing. Frontend build green.pallets/itsdangerous) on the Ingest page reported "Pipeline complete 🎉" but built 0 nodes. Root cause: the Scout LLM had to decide to clone, and a bare slug (no http scheme) was treated as "analyze a local repo" — Scout never cloned, workers found nothing, and the run reported a silent empty success. Only a full https://github.com/… URL happened to work.run_pipeline now promotes any owner/repo slug to https://github.com/owner/repo before Scout runs, so both inputs take the identical path.clone_repo); Scout is handed a guaranteed local checkout. The plan is re-anchored to that clone so a hallucinated path can't break the run.status:"error" with a clear message instead of "completing" with an empty brain. clone_repo error classification fixed so a missing repo no longer mis-reports as "Branch '' not found" — it now says "Repository not found or not accessible… private repos aren't supported yet."pallets/itsdangerous now builds 44 nodes / 221 edges (167 soft + 54 hard) / 8 entities + 7 concepts + 6 flows, and Chat returns a grounded, cited answer ([[entities/signer-module]]). Nonexistent slug/URL now return a clean error. Backend suite green (137 passing; also un-stuck a stale connector-registry test).billing.PLANS catalog — it ships inside the existing Solo/Team plans, so there's no separate plan and no Razorpay plan IDs needed. Pricing is back to Solo / Team / Enterprise.#code-review section; pricing lives only in the Pricing section now.cr-hero/cr-review/cr-byok images for light, on-theme versions (violet/blue on white); dropped the unused cr-cost accent.```suggestion block the author applies in one click. Findings are now tagged by category (bug/security/performance/style/lint).#code-review section. GitHub/GitLab/Bitbucket claim retained (GitLab/Bitbucket on the roadmap).tests/test_code_review.py now 29 passing (added secret-scan, suggestion-mapping and summary-body cases); verified end-to-end against a real GitHub PR via tests/manual_pr_review_live.py./enhanciar/ rendered SignupScreen when signed-out, the app when signed-in); landing was at /./ = marketing landing, /login = sign-in, /app = the app. Backend enhanciar_alias middleware maps /login + /app (and sub-paths) to the SPA index; /enhanciar/ still works for back-compat (OAuth redirects, old bookmarks). Rendering stays auth-driven, with the URL normalized: signed-out on an app route → /login; signed-in on /login or /enhanciar/ → /app. Post-OAuth redirects + landing CTAs now target /login//app. Verified end-to-end against the backend (login page renders at /login; /app while signed-out normalizes to /login; landing reachable at /).onAuthStateChanged resolved (Firebase persists auth async in IndexedDB) and redirected them to /enhanciar/.dh_authed localStorage flag on sign-in/out (src/App.jsx + src/landing/App.jsx); a tiny inline guard in landing.html <head> reads it and location.replace('/enhanciar/') before any render. Logged-out / first-time visitors have no flag and get the landing instantly. Verified: flag set → app loads with the landing never painting; no flag → landing shows.CodeReviewSection (id #code-review) between Features and Steps — headline "Senior-level code review. Zero inference bill.", a how-it-works flow, the BYOK-economics "money shot" (flat per-seat vs per-seat + $5/1k-line overage), and a comparison strip vs per-seat tools. The Features bento "Code Review" card now anchors to it.🔍 Code Reviews to the primary nav; added a dismissible dashboard callout ("every PR reviewed by your own LLM key. $0 inference billed") that deep-links via the switchTab event; and an onboarding "first win" choice that can land the user straight on the Reviews tab.nano_banana) in the brand palette (violet #1C525D / blue #2563eb on near-black) — /cr-hero.png, /cr-review.png, /cr-byok.png, /cr-cost.png.CONNECTING_GITHUB.md documents the new Pull requests: Read & Write permission + pull_request webhook + re-consent; added tests/test_code_review.py (20 passing) covering diff-line mapping, review-JSON parsing, route registration, billing, and job dispatch.frontend/src/index.css — accent #1C525D→#2563eb, Inter/JetBrains Mono) so no screen recording is needed and every label/citation stays crisp. Music bed + transition/▸click SFX, mastered to −14 LUFS. Output: video/launch_v2/ENHANCIAR_launch_UI_v1.mp4. Build guide + reference-video teardown in video/launch_v2/.owner/name + PR number; Enhanciar reviews the diff with your own LLM key ($0 inference billed) and posts the review on GitHub. New frontend/src/components/CodeReviewPanel.jsx, registered as tab id reviews in App.jsx.path:line, severity and the comment body.crSubmitPR, crGetReviews and crGetReview to frontend/src/api.js, wired to POST /api/code-reviews/submit-pr, GET /api/code-reviews and GET /api/code-reviews/{review_id}.enhanciar/code_review.py exposes run_pr_review(workspace_id, full_name, pr_number, installation_id, head_sha=None, emit=None): mints an installation token, fetches the PR's changed files, builds a grounded prompt (repo/wiki context + workspace review_guidelines.md + unified diffs, capped ~20k chars), calls the BYOK LLM via _call_llm, and posts a GitHub review._valid_diff_lines) computes the valid added/changed new-file line numbers per file, so inline comments only anchor to changed lines (avoids GitHub 422s); trivial style nits are severity-gated out. Robust JSON parsing (_parse_review_json) strips code fences and falls back to a plain-text summary review when the model returns non-JSON. Verdict maps to APPROVE / REQUEST_CHANGES / COMMENT, auto-retrying as COMMENT on self-PRs.kind="code_review" with estimated tokens. Added get_pr_files (paginated) and post_pr_review helpers to enhanciar/github_app.py. The whole engine is best-effort and never raises — it returns a structured result dict instead.drawtext/freetype, captions are rendered as transparent PNGs via PIL and overlaid with fade in/out: c3 wordmark, c4 "cited — Slack · Code · Ticket", c6 "cited", c8 hero wordmark + tagline. Assembled via concat → loudnorm I=-16:TP=-1.5:LRA=11 → libx264 crf20. Output: video/office_film/film2v3/out/ENHANCIAR_office_2min_v8_web.mp4 (120.8s, 1280×720, h264+aac).loudnorm I=-16:TP=-1.5:LRA=11 → libx264 crf23. Output: video/office_film/film2v3/out/ENHANCIAR_office_2min_v4_web.mp4 (120.8s, 1280×720).demo, github_code, skills), but the Monitor only ever streamed the one it auto-attached to (demo://*) — so the GitHub/repo and skills jobs ran invisibly. Added a "Jobs (N)" card to the Monitor side panel that polls /api/jobs and lists every recent job with a live status badge (running/done/failed). Click any job to switch the live stream to it — so you can now watch the GitHub repo ingest, not just the demo connectors. The auto-attach default (newest active job) is unchanged.source: github repo-summary docs (overview, index.js, url-alphabet/index.js, non-secure/index.js, package.json) to the demo dataset, so the repo shows under a GitHub · 5 chip and is chat-searchable. Re-ingest to populate.read_codebase_file was never called and the model said "the wiki doesn't have the code". Fix: the BYOK path now injects real source into the context — for "show me <file>" it pulls the verbatim file, for "where is X used" it runs a source grep, and for conceptual repo questions ("what's the default ID length") it grounds the answer in the README + entry file. Verified live: the chat returns the actual index.js.read_codebase_file and a new grep_source resolve from the local clone, then a new gcs_storage module's raw/<repo>/ mirror. The clone lives on ephemeral Cloud Run disk (wiped on redeploy, and not shared across the up-to-4 instances), so ingest now mirrors the source to GCS and reads fall back to it. Verified end-to-end against the real bucket (upload → read → grep). Activate in prod with ENHANCIAR_USE_GCS=1.grep_source for identifier questions, LINK-INTEGRITY + WRITE-PATTERN-HONESTY anti-hallucination rules) plus an explicit "when asked to SHOW code you MUST call read_codebase_file, never say the wiki lacks it" rule. (Applies to the ADK path.)ai/nanoid, and every connector doc (Slack/Notion/Jira/Linear/Zendesk/Gmail/Drive/Files/Database/WhatsApp) is rethemed around nanoid (default size 21, the URL-safe alphabet, customAlphabet, the non-secure variant, the ESM-only v5 release). Tags link the connector docs to the real nanoid source files (graph: 0 → ~90 edges, 0 isolated). No slugify references remain.PaymentFlow called .toLocaleString() directly on plan fields (price_inr_monthly, daily_token_cap, …) that can be null in the catalog, throwing Cannot read properties of null inside the plans .map() and tripping the error boundary ("Billing couldn't render"). All number formatting is now null-safe ((v ?? 0).toLocaleString()). Verified locally: the tab renders./api/code-graph returns nodes keyed file::symbol with no path/id, but the click resolver looked them up by path/id, so it never matched and the panel stayed empty. It now matches by name/file (what the tree is actually built from), and the detail shows kind/file/line/repo when no wiki summary exists.unlimited_quota, default ON). When on, _caps_for returns an unlimited sentinel so the daily/monthly caps stop enforcing (no more 402/429 cap errors) and the header meter shows ∞ instead of used / 0. Toggle off in the admin Feature Flags page to re-enforce plan caps. Verified locally: meter reads 0 / ∞.[[wiki links]], so the graph was 30 nodes with ~1 edge (a cloud of disconnected dots). Added shared-token soft edges: pages that share a distinctive tag/keyword/entity/title-word get linked, generic tokens (present in >60% of pages) are skipped to avoid a hairball, and per-node degree is capped. When a repo file shares a token it becomes the hub (star), so connector docs cluster around the source files. Demo dataset went from 1 → ~90 edges, 0 isolated nodes.counter, index, test; a customReplacements ticket → overridable, replacements). The connector chatter, tickets and docs now visibly cluster around index.js, overridable-replacements.js, package.json, test.js.source: github wiki pages summarizing the real repo files (overview, index.js, overridable-replacements.js, package.json). The repo previously ingested only into the code tree, so "what repo data do we have?" found nothing in the wiki the chat searches. Now it shows under a GitHub filter chip and answers repo questions.generate_graph_manifest builds the shared-token edges; _to_wiki_page carries demo tags into frontmatter; new github source in the demo dataset. Re-ingest to regenerate the graph with edges.icon Title · Source / category.owner: … title: … type: … block that was rendering as plain text at the top of every page).source: <connector> into each page's frontmatter, and GET /api/wiki/pages returns title + source per page (GitHub/code pages resolve to github via their sources: [repo:…]). Re-ingest to populate the source on existing pages.pipelineStatus in its dependency array, and attachToJob itself calls setPipelineStatus('running') — so the moment it attached, the effect tore itself down and re-ran into an early return, killing the poll loop after a single tick. Replaced the pipelineStatus guard with a dedicated manualIngestActiveRef, dropped pipelineStatus from the deps, and clear the watched-job ref on cleanup so re-entering Monitor mid-job re-attaches. The counter now climbs live to N/N and flips to COMPLETE.config, and write_wiki_page raised on any category outside the allowed set, which failed the entire GitHub ingest job (Invalid wiki category 'config'). It now coerces unknown/near-miss categories to a valid bucket (synonym map, e.g. config → infrastructure; fallback docs) and logs a warning, so one stray category can't take down a whole run..dh-body .tab-pane { overflow:auto }). Only tall tabs (e.g. Settings) render a vertical scrollbar, which steals the scrollbar's width (~6–15px) from the content area; every other tab — notably Graph's fixed canvas — keeps the full width. Switching between a scrolling and a non-scrolling tab therefore nudged the content sideways. Added scrollbar-gutter: stable so the gutter is reserved on every pane and content width stays constant. Verified live on prod: content-width spread across tabs went from 6px → 0px.jobs_for_user ordered by created_at alongside a uid filter, which needs a composite Firestore index that wasn't provisioned, so /api/jobs failed and the Monitor tab silently discovered zero jobs (affected all ingests, not just demo). Now queries by uid and sorts in Python — no index needed. A second bug also hid jobs: the Monitor discovery loop read active.job_id but list records carry id, so it never attached — now accepts either. Plus "Ingest demo data" jumps you to Monitor to watch it stream.source:uid:slug, so the derived wiki filename/breadcrumb leaked the gibberish workspace uid (nxZOoZl5…-slug). Dropped the uid (the folder already namespaces by uid) → filenames are now just the readable slug (e.g. slug-171-make-slugify-esm-only-for-v3).DemoConnector: a real background job (visible in Monitor), LLM-enriched when a model key is present and gracefully render-only when not (so the demo always ingests — no hard missing_llm_key failure). GitHub still does a real repo ingest.demo_data (audience: admin, default off) — added to the feature-flag catalog so it auto-renders as a toggle on the admin Feature Flags page. Read via feature_flags.is_enabled("demo_data", uid).GET /api/connectors now demo-aware — when demo is on for the caller, every connector reports credentials_configured: true and the response carries demo: true; otherwise unchanged.POST /api/demo/ingest (auth + verified-email gated) — returns 400 "demo mode is off" when the flag is off; when on it (a) enqueues a real GitHub code ingest of your first connected repo, or public sindresorhus/slugify as a fallback, and (b) writes a slugify-themed sample brain (Slack, Notion, Jira, Linear, Zendesk, Gmail, WhatsApp) through the render-only wiki path — no BYOK model key required. Returns {ok, demo, github_job_id, pages_written, sources}.whatsapp_app.demo_mode(uid) now also returns true when demo_data is on for the user (OR'd with the existing env/auto behavior), so the admin toggle lights up WhatsApp sample data without env changes.demo.ingest audit event with the repo, job id, page count and sources.www.google.com, used by Firebase App Check) and Tag Manager (www.googletagmanager.com) to script-src/frame-src/connect-src, silencing the report-only console warnings and making it safe to flip CSP to enforcing later.onDark variant) so it reads clearly.WHATSAPP_SIDECAR_URL is configured the connector serves sample groups + transcripts (badged "SAMPLE DATA") that flow through the real ingestion pipeline, so the feature is demonstrable with no sidecar and no phone. Set WHATSAPP_DEMO_MODE=off or wire a real sidecar to disable.demo_data feature flag marks every source connected and surfaces an "✨ Ingest demo data" button that loads sample data through the real pipeline (check Monitor / Graph). GitHub + WhatsApp moved out of the "More ▾" menu into this page.whatsapp-sidecar/ (whatsapp-web.js) holds one session per user and is never internet-exposed; the Python backend proxies it server-side with per-user auth. Set WHATSAPP_SIDECAR_URL to enable; when unset the panel shows a clear "not configured" state.ENHANCIAR_ENRICH_INGEST=0 stays as the explicit render-only escape hatch, and a single flaky LLM call still degrades just that document to render-only.ENHANCIAR_ENRICH_INGEST=0.SKILL.md (frontmatter + cited steps) in the site's code-block styling./enhanciar/features.html.GET /api/skills) showing the when-to-use description, a subtle confidence meter, last-compiled date and source-document count; click a card to read the full SKILL.md playbook rendered as markdown, with a one-click "Copy SKILL.md" button.POST /api/skills/compile, shows a live compiling state with a "Watch in Monitor" shortcut, polls the job and refreshes the library when done; disabled while a compile is in flight.GET /api/skills/process-map: each process with the systems it uses, the docs it was derived from and the teams involved, with kind-coded chips per edge.GET /api/skills; intent patterns are anchored and conservative so normal questions (e.g. "how does the skills compiler work") still flow to the LLM.POST /api/workspaces).POST …/members; a 404 surfaces as "That email has no Enhanciar account yet — ask them to sign up first") and one-click remove (DELETE …/members/{uid}), with inline per-workspace error surfacing.localStorage["dh_active_workspace"]; the new src/workspace.js helper (getActiveWorkspace / setActiveWorkspace / headerFor) injects X-Workspace-Id app-wide.GET /api/connectors per source.••••tail badges, only changed fields are sent on Save (PUT /api/account/integrations), and a Clear button wipes a stored secret.POST /api/ingest/{source} with a toast action that jumps to the Monitor tab.POST /api/files/upload; images are rejected client-side with a friendly message.job_id.POST /api/files/note and link capture via POST /api/files/url.POST /api/skills/compile.ENHANCIAR_AUTO_SKILLS=0 to disable; best-effort and never fails the ingest job.POST /api/workspaces creates a shared brain (owner = caller, plan inherited), GET /api/workspaces lists yours, and owner-only POST/DELETE …/members invite by email or uid / remove members.X-Workspace-Id on chat and wiki reads to query a team brain; membership is verified server-side, non-members silently fall back to their personal workspace.enhanciar/acl.py checks document ACLs (uid: / domain: / public:*) on every wiki list, search, page read, MCP tool and chat retrieval. Documents without an ACL remain workspace-visible (fully backward compatible).acl: [uid:…, domain:…]) so future ingests carry permissions end-to-end.generic_pipeline.py) that every new connector reuses; adding a source no longer means a bespoke pipeline.SKILL.md procedure files AI agents can run.