Query Console: promotion + Firestore querying - Manual Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | feat/query-console-958, PR #961 |
| Issue | #958: feat(admin/analytics): promote Query Console to its own surface and teach it to query Firestore |
| Environment | Local stack on NON-DEFAULT ports (branch-only endpoint): admin :3011 -> analytics-api :8092, both against live lantern-app-dev Firebase |
| Build flags / config | none. No feature flag gates this work. |
| Build (commit) | Run 1: c4825c8e plus the stale-results fix that came out of it. Run 2: 219c284b |
| Tester | Claude (headless Playwright chromium, plus two Node probes for the API-level scenarios) |
| Date | Run 1: 2026-08-24. Run 2: 2026-08-27 |
Which run is this? โ
Two runs, both recorded here. Run 1 is the original build (2026-08-24, scenarios 1 to 16). Run 2 is the bug-fix, design-verification and grounding work (2026-08-27, scenarios 17 to 38). Each scenario carries its own filled result block; a blank block would read as a pass to anyone skimming, so anything not exercised says so in its own words.
| Run | Date | Build | What it covered |
|---|---|---|---|
| 1 | 2026-08-24 | c4825c8e | The nav move, the redirect, the Firestore endpoint and its allowlist refusals |
| 2 | 2026-08-27 | 219c284b | #970 and #971, four more bugs, the discoverability build, the allowlist corrections, and slice 5 grounding the Assistant |
Summary โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | Query Console is its own Analytics nav item | [x] pass [ ] fail [ ] blocked | |
| 2 | BigQuery tabs no longer carry Query Console | [x] pass [ ] fail [ ] blocked | |
| 3 | Old /bigquery/console link redirects, does not 404 | [x] pass [ ] fail [ ] blocked | |
| 4 | Regression: the three remaining BigQuery tabs still load | [x] pass [ ] fail [ ] blocked | |
| 5 | Activation: BigQuery is the default source with NO manual selection | [x] pass [ ] fail [ ] blocked | |
| 6 | Regression: BigQuery SQL still runs and renders through ConsoleResults | [x] pass [ ] fail [ ] blocked | |
| 7 | Firestore happy path: venues, no filters, renders in ConsoleResults | [x] pass [ ] fail [ ] blocked | |
| 8 | Firestore ordering on a newly approved field (offers.createdAt) | [x] pass [ ] fail [ ] blocked | |
| 9 | Guardrail: an unlisted collection is refused outright | [x] pass [ ] fail [ ] blocked | |
| 10 | Guardrail: an unlisted field is refused outright | [x] pass [ ] fail [ ] blocked | |
| 11 | FALSIFYING: unindexed compound query surfaces a clickable create-index link | [x] pass [ ] fail [ ] blocked | field pair substituted, see below |
| 12 | CONTROL: a single-field query on the same collection succeeds with no index prompt | [x] pass [ ] fail [ ] blocked | |
| 13 | Pickers are StyledSelect, no native <select> in the DOM | [x] pass [ ] fail [ ] blocked | |
| 14 | Limit ceiling is enforced server side | [x] pass [ ] fail [ ] blocked | |
| 15 | Payload verification: only allowlisted fields leave the server | [x] pass [ ] fail [ ] blocked | |
| 16 | Regression: a non-admin cannot reach the endpoint | [ ] pass [ ] fail [x] blocked | half exercised, see below |
14 of 16 fully exercised and passing. One (11) passed on a substituted field pair. One (16) is half exercised: the unauthenticated arm passed, the non-admin arm could not be driven from here.
Setup / preconditions โ
- Start analytics-api on a non-default port so it does not collide with a parallel session:
GOOGLE_APPLICATION_CREDENTIALS=<real ADC path> APP_CHECK_DISABLED=true PORT=8092 node --env-file=.env.local services/api/analytics/src/index.js - Start the admin portal on a non-default port pointed at it. The client reads
VITE_ANALYTICS_API_URL=/apiand vite proxies/api/analyticstoANALYTICS_API_ORIGIN, so overridingVITE_ANALYTICS_API_URLdoes nothing. Override the ORIGIN:ANALYTICS_API_ORIGIN=http://localhost:8092 AUTH_API_ORIGIN=https://auth-api-531553779372.us-central1.run.app npx vite --port 3011 --strictPort - A fresh worktree needs
npm installANDnode tooling/scripts/generate-client-sdk-docs.mjs. Without the second,apps/admin/src/generated/client-sdk.jsonis missing andAdminShell.jsxfails to load with a misleading "Failed to fetch dynamically imported module". - Sign in with the Agent Probe admin (credentials in the 0600 env file, never inline them).
- Data lives in live
lantern-app-devFirestore. Everything under test is READ ONLY: there is no write path in this change, so no scenario here mutated a document. - The four newly approved allowlist fields are
offers.createdAt,offers.updatedAt,venues.createdAt,venues.updatedAt. No other field was added. - Clean state: clear
localStoragefor the admin origin before scenario 5, since console layout preferences persist there.
Which scenario can tell us we are wrong? โ
Scenario 11 versus scenario 12. They make opposite predictions on the same collection.
- 11 predicts a compound filter on an unindexed field pair FAILS, and that the failure carries a
create-indexURL we render as a link. - 12 predicts a single-field query on that same collection SUCCEEDS with no index prompt at all.
If 12 also demands an index, the diagnosis is wrong: the problem is not "compound queries need a composite index", it is something broader about how the endpoint builds queries, and the index-link rendering is treating a general failure as a specific one. If 11 succeeds instead, the index-link path has never been exercised and scenario 11's pass means nothing.
What actually happened: 11's originally-chosen pair (offers.status + offers.createdAt) came back 200 with 6 rows, because that pair is already indexed in dev. Per the plan's own instruction, another pair was found and named: venues.city + venues.createdAt, which is not indexed. That pair failed exactly as predicted, with the URL. 12 succeeded as predicted. The pair holds.
Scenarios โ
1. Query Console is its own Analytics nav item โ
- Goal: the console is reachable as a sibling of Dashboards, Event Tracking and BigQuery, not buried in a tab.
- Steps:
- Load
http://localhost:3011/admin. - Expand Analytics in the left nav.
- Read the sub-items.
- Click Query Console.
- Load
- Expected: Analytics lists Dashboards, Event Tracking, BigQuery, Query Console. Clicking Query Console navigates to
/admin/analytics/query-consoleand the console renders.
Result: [x] pass [ ] fail [ ] blocked
Actual: Sub-items read "Dashboards | Event Tracking | BigQuery | Query Console".
Clicking Query Console landed on /admin/analytics/query-console with the
toolbar, rail, editor and results all rendered, and the nav row active.
Evidence: shots/01-query-console.png; probe checks 1 and 1.1
Follow-up: none2. BigQuery tabs no longer carry Query Console โ
- Goal: the move is a move, not a copy.
- Steps:
- Navigate to
/admin/analytics/bigquery/export. - Read the tab strip.
- Navigate to
- Expected: exactly Reference Docs, Export Status, Scheduled Reports, Data Retention.
Result: [x] pass [ ] fail [ ] blocked
Actual: Tab strip read "Reference Docs | Export Status | Scheduled Reports |
Data Retention". No Query Console tab anywhere on the page.
Evidence: probe check 2
Follow-up: none3. Old /bigquery/console link redirects, does not 404 โ
- Goal: saved links and bookmarks keep working.
- Steps:
- Load
http://localhost:3011/admin/analytics/bigquery/consoledirectly.
- Load
- Expected: lands on
/admin/analytics/query-consolewith the console rendered.
Result: [x] pass [ ] fail [ ] blocked
Actual: Final pathname /admin/analytics/query-console, console toolbar present.
No blank page and no bounce to the admin dashboard.
Evidence: probe check 3
Follow-up: The frozen design specs under docs/planning/ still cite the old path.
They were deliberately left alone: they are historical records, and
the redirect keeps their links live.4. Regression: the three remaining BigQuery tabs still load โ
- Goal: removing one tab did not disturb its siblings.
- Steps:
- Visit
/admin/analytics/bigquery/export, then/reports, then/retention.
- Visit
- Expected: each renders its own panel with the correct page title.
Result: [x] pass [ ] fail [ ] blocked
Actual: export=true reports=true retention=true. Each rendered its own heading
("BigQuery Export Status", "BigQuery Scheduled Reports", "BigQuery Data
Retention") with the correct tab active.
Evidence: probe check 4
Follow-up: none5. Activation: BigQuery is the default source with NO manual selection โ
- Goal: prove the SHIPPED default, not that the toggle works.
- Steps:
- Clear site data for the admin origin.
- Load
/admin/analytics/query-consolefresh. - Do not click anything.
- Expected: BigQuery already selected, SQL editor visible, Firestore builder not shown.
Result: [x] pass [ ] fail [ ] blocked
Actual: On first paint, BigQuery tab aria-selected="true", Firestore
aria-selected="false", SQL editor present. ZERO calls to
/analytics/admin/firestore-* had been made at that point: the Firestore
schema fetch is gated on the source, so a console sitting on BigQuery
issues no Firestore request at all. The unit suite asserts the same
thing ("opens on BigQuery with no manual selection, and asks Firestore
nothing").
Evidence: probe check 5; apps/admin/src/admin/analytics/__tests__/QueryConsolePage.test.jsx
Follow-up: none6. Regression: BigQuery SQL still runs and renders through ConsoleResults โ
- Goal: the existing path is untouched by the Firestore work.
- Steps:
- Run a 7-day partition-filtered
analytics.eventsaggregate. - Inspect the results grid.
- Run a 7-day partition-filtered
- Expected: rows render, row expands, Copy CSV works.
Result: [x] pass [ ] fail [ ] blocked
Actual: 10 rows returned with headers "event_name STRING" and "n INTEGER",
rendered in the same grid as before. Row filter, Copy CSV and Download
all present.
Evidence: shots/06-bigquery-results.png; probe check 6
Follow-up: none7. Firestore happy path: venues, no filters, renders in ConsoleResults โ
- Goal: the structured builder returns real documents through the SAME results component as BigQuery.
- Steps:
- Click the Firestore source tab.
- Pick collection
venues, no filters, limit 10. - Run, expand a row, click Download.
- Expected: venue rows in the same table chrome, with filter, Copy CSV, Download and click-to-expand.
Result: [x] pass [ ] fail [ ] blocked
Actual: 10 venue rows rendered. Columns: id, name, city, state, category, tier,
status, activeLanternCount, source, createdAt, updatedAt, with the type
chips the BigQuery grid uses. Copy CSV and Download both present, and
clicking a row opened the same detail list.
Response body shape confirmed as { rows, schema, ... }, and the status
strip read "10 rows from venues in <n> ms".
Evidence: shots/08-firestore-venues.png; probe checks 7 and 7.1
Follow-up: The city, state and tier columns render EMPTY for every row. See the
Findings section: those three fields do not exist on venue documents.8. Firestore ordering on a newly approved field (offers.createdAt) โ
- Goal: exercise the reason the four fields were approved: order the moderation queue oldest first.
- Steps:
- Pick collection
offers, order bycreatedAtascending, limit 25. - Run.
- Pick collection
- Expected: oldest first,
createdAtreadable rather than{_seconds, _nanoseconds}.
Result: [x] pass [ ] fail [ ] blocked
Actual: 9 offers returned in 235 ms, oldest first:
2026-04-25T16:27:59.559Z | 2026-04-30T16:24:03.374Z |
2026-04-30T17:49:44.600Z | 2026-05-04T05:43:38.706Z |
2026-05-05T03:37:24.755Z | ...
Timestamps render as readable ISO strings in both the grid and the
expanded row detail. Nothing showed as _seconds/_nanoseconds.
Evidence: shots/09-offers-ordered.png; probe check 8; the API probe returned the
same nine values in the same order.
Follow-up: none9. Guardrail: an unlisted collection is refused outright โ
- Goal: a collection absent from the allowlist fails, and fails BEFORE any read.
- Steps:
POST /analytics/admin/firestore-querywith{ "collection": "users" }, then"lanterns".
- Expected: 400
COLLECTION_NOT_ALLOWED, no document data.
Result: [x] pass [ ] fail [ ] blocked
Actual: users -> 400 COLLECTION_NOT_ALLOWED, no rows in the body.
lanterns -> 400 COLLECTION_NOT_ALLOWED, no rows in the body.
The refusal happens in validateQuery before the Firestore call is
built; the unit suite asserts the collection handle is never even
touched ("refuses an unlisted collection without touching Firestore").
Evidence: API probe, scenario 9 block; services/api/analytics/test/services/firestoreQuery.service.test.js
Follow-up: none10. Guardrail: an unlisted field is refused outright โ
- Goal: the allowlist is per FIELD, so an allowlisted collection does not make every field readable.
- Steps:
- Filter on
offers.reviewNote. - Order by
offers.reviewedBy. - Order by
merchantProfiles.updatedAt.
- Filter on
- Expected: 400
FIELD_NOT_ALLOWEDon all three.
Result: [x] pass [ ] fail [ ] blocked
Actual: offers.reviewNote filter -> 400 FIELD_NOT_ALLOWED
offers.reviewedBy orderBy -> 400 FIELD_NOT_ALLOWED
merchantProfiles.updatedAt orderBy -> 400 FIELD_NOT_ALLOWED
No rows in any body. The error carries allowedFields, and the console's
status strip renders it as "Readable here: ..." so the refusal is
actionable rather than just a no.
Evidence: API probe, scenario 10 block
Follow-up: none11. FALSIFYING: unindexed compound query surfaces a clickable create-index link โ
- Prediction, stated before the run: this FAILS, with a composite-index error, and the console renders the
create-indexURL as a link. - Steps:
- Collection
venues, filtercity == Austin, order bycreatedAtdescending. - Run.
- Collection
- Expected: an error strip naming the index requirement, with an anchor whose href is the
create_compositeURL.
Result: [x] pass [ ] fail [ ] blocked
Actual: FIRST ATTEMPT USED A DIFFERENT PAIR AND DID NOT FALSIFY. The pair the
plan originally named, offers.status == active + order by createdAt,
came back 200 with 6 rows: that composite index already exists in dev,
so the scenario could not have proved anything. Per the plan's own
instruction, a genuinely unindexed pair was found and is named here:
venues.city + venues.createdAt.
With that pair: 400 FIRESTORE_INDEX_REQUIRED, message "This filter and
ordering combination needs a composite index.", and indexUrl
https://console.firebase.google.com/v1/r/project/lantern-app-dev/firestore/indexes?create_composite=Ck5wcm9qZWN0cy9sYW50ZXJuLWFwcC1kZXYv...
In the UI this renders as a red-bordered strip carrying the sentence,
the [FIRESTORE_INDEX_REQUIRED] code chip, and an amber underlined link
"Create the index in Firebase" whose href equals the server's indexUrl
exactly, opening in a new tab.
Evidence: shots/10-index-required.png; API probe extra block; probe check 11
Follow-up: THIS RUN FOUND A DEFECT AND IT IS FIXED. The first pass showed the
previous query's 9 offer rows still sitting in the results grid
underneath the red error strip, while the collection picker said
venues. That reads as "here is what your query returned". The Firestore
source now clears its last result on failure, the probe asserts zero
stale rows under an error, and a unit test covers it ("clears the
previous results when a query fails"). The BigQuery source has the same
behaviour and was NOT changed: see Findings.12. CONTROL: a single-field query on the same collection succeeds with no index prompt โ
- Prediction, stated before the run: this SUCCEEDS. A single-field ordered query needs only the automatic single-field index.
- Steps:
- Collection
venues, no filters, order bycreatedAtdescending, limit 10. - Run.
- Collection
- Expected: rows render, no index error.
Result: [x] pass [ ] fail [ ] blocked
Actual: 10 rows rendered, zero index links on the page, no error strip. The API
probe ran the same control against offers (order by createdAt desc, no
filter) and got 200 with 9 rows.
The prediction held, so scenario 11's failure really is about the
compound pair rather than about how the endpoint builds queries.
Evidence: shots/11-control.png; probe check 12
Follow-up: none13. Pickers are StyledSelect, no native <select> in the DOM โ
- Goal: the project dropdown convention holds on the new surface.
- Steps:
- Open the collection, field and operator pickers.
- Count native selects in the document.
- Expected: react-select menus, and zero native selects.
Result: [x] pass [ ] fail [ ] blocked
Actual: document.querySelectorAll('select').length = 0 with the Firestore
builder mounted and a filter row present. All four picker types
(collection, filter field, operator, order field and direction) render
the dark amber-accented react-select control and open its menu.
A component test asserts the same thing so it cannot regress silently.
Evidence: shots/07-firestore-builder.png; probe check 13;
apps/admin/src/admin/analytics/console/__tests__/FirestoreBuilder.test.jsx
Follow-up: none14. Limit ceiling is enforced server side โ
- Goal: the client cannot ask for an unbounded read.
- Steps:
POSTwithlimit: 100000, thenlimit: 0.
- Expected: 400 naming
limitin both cases, not a silent clamp.
Result: [x] pass [ ] fail [ ] blocked
Actual: limit 100000 -> 400 INVALID_LIMIT, no rows.
limit 0 -> 400 INVALID_LIMIT, no rows.
Neither was clamped and then honoured. The ceiling is 500 and it is
reported to the client by GET /analytics/admin/firestore-schema, so the
builder's number input carries the same max.
Evidence: API probe, scenario 14 block
Follow-up: none15. Payload verification: only allowlisted fields leave the server โ
- Goal: the thing the field allowlist exists for.
- Steps:
- Read each collection RAW via the Admin SDK and collect every field name present.
- Read the same collection through the endpoint.
- Diff the two key sets.
- Expected: only
idplus allowlisted fields survive.
Result: [x] pass [ ] fail [ ] blocked
Actual: Zero leaks in all three collections checked.
venues (25 docs sampled, 37 distinct fields on the documents)
response: activeLanternCount, category, createdAt, id, name, source, status
correctly dropped (31): address, addressCity, addressComponents,
addressCountry, addressPostcode, addressState, addressStreet, capacity,
cuisine, email, geohash, googleMapsUrl, lastEnrichedAt, lat, lng,
nameLower, openingHours, osmId, osmTags, osmType, outdoorSeating,
phoneNumber, searchTerms, takeaway, userReportedClosed,
userReportedClosedAt, userReportedClosedBy, website,
wheelchairAccessible, wifi, yelpUrl
offers (9 docs, 21 distinct fields)
response: createdAt, expiresAt, id, merchantId, status, title, updatedAt, venueId
correctly dropped (14): budget, createdBy, description, heroLayout,
liveEvent, moderation, per_user_limit, placement, radius, reviewedAt,
reviewedBy, showDisclaimerWhileSuppliesLast, targetAudience,
useGradientFallback
merchantProfiles (2 docs, 16 distinct fields)
response: createdAt, id, status
correctly dropped (13): businessName, contactName, createdBy,
displayName, failedLoginAttempts, lastFailedLoginAt,
lastMerchantLoginAt, merchantPasswordHash, merchantPasswordSalt,
notes, phone, updatedAt, updatedBy
The merchantProfiles line is the #875 reasoning proved live: those
documents carry merchantPasswordHash and merchantPasswordSalt TODAY,
and both were dropped. offers carries reviewedAt and reviewedBy today,
and both were dropped.
Evidence: the leak probe's per-collection diff, reproduced above verbatim
Follow-up: none16. Regression: a non-admin cannot reach the endpoint โ
- Goal: the new route inherits the same admin gate as the rest of
/analytics/admin. - Steps:
POSTwith noAuthorizationheader.- Repeat with a valid non-admin user token.
- Expected: 401 then 403, no rows in either.
Result: [ ] pass [ ] fail [x] blocked (half exercised)
Actual: Arm 1 PASSED: no Authorization header -> 401, no rows.
Arm 2 NOT EXERCISED: no non-admin ID token could be minted from this
machine. The app's non-admin users are phone-auth accounts whose
sign-in mints a custom token server side, and custom-token signing
needs a service-account credential; this VM has user ADC, which cannot
sign one. There is no non-admin email/password account to sign in as.
What we know instead, and its limit:
The route is mounted inside the SAME express chain as every other
/analytics/admin route: app.use('/analytics/admin', verifyAppCheck,
verifyFirebaseToken, requireRole('admin'), adminRoutes) in
services/api/analytics/src/index.js, unchanged by this PR. So a
non-admin is refused by middleware that predates this work and is
shared with a dozen live endpoints. That is a strong argument, not a
measurement, and it is recorded here as an argument.
Follow-up: Exercise arm 2 on deployed dev in the post-merge pass, signing in as an
ordinary app user, OR add a non-admin fixture to the emulator suite.Run 2 summary (2026-08-27) โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 17 | #970: the document key is withheld on merchantProfiles | [x] pass [ ] fail [ ] blocked | |
| 18 | #970: the key is still shipped where it names a row | [x] pass [ ] fail [ ] blocked | |
| 19 | #970: projection stops at depth one | [x] pass [ ] fail [ ] blocked | unit only, see block |
| 20 | #970: the audit log carries filter fields, never values | [x] pass [ ] fail [ ] blocked | |
| 21 | #971: a recovered schema fetch clears the error | [x] pass [ ] fail [ ] blocked | |
| 22 | #971: a held shortcut fires ONE query | [x] pass [ ] fail [ ] blocked | the headline number |
| 23 | #971: clearing the Limit box does not write a 0 | [x] pass [ ] fail [ ] blocked | |
| 24 | The filter row's four controls share one height | [x] pass [ ] fail [ ] blocked | |
| 25 | A non-JSON error body is not rendered as HTML | [x] pass [ ] fail [ ] blocked | |
| 26 | Accessibility: focus survives removing a filter | [x] pass [ ] fail [ ] blocked | |
| 27 | Accessibility: the status strip is a stable live region | [x] pass [ ] fail [ ] blocked | |
| 28 | The rail opens collapsed and behaves like the BQ schema browser | [x] pass [ ] fail [ ] blocked | |
| 29 | Every starter question fills the builder AND returns rows | [x] pass [ ] fail [ ] blocked | one dropped, see block |
| 30 | Saving a Firestore inquiry works end to end | [ ] pass [ ] fail [x] blocked | partially exercised |
| 31 | Reference Docs sits first in the tab strip | [x] pass [ ] fail [ ] blocked | link target un-exercised |
| 32 | featureFlags is gone and featureRequests names real fields | [x] pass [ ] fail [ ] blocked | |
| 33 | The five dead venue and offer field names are corrected | [x] pass [ ] fail [ ] blocked | |
| 34 | The Assistant stops sending Auth UIDs to the Claude API | [x] pass [ ] fail [ ] blocked | unit only, see block |
| 35 | get_bq_schema returns real names, scoped to analytics | [x] pass [ ] fail [ ] blocked | run against real BigQuery |
| 36 | The narrowed repeat guard leaves held undo working | [ ] pass [ ] fail [x] blocked | NOT EXERCISED |
| 37 | The Assistant actually uses the schema tool in a real turn | [ ] pass [ ] fail [x] blocked | NOT EXERCISED |
| 38 | Anything at all on deployed dev | [ ] pass [ ] fail [x] blocked | NOT EXERCISED, cannot be |
19 of 22 exercised and passing. One (30) is partially exercised. Three (36, 37, 38) were NOT exercised at all and are described below rather than left blank.
Run 2 scenarios โ
17. #970: the document key is withheld on merchantProfiles โ
- Prediction, stated before the run: rows come back with NO
id, and noidcolumn in the schema. - Steps: Firestore source, collection
merchantProfiles, no filters, Run. Read the raw response. - Expected: every row is
{status, createdAt}and the schema has noidentry.
Result: [x] pass [ ] fail [ ] blocked
Actual: 2 rows, keys exactly ["status","createdAt"]. Schema column names
["status","createdAt"], no id.
BEFORE the fix these documents shipped their key, and the key is a
28-character Firebase Auth UID: confirmed by sampling real documents
(id length 28, matching /^[A-Za-z0-9]{28}$/, where venues, offers and
featureRequests are 20-character auto-ids). At limit 500 that is up to
500 staff UIDs per request, under a two-field allowlist that had been
reviewed and approved.
Evidence: browser run 2026-08-27, 13/13 checks; route test "withholds the
document key on a collection keyed by auth UID"
Follow-up: none on the console. The SAME defect existed in the Assistant and is
scenario 34.18. #970: the key is still shipped where it names a row โ
- Prediction, stated before the run:
offersstill carriesid, because withholding every key would break the moderation workflow. - Steps: collection
offers, Run, read the raw response. - Expected: rows carry
id, andidis the first schema column.
Result: [x] pass [ ] fail [ ] blocked
Actual: 9 rows, first key set
["id","title","status","merchantId","venueId","expiresAt","createdAt","updatedAt"].
Schema starts with {name:"id", type:"STRING"}.
This is the control for 17: it shows the fix is a per-collection
RULING rather than a blanket removal.
Evidence: browser run 2026-08-27; service test "is present when the policy allows it"
Follow-up: none19. #970: projection stops at depth one โ
- Prediction, stated before the run: a field holding a map is summarized by key COUNT, never by key names.
- Steps: project a document whose allowlisted
sourcefield is a map. - Expected: the cell reads
<map, N keys>and no nested key name appears anywhere in the response.
Result: [x] pass [ ] fail [ ] blocked
Actual: UNIT ONLY, and the reason is stated rather than hidden: no allowlisted
field holds a map today, checked across every collection before the
change, so there is nothing live to exercise it against. The cap
therefore regresses nothing and cannot be demonstrated in the browser.
Unit: a venues document with source = {provider, importedBy,
rawPayload} projects to the string "<map, 3 keys>", and the serialized
rows contain neither "importedBy" nor the nested value.
A second test covers the trap: a plain map carrying `path` and
`firestore` keys is still summarized, because the DocumentReference
check was duck-typed on that shape until 2026-08-27 and is now checked
by class.
Evidence: service tests "summarizes a map by key count and never by key name",
"summarizes a map that happens to LOOK like a DocumentReference",
"still renders a REAL DocumentReference as its path"
Follow-up: venues.source is a scalar today and the import pipeline already writes
richer provenance maps beside it, so this becomes live-exercisable the
moment that lands. Re-run this scenario then.20. #970: the audit log carries filter fields, never values โ
- Prediction, stated before the run: the log entry names the fields and operators used, and contains no filter VALUE.
- Steps: run a query filtered on a distinctive value, inspect the emitted log entry.
- Expected:
filters: [{field, op}]present, the value absent.
Result: [x] pass [ ] fail [ ] blocked
Actual: Log entry carries filters [{field:"merchantId",op:"=="}] and the
serialized entry does not contain the filter value at all.
Before this change the entry carried only filterCount, so fifty
queries walking one merchant's offer book were indistinguishable from
fifty moderation checks.
Evidence: route test "logs the filter fields and operators, and never the filter
values"; browser run confirmed queryMeta.filters is an array
Follow-up: none21. #971: a recovered schema fetch clears the error โ
- Prediction, stated before the run: after a failed schema fetch, switching away and back re-fetches AND clears the stale error, with no page reload.
- Steps:
- Force
GET /analytics/admin/firestore-schemato answer 503. - Open the Firestore tab, confirm the error renders.
- Remove the 503, click BigQuery, click Firestore.
- Force
- Expected: the builder renders, with no error.
Result: [x] pass [ ] fail [ ] blocked
Actual: Step 2 rendered "Could not load the Firestore allowlist" as predicted.
Step 3 recovered fully: the error was gone and #fs-limit was present,
with no reload.
Before the fix, setSchemaError was never reset, so one cold-start 503
killed the Firestore source until a full page reload.
Evidence: browser run 2026-08-27, checks "a failed schema fetch still shows an
error" and "a recovered schema fetch clears the error without a
reload"; shots verify-02-schema-503.png, verify-03-recovered.png
Follow-up: none22. #971: a held shortcut fires ONE query โ
- Prediction, stated before the run: holding Cmd/Ctrl+Enter fires at most one request, where it previously fired one per key repeat.
- Steps: hold Ctrl+Enter for one second on the Firestore source. Count
firestore-queryresponses at the network layer. - Expected: exactly 1.
Result: [x] pass [ ] fail [ ] blocked
Actual: BEFORE: 65 requests on one run and 87 on another, from ONE SECOND of
held keys, every one issued past a Run button rendered disabled the
whole time. The filed issue estimated about 30, so the real rate was
two to three times worse than reported.
AFTER: 1 request.
Measured the same way both times, by counting responses whose URL
contains firestore-query during the hold plus a 4 second settle.
Each of those requests was a 500-row read, so the pre-fix behaviour was
roughly 32,500 to 43,500 documents read from one keypress.
Evidence: browser runs 2026-08-27 (repro and verify); hook test "runs one query
at a time, however many times the shortcut repeats"
Follow-up: The guard is client side. A second tab, or any non-browser caller with
an admin token, still reproduces unlimited 500-row reads because
/analytics/admin has no rate limit. Filed as
https://github.com/cattreedev/lantern_app/issues/977, out of scope here.23. #971: clearing the Limit box does not write a 0 โ
- Prediction, stated before the run: selecting the Limit value and pressing Backspace leaves the field EMPTY, and blurring normalizes it to the default rather than to 0.
- Steps: click Limit, select all, Backspace, read the value. Then blur and read again.
- Expected: "" then "50".
Result: [x] pass [ ] fail [ ] blocked
Actual: After Backspace the field read "" (empty). After blur it read "50".
Before the fix, Number('') is 0 so React wrote a literal "0" back into
the box under the cursor, and running at that moment sent limit 0,
which survived `limit ?? 50` because nullish coalescing does not catch
zero, and earned a 400.
Clamping also covers the two typed cases the issue named: 900 clamps
to 500 rather than 400ing, and 50.5 floors to 50.
Evidence: browser run 2026-08-27, checks "clearing Limit leaves it empty, not 0"
and "Limit normalizes on blur to the default"; hook tests for
clampLimit
Follow-up: none24. The filter row's four controls share one height โ
- Prediction, stated before the run: all four controls in a filter row report the same rendered height.
- Steps: add a filter, measure
getBoundingClientRect().heighton both StyledSelects, the value input and the remove button. - Expected: one distinct value.
Result: [x] pass [ ] fail [ ] blocked
Actual: [42, 42, 42, 42].
BEFORE: 42, 42, 34, 24 in one centered flex row.
THE TRAP, and it is why a first fix did not work: StyledSelect
DECLARES minHeight 38px and RENDERS at 42, because react-select's
control and value-container padding carry it past the minimum. Setting
the shared height to the declared 38 produced [42,42,38,38], still
wrong. The value came from measuring the running page, not from
reading the style object.
Evidence: browser measurement run 2026-08-27 (getComputedStyle plus
getBoundingClientRect on every control in the chain)
Follow-up: none25. A non-JSON error body is not rendered as HTML โ
- Prediction, stated before the run: an API answering with an HTML error page produces a readable sentence, not a pasted document.
- Steps: point the portal at an analytics-api lacking the endpoint, open the Firestore tab.
- Expected: a short message naming the real cause.
Result: [x] pass [ ] fail [ ] blocked
Actual: This was found by the OPERATOR, not by the plan. Her words: "Could not
load the Firestore allowlist: <!DOCTYPE html> <html lang="en"> <head>
<meta charset="utf-8"> <title>Error</title> </head> <body> <pre>Cannot
GET /analytics/admin/firestore-schema</pre> </body> </html>".
apiRequest used a non-JSON body AS the error message, so express's 404
page was rendered into the console's error strip.
The message is now derived from the status, and 404 says the analytics
API is likely running an older build than the portal, which is the
actual cause. The raw body is kept as `detail` for diagnosis.
Evidence: operator report 2026-08-27; the underlying 404 reproduced and traced to
ANALYTICS_API_ORIGIN pointing at deployed Cloud Run
Follow-up: The CONFIGURATION half is not a code bug. A portal proxied to an
analytics-api that predates these endpoints will always 404; the fix
is to point ANALYTICS_API_ORIGIN at a build that has them.26. Accessibility: focus survives removing a filter โ
- Prediction, stated before the run: removing a filter row moves focus to a surviving control rather than dropping it to
<body>. - Steps: add a filter, focus its remove button, activate it, read
document.activeElement. - Expected: focus is on Add filter.
Result: [x] pass [ ] fail [ ] blocked
Actual: document.activeElement resolved to "Add filter".
Before the fix the row unmounted with the focused button inside it, so
focus fell to body and the next Tab restarted past the whole sidebar.
Evidence: browser run 2026-08-27, check "removing a filter keeps focus in the
builder"
Follow-up: none27. Accessibility: the status strip is a stable live region โ
- Prediction, stated before the run: the strip carries role and aria-live from FIRST render, not only once an error exists.
- Steps: read role and aria-live on the strip before any query runs.
- Expected: role="status", aria-live="polite".
Result: [x] pass [ ] fail [ ] blocked
Actual: {role:"status", live:"polite"} present with no query run.
Before the fix the component toggled `role` between undefined and
"alert" on an element already in the DOM, which is the unreliable half
of the ARIA contract: a screen reader watches a region that was live
when it mounted, so nothing announced a query starting, finishing or
failing. aria-busy now carries the in-flight state.
NOTE: this asserts the ATTRIBUTES, not that a screen reader speaks. A
real assistive-technology pass is not something this harness can do.
Evidence: browser run 2026-08-27; three FirestoreStatusStrip unit tests
Follow-up: A real screen-reader pass on this surface has never been done, here or
in run 1.28. The rail opens collapsed and behaves like the BQ schema browser โ
- Prediction, stated before the run: one collapsed group per collection, a count badge, and a filter that force-opens matching groups.
- Steps: open the Firestore source. Count groups and visible questions. Expand all. Filter for "bars".
- Expected: 4 groups, 0 questions visible, then all visible, then only the matching one.
Result: [x] pass [ ] fail [ ] blocked
Actual: 4 groups (venues, offers, merchantProfiles, featureRequests), 0
questions visible on load. Expand all revealed 9. Filtering "bars"
left exactly ["Which bars are in the system?"] and disabled that
group's collapse toggle, so a match cannot hide behind a chevron.
This shape was a REJECTION, not a plan. The first build put a field
reference under the operator's phrase "What you can ask" and invented
its own interaction. Her words: "For the what you can ask, why isn't it
expand collapse based on collection? Can you look at how BQ is built to
please emulate that?" and "i literally meant it as what you can ask. If
a user clicks, that auto fills."
Evidence: browser run 2026-08-27, 4 checks; 19 FirestoreRail unit tests
Follow-up: none29. Every starter question fills the builder AND returns rows โ
- Prediction, stated before the run: clicking any question populates the builder, and running it returns at least one row against real dev data.
- Steps: expand all, click each question in turn, Run, record the row count from the response.
- Expected: every question returns rows and no question errors.
Result: [x] pass [ ] fail [ ] blocked
Actual: 9 questions, all 9 filled the builder, all 9 ran without error, all 9
returned rows:
Which venues are busiest right now? venues 50
What came in from the venue import backlog? venues 50
Which bars are in the system? venues 50
Which offers are live right now? offers 6
Which offers were rejected? offers 1
What was submitted most recently? offers 9
Which merchant accounts are still setting up? merchantProfiles 2
What has been requested so far? featureRequests 3
What is still sitting as submitted? featureRequests 3
A TENTH was written and then DROPPED. "Which flags are switched on?"
returned 0 rows because featureFlags is empty, and a starter question
that can only ever paint an empty table teaches the opposite of what it
is for. featureFlags was subsequently removed from the allowlist
entirely (scenario 32).
The values in these questions were read out of dev before being
written, and that check paid: venues.status is `open` on all 300
sampled documents, not live/pending/archived; offers.status has no
`pending`; and activeLanternCount > 0 currently matches ZERO venues.
Roughly half the questions drafted from field names alone would have
painted empty tables.
Evidence: browser run 2026-08-27, per-question row counts; service tests
"gives every question a structured query that names real fields" and
"validates every starter question, so none of them can 400"
Follow-up: "What is waiting in the moderation queue?" (status == pending_review)
was added later and returns 0 rows TODAY, because no offer is in that
state on dev. It is kept deliberately, unlike the featureFlags one:
pending_review is a real state that real offers pass through, so the
question will answer as soon as one does.30. Saving a Firestore inquiry works end to end โ
- Prediction, stated before the run: Save is reachable on the Firestore source, the dialog previews the structured query, and a saved inquiry reloads into the builder.
- Steps: build a query, Save, name it, reopen it from the rail's Saved panel.
- Expected: the builder returns to the saved state.
Result: [ ] pass [ ] fail [x] blocked
Actual: PARTIALLY EXERCISED, and the un-exercised half is named here rather
than implied.
EXERCISED: the Save button exists on the Firestore source (it did NOT
before: ConsoleToolbar hid it behind `isSql`, so the store, the dialog
and the rail panel all worked and the door did not exist). The dialog
opens. Its preview renders the STRUCTURED inquiry, verified reading
"collection: featureRequests / where status == "submitted" / limit 50"
rather than an empty SQL box.
NOT EXERCISED: actually writing a saved inquiry to Firestore and
reloading it. adminSavedQueries is a SHARED collection on live dev, and
writing test rows into it would leave litter another session sees. Every
other scenario in both runs is read only.
The reload path is covered by unit tests only: the rail filters to
firestore rows, a legacy SQL row is excluded, and loadQuery replaces
the whole query rather than merging it.
Evidence: browser run 2026-08-27, checks "Save is available on the Firestore
source" and "the dialog previews the STRUCTURED inquiry"; shots
q-04-save-dialog.png; FirestoreRail and SaveQueryDialog unit tests
Follow-up: SOMEBODY SHOULD ACTUALLY SAVE ONE. The write path to adminSavedQueries
has never been exercised for the firestore source, on any run. It needs
either a throwaway named row the tester then deletes, or an emulator.31. Reference Docs sits first in the tab strip โ
- Prediction, stated before the run: the strip reads Reference Docs, BigQuery, Firestore, and the first is an external link.
- Steps: read every
.page-tabs__tabin order, with its tag and href. - Expected: three tabs in that order, the first an anchor.
Result: [x] pass [ ] fail [ ] blocked
Actual: ["Reference Docs","BigQuery","Firestore"], first is an <A> with href
http://localhost:5174/engineering/analytics/query-console.
This ordering is the operator's: "I think we need a simple reference
doc and have that at the front of the sub nav line items so it would be
something like ( Reference doc | BQ | FS ). Other pages have that for
reference." It matches BigQueryTabs and EventTrackingTabs exactly.
NOT EXERCISED: whether the link RESOLVES. It 404s right now, and the
operator hit that: "I am getting a 404 when I click on the reference
link". The cause is environmental rather than a defect. The docs site
is VitePress serving the MAIN checkout, and query-console.md exists
only on this branch, so it cannot resolve until the branch merges.
Evidence: browser run 2026-08-27, 2 checks; operator report of the 404
Follow-up: RE-CHECK THIS LINK AFTER MERGE. It is the one scenario in run 2 whose
real answer is only available post-merge, and a 404 on a reference link
the console advertises would be worse than not linking it.32. featureFlags is gone and featureRequests names real fields โ
- Prediction, stated before the run: four readable collections, not five, and featureRequests ordering by date returns rows.
- Steps: read the rail groups and the collection picker. Run the featureRequests question that orders by date.
- Expected: no featureFlags anywhere, and rows with real timestamps.
Result: [x] pass [ ] fail [ ] blocked
Actual: Rail groups ["venues","offers","merchantProfiles","featureRequests"].
Picker offers 4 options, no featureFlags.
Ordering featureRequests by submittedAt returned 3 rows carrying real
timestamps and real issue numbers.
BEFORE: the allowlist named `createdAt` and `issueNumber`, and this
collection has never carried either. The writer emits `submittedAt` and
`githubIssueNumber`. Both dead names projected to nothing, so the
column was permanently empty and ordering by date returned NO ROWS AT
ALL. featureFlags had zero documents and zero writers or readers
anywhere in the repo.
Also asserted: `submittedBy`, which holds a user id, never reaches the
browser.
Evidence: browser run 2026-08-27, 6 checks; census against live documents
Follow-up: none. This was found by the operator asking what the two collections
were FOR, and answering by checking rather than by reading the list.33. The five dead venue and offer field names are corrected โ
- Prediction, stated before the run: the corrected names carry real values where the old names returned nothing.
- Steps: run a venues question and an offers question, count rows carrying the corrected fields, and read the schema column names.
- Expected: real values present, dead names absent from the schema.
Result: [x] pass [ ] fail [ ] blocked
Actual: venues: 50 rows, 22 of them carrying addressCity. Schema reads
["id","name","addressCity","addressState","category","status",
"activeLanternCount","source","createdAt","updatedAt"], with no city,
state or tier.
offers: 6 rows, 6 of them carrying targetAudience. Schema has
targetAudience and neither audience nor startsAt.
The census that justified each removal, against live documents:
venues.city 0 / 300 -> addressCity 112 / 300
venues.state 0 / 300 -> addressState 104 / 300
venues.tier 0 / 300 -> not a venue concept
offers.audience 0 / 9 -> targetAudience 9 / 9
offers.startsAt 0 / 9 -> nested in liveEvent only
Run 1 measured the same three venue fields across the FULL collection
and got 0 of 22,634 each, which is the better number.
Evidence: browser run 2026-08-27, 5 checks; live census; drift test "name venue
and offer fields the writers actually emit"
Follow-up: THIS WAS NOT A NEW FINDING. Run 1 recorded all seven dead fields on
2026-08-24 under "Seven allowlisted fields have no data behind them at
all", and correctly parked them as an operator decision. They then sat
for three days because nothing carried that decision to her. The
finding was not missed; the handoff was. Worth remembering when a
run parks something.34. The Assistant stops sending Auth UIDs to the Claude API โ
- Prediction, stated before the run:
query_firestoreon merchantProfiles returns no document key, on both the single read and the list read. - Steps: exercise the tool against stubbed documents whose ids are Auth-UID shaped.
- Expected: the UID appears nowhere in the tool output.
Result: [x] pass [ ] fail [ ] blocked
Actual: UNIT ONLY, and the reason matters: exercising this live would mean
driving a real Assistant conversation, which sends content to the
Claude API and writes a stored conversation log. Doing that to
demonstrate a UID leak would be reproducing the leak.
Single read: the 28-character id is absent from the output, the status
field is present. List read: neither of two UID-shaped ids appears, and
the count is still 2. Control: offers still ships its key.
SEVERITY, because it is worse than the console's version of the same
bug: the console kept those ids in-house, this tool sends whatever it
returns to an EXTERNAL processor and into the stored conversation log,
25 documents at a time. Pre-existing on dev, found by the code review.
Evidence: three tests in firestoreTool.privacy.test.js under "the document key
goes through the same review as every field"
Follow-up: The drift test now compares the KEY decision, not only field names.
It could not see this divergence at all before, which is the more
useful half: a guard that checks half a decision reports green on the
other half. Proven to fail by adding merchantProfiles to the
assistant's expose set.35. get_bq_schema returns real names, scoped to analytics โ
- Prediction, stated before the run: the tool returns real tables and columns from the analytics dataset, and nothing from the other eight.
- Steps: run the tool against REAL BigQuery. Compare its output against the full dataset list. Check for row-data keys.
- Expected: real analytics tables only.
Result: [x] pass [ ] fail [ ] blocked
Actual: RUN AGAINST REAL BIGQUERY, not the mock.
5 tables returned: analytics.ad_delivery_daily (13 cols),
analytics.event_counts_daily (7), analytics.events (12),
analytics.recent_system_events (VIEW, 10),
analytics.recent_user_events (VIEW, 10).
analytics.events correctly reports partitionedOn = timestamp, which is
the whole reason a query without that filter fails with a message about
partition elimination rather than about a missing filter.
Eight other datasets exist (billing_attrib, billing_export_gcp,
billing_exports_gcp, billing_marts, billing_norm, billing_raw, logs,
ops). NONE appear anywhere in the output.
No row-data keys. No placeholder tokens.
Refusals read correctly and distinguish the two cases: an out-of-scope
dataset says so as a deliberate limit, an absent table inside a
readable dataset says no such table.
A BUG IN THIS WORK WAS FOUND ONLY BY THIS LIVE RUN: the BigQuery client
reports the LITERAL string {{projectId}} until it resolves one, so the
first working version put that placeholder into the model's context.
That is the same failure class the tool exists to prevent, since a
placeholder reads as a name where a null reads as unknown. The mocks
passed it happily.
Evidence: live BigQuery run 2026-08-27; 20 tests in bqSchemaTool.test.js
Follow-up: none36. The narrowed repeat guard leaves held undo working โ
- Prediction, stated before the run: holding Cmd/Ctrl+Z in the SQL editor still undoes repeatedly.
- Steps: BigQuery source, type SQL, hold the undo shortcut, observe whether the editor keeps undoing.
- Expected: repeated undo works.
Result: [ ] pass [ ] fail [x] blocked
Actual: NOT EXERCISED. Recorded as un-exercised rather than assumed, because
the change was made in RESPONSE to this exact risk and never tested
against it.
The #971 fix originally placed `if (e.repeat) { preventDefault; return }`
above the key branches, so it cancelled EVERY held modifier combination
on the page, undo and redo in the SQL editor included. The code review
caught it and it is now scoped to the Enter branch.
The reasoning is sound (the handler only owns Enter, E and S, and the
hook's in-flight ref stops duplicate requests either way) but reasoning
is not a run, and today produced four separate cases where the code
said one thing and the behaviour said another.
Evidence: none. That is the point of this block.
Follow-up: EXERCISE THIS. Hold undo in the BigQuery SQL editor and confirm it
repeats. It is a two minute check and it closes the one change on this
branch whose only support is an argument.37. The Assistant actually uses the schema tool in a real turn โ
- Prediction, stated before the run: asked a BigQuery question, the Assistant calls
get_bq_schemaand writes SQL using only names it returned. - Steps: open the admin Assistant, ask the question that failed on 2026-08-24, observe the tool call and the SQL.
- Expected: a real table name, and a filter on
timestampforanalytics.events.
Result: [ ] pass [ ] fail [x] blocked
Actual: NOT EXERCISED. The tool and the prompt are verified in isolation; the
BEHAVIOUR they exist to change is not.
What IS verified: the tool returns correct real data (scenario 35), the
system prompt now names BigQuery where it previously contained the word
zero times, and the guardrail covers dataset, table and column names
where it previously named only file paths, function names and Firestore
fields.
What is NOT: that the model actually calls the tool before writing SQL,
that it stops producing `your-project.firestore_export.offers`, and
that it routes a no-time-range question to query_firestore instead.
A real turn costs a Claude API call and writes a stored conversation
log, and the outcome is model behaviour rather than a code path, so it
is a judgement call rather than an assertion.
Evidence: none for the behaviour. Scenario 35 for the tool.
Follow-up: ASK IT THE ORIGINAL QUESTION. The 2026-08-24 failure is the benchmark
and it is reproducible: ask for a query about offers and see whether a
real table name comes back. Until someone does, slice 5 is a
well-tested mechanism with an unmeasured effect.38. Anything at all on deployed dev โ
- Prediction, stated before the run: none. This scenario exists to record an absence.
Result: [ ] pass [ ] fail [x] blocked
Actual: NOT EXERCISED, and it CANNOT be from here. Every endpoint in this
branch exists only on the branch. Deployed dev runs an analytics-api
that predates them, which is exactly why the operator's own portal
answered 404 all morning: it proxies to deployed Cloud Run.
Both runs are therefore local-stack only, against live lantern-app-dev
Firestore and BigQuery for data.
Evidence: the 404 the operator hit, traced to ANALYTICS_API_ORIGIN
Follow-up: The post-deploy pass is the close step. Re-run scenarios 17, 18, 22,
29, 31 and 33 against deployed dev once this merges and the
analytics-api deploys, because those six are the ones whose answers
depend on the deployed build rather than on the code.Findings from run 1 (2026-08-24) โ
Four things run 1 surfaced that are not pass/fail on the code under test.
A defect found and fixed: a failed query left the previous result on screen. โ
- Seen in the first browser pass at scenario 11: nine
offersrows sat in the results grid under a red index error, while the picker readvenues. - Fixed on the Firestore source: a failed run now clears the last result. Covered by a unit test and asserted by the browser probe.
- The BigQuery source does the same thing and was deliberately NOT changed. It is pre-existing behaviour on a surface outside this slice's scope. It is worth a follow-up so the two sources behave alike, but that is her call, not a silent edit.
Seven allowlisted fields have no data behind them at all. โ
- A census over every document in each collection:
| Collection | Field | Documents carrying it |
|---|---|---|
| venues | city | 0 of 22,634 |
| venues | state | 0 of 22,634 |
| venues | tier | 0 of 22,634 |
| offers | audience | 0 of 9 |
| offers | startsAt | 0 of 9 |
| featureRequests | createdAt | 0 of 3 |
| featureRequests | issueNumber | 0 of 3 |
- All seven were inherited verbatim from the assistant's
FIRESTORE_FIELD_ALLOWLIST, so the same fiction exists there: the Assistant can offer to filtervenues.cityand will always match nothing. The venue documents useaddressCity/addressState; offers usetargetAudience. - Nothing was changed. Removing them is an edit to an allowlist the operator approved, so it is hers to make.
venues.updatedAt is approved but nearly empty. โ
- 8 documents of 22,634 carry it.
venues.createdAtis on all 22,634, and bothofferstimestamps are on all 9. - So three of the four approved fields fully serve their stated purpose. The fourth, "same recency need" on the venue import backlog, currently has eight rows behind it.
Two environment traps cost time and are now written into Setup. โ
- Overriding
VITE_ANALYTICS_API_URLdoes nothing: the client reads/apiand vite proxies toANALYTICS_API_ORIGIN, which is the variable that actually has to move. - A fresh worktree needs
generate-client-sdk-docs.mjsas well asnpm install, orAdminShell.jsxdies with a dynamic-import error that names the wrong file.
Findings from run 2 (2026-08-27) โ
Run 1 already found the seven dead fields. The finding was not missed, the handoff was. โ
- The table above under "Seven allowlisted fields have no data behind them at all" is dated 2026-08-24 and is correct in every row. It ends "Nothing was changed. Removing them is an edit to an allowlist the operator approved, so it is hers to make", which was the right call.
- They sat for three days. Nothing carried that decision to her, so a code review on 2026-08-27 reported five of them as new, and the two
featureRequestsones were found separately when she asked what the collection was for. - The lesson is about parking, not about detection. A finding parked pending a decision needs a route to the person who makes it, or it is indistinguishable from a finding nobody had.
Three separate times, the code asserted something the data contradicted, and nothing failed. โ
- Status descriptions naming values nobody uses (
pending,approved,live) on collections whose real values areopen,active,draft,rejected,submitted,pending_setup. - Seven field names on collections that have never carried them.
- A whole collection,
featureFlags, allowlisted and advertised, backed by nothing. - None of them errored, which is why they survived. A dead field projects to nothing so the column is merely empty; a dead collection returns zero rows so it merely looks quiet.
The guard written to stop this was measuring the wrong property. โ
- The allowlist drift test compares the two copies. That can only tell you they MATCH, never that either is RIGHT, and all five venue and offer names were wrong identically, so it stayed green.
- It also compared only field names while the two surfaces disagreed on whether a document KEY leaves the process, so it reported green on half a decision.
- Agreement is not correctness, and a check that covers half a decision covers none of it. Both limits are now stated in the test file rather than left implicit.
Two bugs on this branch were found only by running the thing, never by reading it. โ
- The
placeholder in brand-new code, which every mock passed happily. - All seven fictional field names, each invisible in the diff and obvious in one query.
- The habit that caught both: check it against the writer, then check it against real data.
What neither run has ever exercised โ
- The write path to
adminSavedQueriesfor the firestore source (scenario 30). - Held undo in the SQL editor after the repeat guard was narrowed (scenario 36).
- Any real Assistant turn using the new schema tool (scenario 37).
- Anything at all on deployed dev, which is not possible until this merges (scenario 38).
- A real screen-reader pass on the console (scenario 27 asserts the attributes only).
Feedback log โ
Operator feedback during these runs goes here, verbatim, in the order it arrived.
2026-08-27, opening scope:
"It isn't finished. Looking at the firestore query console, it has bugs. I also want to verify the design of it as well."
2026-08-27, hitting the dead Firestore tab herself:
"FYI I am still seeing this error when I try to look at the firestore query console: Could not load the Firestore allowlist:
Error Cannot GET /analytics/admin/firestore-schema"
2026-08-27, once it worked:
"I see it working now, thank you! That said, we definitely need some design and functionality considerations. First off, we probably need to create a reference doc for this. Or at bare minimum have some breadcrumbs ( like what is firestore for vs bigquery? ). Also, how would someone know how to even query firestore? For example, how would they know to query how many lanterns are lit at an x venue for example?"
2026-08-27, choosing from the mockups:
"I think a mix of B and D ( minus that FYI popup on "firestore answers..." ). For B, I think having a similar "what you can ask", but also saved inquires would be helpful. I think we need a simple reference doc and have that at the front of the sub nav line items so it would be something like ( Reference doc | BQ | FS ). Other pages have that for reference."
2026-08-27, rejecting three parts of that build:
"For the what you can ask, why isn't it expand collapse based on collection? Can you look at how BQ is built to please emulate that?"
"For the what you can ask, i literally meant it as what you can ask. If a user clicks, that auto fills."
"There is literally no way you can even save a query"
2026-08-27, on the field descriptions:
"based on first glances, yes"
2026-08-27, on the reference link:
"I am getting a 404 when I click on the reference link"
2026-08-27, the question that found two live defects:
"What are these feature requests and feature flags for?"