Skip to content

feat: Genie One managed-memory agent panel - #7

Open
zuckerberg-db wants to merge 123 commits into
mainfrom
genie-agent
Open

feat: Genie One managed-memory agent panel#7
zuckerberg-db wants to merge 123 commits into
mainfrom
genie-agent

Conversation

@zuckerberg-db

Copy link
Copy Markdown
Collaborator

Summary

Adds an optional Agent panel to the SSO-SPN org view: a slide-out chat assistant with Genie One (workspace-wide Genie via MCP) and durable managed memory (Unity Catalog memory store).

Unlike the VSCode/notebook editors (Go proxy), the agent embeds through a Vercel-native reverse proxy at /api/agent-proxy that mints a workspace bearer from the current user's mapped service principal — so guest users never hit the Databricks OAuth wall.

The deployable Databricks App is assembled from the vendor/app-templates submodule (agent-openai-agents-sdk + e2e-chatbot-app-next) plus a local overlay in agent/.

Architecture

sequenceDiagram
    participant User
    participant Browser as Browser (Agent panel iframe)
    participant NextJS as Next.js (/api/agent-proxy)
    participant DB as Database
    participant OIDC as Databricks OIDC
    participant App as Agent App (Databricks)
    participant Genie as Genie One MCP
    participant Memory as UC memory store

    User->>Browser: Open Agent panel
    Browser->>NextJS: GET /api/agent-proxy
    NextJS->>DB: Resolve session, org, SPN mapping
    NextJS->>OIDC: client_credentials token
    NextJS->>App: Proxy with Bearer + HTML rewrite
    Browser->>NextJS: POST /api/agent-proxy/api/chat (SSE)
    NextJS->>App: Forward chat stream
    App->>Genie: ask_genie_one
    App->>Memory: read/write long-term memory
    App-->>Browser: Streamed answer
Loading

See also: src/app/docs/solutions/agent/page.tsx and public/solutions/agent/architecture.mermaid.

What's new

Frontend (Vercel)

  • src/components/agent/agent-panel.tsx — slide-out panel (feature-flagged)
  • src/app/api/agent-proxy/[[...path]]/route.ts — SPN token minting, SSE proxy, HTML rewrite (<base>, light theme)
  • src/stores/agent-panel-store.ts — panel open/minimize state
  • Wired into src/app/sso-spn/[orgId]/layout.tsx

Agent app (Databricks)

  • agent/ overlay: agent_server/ (Genie tools, managed memory), chat UI patches, databricks.yml
  • scripts/assemble_agent.sh — merges submodule + overlay → agent-build/
  • agent/scripts/deploy_agent.sh, vendor_wheels.sh, setup_memory_store.py, start_app.py

Auth / guest fixes

  • src/lib/auth-dynamic.ts — Okta plugin only initializes when env vars are set (fixes Vercel build prerender crash)
  • Guest API contract documented in README (was marked "Coming Soon" but routes exist)

Docs / bootstrap

  • README Agent Panel section
  • BOOTSTRAP.md + scripts/bootstrap.sh — end-to-end fresh-workspace runbook (Phases 0–9)
  • Solution docs index + agent solution page

Enable the panel

Frontend (.env.local / Vercel):

NEXT_PUBLIC_AGENT_ENABLED=true
DATABRICKS_AGENT_APP_URL=https://your-agent-app.databricksapps.com

Runtime prerequisites (or /api/agent-proxy returns 400/401):

  • Active org has workspaceUrl set
  • User has an SPN mapping (userSpns) for that org
  • Guest SP has CAN_USE on the agent app (documented in BOOTSTRAP Phase 6b)

Agent app (bundle in agent/databricks.yml, not frontend env):

  • GENIE_MCP_MODE=one — workspace-wide Genie One
  • DATABRICKS_MEMORY_STORE — UC memory store FQN (must be created + granted post-deploy)

Deploy path (happy path)

git submodule update --init
bash scripts/assemble_agent.sh

cd agent-build
uv run --python 3.12 python scripts/quickstart.py --profile <p> --lakebase-create-new <name>
bash scripts/vendor_wheels.sh

databricks bundle deploy --profile <p> -t dev
databricks bundle run agent_openai_agents_sdk --profile <p> -t dev

uv run --python 3.12 python scripts/setup_memory_store.py <app-sp-client-id> \
  --memory-store workspace.default.firefly_managed_memory --profile <p>

Or use bash scripts/deploy_agent.sh <profile> (deploy → run → memory store).

Full fresh-workspace walkthrough: BOOTSTRAP.md.

Known limitations

  • First app start can take several minutes — container runs uv sync, npm install, and a Next.js production build before serving. Expect transient 503s; judge health on app_status=RUNNING, not deployment status alone.
  • Python 3.11 runtime on Databricks Apps (cp311 wheels vendored; README documents the pin).
  • UC memory store is not auto-createdsetup_memory_store.py must run after deploy or memory silently no-ops.
  • Genie needs queryable data — grant the app SP USE CATALOG / USE SCHEMA / SELECT + warehouse CAN_USE on schemas with tables.
  • Preview deployments: do not set NEXT_PUBLIC_BETTER_AUTH_URL (causes CORS on hash-suffixed preview URLs). Omit SPN_AUTH_OKTA_* unless using Okta.

Reviewer guide

Suggested read order:

  1. src/app/docs/solutions/agent/page.tsx
  2. src/app/api/agent-proxy/[[...path]]/route.ts — token + proxy
  3. agent/agent_server/genie_tools.py — Genie One MCP
  4. agent/agent_server/utils_memory.py — UC memory store REST API
  5. scripts/assemble_agent.sh + agent/databricks.yml — packaging

Intentional design choice: editors use the Go proxy; the agent uses the Vercel-native proxy (no Cloud Run / Go proxy required for the panel).

Test plan

Verified on a fresh workspace via BOOTSTRAP.md Phases 0–9:

  • assemble_agent.sh + submodule init
  • Databricks app reaches app_status=RUNNING (vendor wheels + bundle deploy)
  • setup_memory_store.py creates UC memory store and grants app SP
  • Vercel preview deploy with guest-only env tier (no Okta vars; no NEXT_PUBLIC_BETTER_AUTH_URL)
  • Guest API chain (workspace → SPN → user) + browser login via OTT URL
  • Agent panel opens and streams answers via Genie One over seeded UC data
  • Cross-session managed memory (save in session 1, recall in session 2)
  • Genie attribution link visible in panel

…rcel-native SPN proxy

- Vendor databricks/app-templates as a sparse submodule (agent-openai-agents-sdk,
  e2e-chatbot-app-next) with an agent/ overlay + assemble_agent.sh.
- Add a slide-out Agent panel (store + trigger) to the sso-spn org view.
- Embed the managed-memory Databricks App via a same-origin Vercel route
  (api/agent-proxy) that mints the current user's SPN token (guest/BYOD) and
  injects it as a bearer, streaming responses (chat SSE). No Go proxy / Cloud Run
  needed for the agent.
- Chat UI overlay patches: base:"./" + a mount-prefix fetch shim so assets and
  /api/ calls resolve under the proxy subpath.
- proxy: buffer request body with Content-Length (chunked/duplex POSTs were
  rejected by the Databricks Apps front door with "Proxy error:")
- proxy: inject <base href> + force next-themes light so the embedded chat UI
  resolves relative assets under the mount and matches Firefly's light UI
- proxy: serve the injected HTML no-store and drop conditional request headers
  so per-request rewrites always reach the browser (app ETag never changes)
- panel: point iframe at /api/agent-proxy (no trailing slash) to skip the 308
- build: exclude agent/, agent-build/, vendor/ from the Next TS build and add
  .vercelignore so the frontend build ignores the agent overlay + submodule
Add a dedicated /docs/solutions/agent page (with architecture sequence
diagram) and Solutions-nav entry for the managed-memory agent panel, and
document the agent panel + Genie One usage across README, CLAUDE, AGENTS,
and .env.example.

- New docs-site page src/app/docs/solutions/agent/page.tsx +
  public/solutions/agent/architecture.mermaid
- Proxy implementation detail lives under Backend Configuration, not the
  overview
- README: Agent Panel section, "How the agent uses Genie One", agent env vars
- CLAUDE/AGENTS: Vercel-native proxy + submodule/overlay notes
- .env.example: NEXT_PUBLIC_AGENT_ENABLED, DATABRICKS_AGENT_APP_URL
The parent repo gitignores agent-build/, and `databricks bundle deploy`
respects the enclosing repo's ignore rules, so sync found zero files
("no files to sync") and would ship an empty app. assemble_agent.sh now
runs a local-only `git init` on agent-build/ to scope the bundle to it.
Verified: `databricks bundle validate` now reports 0 "no files to sync"
warnings.
The Build & deploy section was happy-path only. Add the generic
operational detail so anyone cloning the fork can deploy without tribal
knowledge: bundle validate + the "0 files to sync" health check, the
post-run HTTP 503 poll (container builds uv/npm/vite before serving), the
Python 3.12 pin for the PyO3 cap, overlay-applied sanity checks, and why
assemble_agent.sh git-inits agent-build.
The agent panel's /api/agent-proxy returns 400/401 unless the user's
active org has a workspaceUrl and a userSpns mapping for the user's email.
Spell this out in the "Enable it" section so it isn't a silent failure.
- add prerequisite section for provisioning the agent's Databricks
  resources (MLflow experiment + Lakebase via quickstart, wheels volume)
- document the post-deploy Lakebase permission grant required for memory
- add the required `bundle run` app resource key and readiness check via
  `databricks apps get ... RUNNING`
- list the Agent-panel SSO->SPN + guest env vars for Vercel deployment
- flag still-unverified claims (empty wheels volume sufficiency, lakebase
  name rules) inline for validation

Flagged UNVERIFIED items are inferred, not yet proven by a negative deploy test.
Genie One is queried as the app's service principal, so it only returns UC
data that SP can access. Without grants, data questions return empty. Document
the required USE CATALOG/USE SCHEMA/SELECT (+ warehouse access), flagged pending
verification of the exact minimal grant on a clean workspace.
… ordering

Replace hardcodes with bundle variables/derivation and fix the deploy flow
based on end-to-end cleanroom proof on a fresh workspace:

- Parameterize wheels-volume + memory-store namespace as bundle vars defaulting
  to workspace.default (works on Default-Storage workspaces with no `main`);
  derive HOST/WORKSPACE_ID/GENIE_ONE_URL at runtime instead of storing them.
- Sync pyproject.toml (not uv.lock) and install build deps offline from
  pre-vendored cp311 wheels via UV_FIND_LINKS (scripts/vendor_wheels.sh),
  avoiding the dead .dev proxy, the lagging .cloud mirror, and the 10-min
  startup wall.
- Frontend Drizzle migrations need no manual Lakebase grant: the Postgres
  resource binding is CAN_CONNECT_AND_CREATE, which auto-provisions the app
  SP's Postgres role at deploy. Drop the pre-build grant step.
- Add scripts/setup_memory_store.py and wire it into scripts/deploy_agent.sh:
  the durable memory is a UC memory store (not Lakebase) that must be created
  and granted READ/WRITE to the app SP — otherwise memory silently no-ops.
- start_app.py builds the frontend before binding the port so failures are
  honest in the live app_status; deploy state goes green at container-start.
- GAP-19: okta plugin in auth-dynamic.ts was unconditionally constructed
  with process.env.SPN_AUTH_OKTA_CLIENT_ID!, crashing Next.js prerendering
  when the env var is absent. Now guarded: plugin is only registered when
  all three SPN_AUTH_OKTA_* vars are present.

- GAP-20: NEXT_PUBLIC_BETTER_AUTH_URL baked at build time causes CORS on
  Vercel preview deployments. Updated .env.example with an explicit warning
  (commented out by default); updated README to say do not set this var
  unless using a stable custom domain on all deployments.

- GAP-21: Guest login was documented as "Coming Soon." Added a Guest User
  Provisioning section to README with the correct 3-step API sequence
  (workspaces → spns → users), required fields, and accurate response shapes.

- GAP-22: Added a Vercel deployment step documenting how to disable SSO
  protection on preview deployments. Corrected CLI command to the real
  `vercel project protection disable <name> --sso` (v54+ syntax).
BOOTSTRAP.md covers all 22 documented gaps with corrected commands,
a full interactive-auth flow (browser OAuth for Databricks, Neon, Vercel,
GitHub — no manual key entry), and a proven vs. inferred gap reference table.

scripts/bootstrap.sh implements the runbook as an executable shell script
with --dry-run and --stop-after=N flags for iterative testing.
Fixes missing step for creating a Databricks service principal with M2M
OAuth credentials for the guest login flow. Phase 9 now reads client ID
and secret from keyring instead of using a placeholder.

Also adds DATABRICKS_ACCOUNT_ID to Phase 0 inputs.
…9 keyring reads

- Phase 8b split into Tier 1 (guest login, required) and Tier 2 (admin
  Databricks OAuth, placeholder-safe for guest-only verification)
- Documents why U2M vars don't crash the build (plain object vs okta() call)
- Phase 8d stores PREVIEW_URL and GUEST_API_SECRET in keyring
- Phase 9 loads all vars from keyring — no implicit shell carry-over
- NG-1: workspace list requires PATH arg (workspace list / --profile)
- NG-2: delete stale HOST/WORKSPACE_ID/GENIE_ONE_URL yml-edit instructions;
  replace with note that these are runtime-injected; add catalog/schema
  override guidance only when non-default
- NG-3: AGENT_APP_NAME default → firefly-openai-managed-mem-v2 (bundle hardcodes this)
- NG-4: remove stale re-assemble step from Phase 4 (wipes quickstart .env + wheels)
- NG-5/6: run bundle deploy/run from agent-build/; add resource key
  (agent_openai_agents_sdk) to bundle run command
- NG-7: pass --memory-store to setup_memory_store.py
- NG-8: UC permissions → PATCH /api/2.1/unity-catalog/permissions/catalog/{name}
  (POST is not a valid method)
- NG-9: warehouses list -o json returns bare array (not {"warehouses":[]}); use
  /api/2.0/permissions/warehouses (not preview/sql endpoint, which returns 405)
- NG-10: SP create output uses SCIM camelCase applicationId (not application_id)
- NG-11/14: replace account-API/curl/UI-fallback block with
  service-principal-secrets-proxy create (workspace-level, no account admin needed)
- NG-12: DB_URL already stored in keyring; ensure drizzle step reads it
- NG-13: add pnpm install before drizzle-kit push (node_modules absent by default)
Also fix unclosed code block fences in Phases 3a, 3d, 4, 5.
GitHub strips cursor:// links from README markdown, so the badge opened
the shields.io image instead of launching Cursor. Route through
cursor.com/redirect so the deeplink handoff works from github.com.
…adge

cursor.com/redirect does not exist (404). GitHub also strips cursor://
links, so use Cursor's documented HTTPS prompt handoff to open the
genie-agent bootstrap workflow.
Add an explicit STOP gate so agents must confirm every required input
before Phase 1 (including read-only probes). Move [ASK] items to a
checklist with [ASK — REQUIRED, BLOCKING] markers. Default clone
directory is now the current working directory instead of ~/Projects/firefly.
- Phase 6c: check UC tables only; defer seeding to end-of-runbook guidance
- Phase 6b: grant guest SP CAN_USE on warehouse and agent app
- End sections: next steps when no UC data; agent drafts and files GitHub issues
Centralize solution metadata, add /docs/solutions hub, and link Agent Panel
from architecture pages, cross-links, nav, homepage, and README so it is no
longer missing from the lakehouse-apps-proxy documentation grid.
neonctl projects create --output json returns {"project":{"id":...}}, but
Phase 7 read json['id'] and got an empty/wrong value, breaking downstream
DATABASE_URL setup. Read project.id with a flat id fallback for older
neonctl versions.

Fixes #16
Phase 8d captured the preview URL with `grep -E 'https://' | tail -1`, which
matched Vercel's trailing "To change the domain… https://vercel.com/…" help
line instead of the deployment URL. Phase 9 guest API calls then hit a
malformed PREVIEW_URL. Anchor the match to the .vercel.app host.

Fixes #15
…al edit

Phase 3b only warned the user to hand-edit agent-build/databricks.yml when
UC_CATALOG/UC_SCHEMA differed from the workspace/default bundle defaults;
skipping the edit broke the deploy or misrouted Genie context. The bundle
already exposes catalog/schema as variables (agent/databricks.yml header),
and DATABRICKS_MEMORY_STORE is the template
"${var.catalog}.${var.schema}.${var.memory_store_name}". Thread the Phase 0
inputs through `bundle deploy/run --var` so no file mutation is needed and
the memory store resolves automatically.

Fixes #12
Phase 1c called `vercel login` without checking the CLI was present, so a
fresh machine failed at the OAuth step. Add an install check (npm i -g vercel)
mirroring the neonctl/pnpm pattern, so no CLI is assumed pre-installed.

Fixes #8
vercel link (Phase 8) needs a repo the deployer can write to, but the clone
points at databrickslabs/firefly (no external write access). After cloning,
create or reuse a user-owned fork and push genie-agent to it, so Vercel's Git
integration links to that remote. Honors SKIP_GITHUB_PUSH and GITHUB_FORK.

Fixes #17
On machines behind a corporate MITM proxy (Zscaler etc.), quickstart.py and
the Databricks SDK fail TLS verification and hang until timeout. Phase 0 now
accepts a combined PEM (via prompt, or preset TLS_PEM_PATH for CI) and exports
REQUESTS_CA_BUNDLE / SSL_CERT_FILE / NODE_EXTRA_CA_CERTS for the session.

Fixes #13
… via npm (#9)

Reorders Phase 1 so pnpm is installed before the CLIs and frontend build
that depend on it, then Vercel, GitHub, Neon, Databricks.

- pnpm moved to 1a (was 1d)
- GitHub CLI: auto-install from the official release archive into
  $HOME/bin when absent (no Homebrew dependency), then auth. Deviates
  from the issue's `brew install gh` suggestion because the target
  audience is not guaranteed to have Homebrew.
- Neon CLI: `npm install -g neonctl` instead of `brew install neonctl`
  for the same reason.
- Install steps are DRY_RUN-guarded so --dry-run never hits the network.
zuckerberg-db and others added 30 commits July 25, 2026 09:11
…y once (#19)

fix(bootstrap): resolve the Vercel origin from the API, deploy once (#19)
…rd regression (#19)

test(bootstrap): guard Phase 8 origin resolution against a third regression (#19)
The first genuinely fresh target (no pre-installed CLIs, no pre-existing Neon
project) reached Phase 9 with six phases failed. Root causes, and what changed:

BOOTSTRAP.md called store_secret / read_secret and defined neither. The only
implementation lived in bootstrap.sh and took (VARNAME, _, KEY) while every
runbook call site used $(read_secret KEY), so copying it across did not work
either. An agent following the runbook wrote its own with bash-only ${!key} and
got "bad substitution" under the macOS default shell.

Phase 1b/1e "installed" the Databricks and GitHub CLIs with a comment and `: ;`.
Invisible on any machine that already had them.

Phase 7 parsed `neonctl projects create` output for a top-level id; the id is
nested under project. bootstrap.sh was fixed on 2026-07-21; the markdown was
rewritten that evening and kept the broken accessor. Because create succeeds
server-side before the parse fails, the retry orphaned a second project.

Phase 3 died on a 503 from public PyPI with no index configured, cascading to
Phases 4-6 and 9, and surfacing only as a raw uv stack trace.

Changes:

- Add scripts/lib/runbook.sh: state helpers, CLI installers, CLI-output parsers,
  and the Phase 3e/4 assertions. One implementation, sourced by both the runbook
  and bootstrap.sh, so they cannot drift. read_secret is one-argument and writes
  to stdout, matching the documented contract; no ${!key}, so it works in zsh.
- Add firefly_preflight_pypi_index: name the cause at Phase 0a and gate Phase 3a
  on it. It reports the two knobs and picks neither -- hardcoding a mirror would
  also stamp that host into any regenerated uv.lock.
- Resolve the Neon project id by re-listing after create instead of parsing the
  create response, and fail closed on an empty id.
- Refuse to deploy a bundle still holding the placeholder experiment id, so the
  failure names Phase 3a instead of returning "Node ID ... does not exist".
- Replace Phase 3e's greps with check_sync_exclude_rules. The old greps matched
  the explanatory comment, so bootstrap.sh failed a correct config.
- Move the constraints.txt export from assemble_agent.sh to vendor_wheels.sh.
  assemble deletes agent-build/ and the template ships no uv.lock, so on a fresh
  clone that branch could only ever warn; vendor_wheels.sh is where uv lock runs.
- Generalise check-runbook-invariants.sh from string matches to structure: every
  sourced lib is bash+zsh-checked, every helper the runbook calls must resolve,
  every bash block must parse in both shells, and no brace block may be a stub.
  All three new checks are negative-tested.
- Add scripts/test-runbook-lib.sh (14 assertions, hermetic).
- Drop the 1a-1f labels from bootstrap.sh Phase 1: its install order differs from
  the runbook's, so "1b" named a different tool in each file.
The 2026-07-25 validation run surfaced a second way to reach Phase 4 with an
unpatched bundle, distinct from quickstart failing outright: the agent's shell
wrapper backgrounded quickstart at exactly 30.0s and returned its own exit 0.
The agent read that 0, treated the phase as done, and moved on while Lakebase
provisioning was still in flight.

Phase 4's guard caught it, but the failure belongs to the phase that owns it.
Assert completion at the end of 3a too, and say plainly in the runbook that a
zero exit from a wrapper is not evidence quickstart finished -- the last line
before the long pause is "Creating new Lakebase", which is the start of
provisioning, not the end.
…-20260725

fix(bootstrap): close the fresh-install defects found on 2026-07-25
Vercel token sourcing had drifted. scripts/bootstrap.sh has preferred
$VERCEL_TOKEN over the CLI's auth.json since someone noted that "a
single hardcoded path made it a silent 404 or a hard stop for anyone
storing auth elsewhere", but BOOTSTRAP.md kept the hardcoded lookup, so
the fix never reached anyone following the doc. auth.json only exists
after an interactive `vercel login`, which makes the documented path fail
outright in CI and for token-based setups.

GUEST_API_SECRET had no recovery story. read_secret only works inside the
session that minted it, and `vercel env pull` looks like the obvious
fallback but returns a redacted ~11-character placeholder. The runbook
stored that as if it were the value and every guest-login call then
failed 401 with nothing explaining why. openssl rand -hex 64 is always
128 characters, so a length check turns the silent 401 into a remint.

The repo's own guards could not run on a stock Mac. Eleven `rg` calls in
check-runbook-invariants.sh and one in test-bootstrap-phase1a.sh made
both abort where ripgrep is absent, so the checks silently validated
nothing in exactly the clean-room they exist to protect. Converted to
grep -E and sed -nE; verified all six guards pass with and without
ripgrep on PATH.

Fixes two guard defects found while doing it: the stub detector read
${#VAR} as a comment-only brace block and rejected correct shell, and
the new GUEST_API_SECRET check matched its own diagnostic echo rather
than the comparison, so it passed with the validation deleted. Both new
checks are verified to fail when their fix is reverted.
…his app (#81)

Closes #78

When a workspace enforces an IP access list, the deployed frontend is a
third party to it: Vercel calls Databricks from its own egress addresses,
which are not the customer's office or VPN ranges. Databricks refuses with
a bare 403 and the dashboard renders "Failed to load SQL warehouses -
Databricks API error: Forbidden", which reads as a broken deployment.

Both wrappers built that message from response.statusText alone and threw
away the header where Databricks explains itself:

  X-Databricks-Reason-Phrase: Source IP address: 18.209.31.242 is blocked
  by Databricks IP ACL for workspace: 7474652207956472

Now the reason is surfaced, and the IP-ACL case is named explicitly so
nobody debugs application code for an enterprise control. Judged on the
user-facing `error` field: `details` already carried the reason, but the UI
shows `error`.

BOOTSTRAP.md gains a pre-flight warning, because the useful moment to learn
this is before deploying, not while staring at a 403.

Verified against a live blocked deployment: the pre-fix response scores
"unattributed", the post-fix shape scores "attributed". Invariant 10 guards
it and is verified to fail when the header read is removed - it initially
passed on its own comment, which is why it now matches the actual
headers.get call.
Closes #79

Guest links are single-use and expire ~10 minutes after minting; a refresh
or back-navigation also consumes one. Phase 9 mints one roughly 20 minutes
into a run, so it is usually dead before a human clicks it - and at that
point the operator has a working deployment and no way in.

The summary previously offered only prose: re-run the three "Guest login"
POSTs above. That is not a recovery path. /api/guest/users needs an spnId,
which needs a workspace record first, so following it means scrolling back
and re-deriving two ids from intermediate responses.

scripts/new-guest-link.sh replays exactly those three calls from state.env
and prints a fresh URL in about two seconds. --open matters: copy-pasting
into a tab that already has the app loaded can consume the link before it
is read. It also rejects a short GUEST_API_SECRET up front, since the
redacted placeholder from `vercel env pull` 401s every call.

Follow-on to #59, which fixed the message on a dead link. This provides a
live one.

Verified against a live deployment: the script mints a link and that link
verifies HTTP 200. Invariant 11 fails both when the script is removed and
when the runbook stops referencing it.
…run bug (#19) (#76)

The hermetic suite added in #75 checks the shape of Phase 8 — origin read not
constructed, no alias pinned, deploy explicitly --prod. It cannot catch a behavioural
change on Vercel's side, and it is not what found the re-run regression. This is.

It deploys for real and reads BETTER_AUTH_URL back out of the running deployment via a
minimal Next.js fixture, then does it a second time against the SAME project. That
second run is the whole point: a bare `vercel deploy` is production only on a project's
FIRST deploy, so a create-and-destroy test — every run a first run — structurally cannot
see the bug where run two points BETTER_AUTH_URL at a per-deployment preview host.

Both of #19's earlier fixes passed suites with exactly that blind spot. The first was
validated with a unique project name, so the collision never happened; the second with a
fresh project per run, so the re-run never happened.

Self-contained: the three SSH helpers are inlined, so it carries no dependency on
out-of-tree scratch tooling, and HOST_REPO defaults to the checkout it lives in.
Preconditions gate on behaviour (deploys --prod, pins no alias) rather than on a
particular implementation's step wording, so a competing fix can still be measured
instead of rejected.

Cannot run in Actions — needs a macOS host, Tart and a Vercel login — so it lands under
tests/manual with a README covering setup, the knobs, and how to investigate a failure.
Note that repository Actions are currently disabled, so the hermetic suite is not
running automatically either; both are manual gates today.

Verified: passes both stages against merged genie-agent
(next-demo -> next-demo-livid-alpha.vercel.app, Vercel CLI 56.3.1), and the precondition
correctly refuses the pre-#71 alias-pinning implementation.
…hase 0 (#83) (#84)

* feat(bootstrap): offer sample-data seeding and Genie space setup at Phase 0 (#83)

A fresh workspace finished bootstrap "successfully" with an empty
$UC_CATALOG.$UC_SCHEMA, so Genie could not answer a single data question. Phase 6c
detected that and the runbook ended with homework. The user was never offered a
way to fix it during the run: Phase 6c said "do not create tables on the user's
behalf" and the closing section said "unless the user explicitly asks", but no
Phase 0 input mentioned seeding, so that escape hatch was unreachable.

Phase 0 now asks. SEED_SAMPLE_DATA (default yes) copies samples.wanderbricks into
an empty schema. GENIE_SPACE_IDS (default None) lets a user point the agent at
spaces they already curated, and when they do, GRANT_GUEST_SPACE_ACCESS offers the
guest SP CAN_RUN on those spaces plus SELECT on the tables they reference.
CREATE_GENIE_SPACE (default yes) otherwise builds one over the seeded data.

Phase 6c acts on those answers via scripts/genie-data-setup.sh, which
scripts/bootstrap.sh calls identically — the runner had no Phase 6c at all, so the
automated path could finish with no data. Both surfaces now call one
implementation, for the same reason scripts/lib/runbook.sh exists.

Safety rules, all guarded:
  * CREATE TABLE IF NOT EXISTS only. A schema holding tables we did not create
    reports already-populated and is left alone.
  * A Genie space is "ours" only on an exact title match, so a re-run reuses it
    and a foreign space covering the same schema is never patched or trashed.
    User-supplied ids are the sole exception.
  * GENIE_MCP_MODE and GENIE_SPACE_ID move together or not at all: agent.py
    raises ValueError on space + empty id and the app fails to boot. Space mode is
    only claimed after the space is confirmed readable.

Also fixes the attribution link, which pointed at Genie One even when the agent
was scoped to a space — the destination never saw the question. It now resolves to
/genie/rooms/<id> and the UI labels it from the path.

Invariants 12-14 cover the mode/id coupling, seeding staying a real user choice in
both surfaces, and Phase 6c parity. Each was verified to fail when its fix is
reverted. test-bootstrap-phase1a.sh gains the three new prompts, which it caught
itself by mis-assigning a later answer.

* fix(bundle): an empty GENIE_SPACE_ID default breaks the Phase 4 deploy (#83)

With `genie_space_id: ""`, the bundle renders the app env entry as
`{"name": "GENIE_SPACE_ID"}` with no `value` key, and the Apps API refuses the
whole deploy:

  Must specify environment variable source using either value or valueFrom

That breaks Phase 4 on the DEFAULT path — Genie One, no space — which is what
most deployments do. It hit six of nine E2E runs and every one still scored
clean, because the agent following the runbook diagnosed it, passed a
placeholder, and the app ended up RUNNING. E2E-HEALTH only asserts the end
state, so an agent that repairs the runbook hides the defect from it.

The variable now defaults to the non-empty sentinel "none", which the Apps API
accepts, and agent.py treats "none"/"null"/"" as unset so
GENIE_MCP_MODE=space still raises rather than querying a bogus space. The
coupling guarantee is unchanged; only where emptiness is detected has moved.

Invariant 12 now rejects an empty default, verified to fail when reintroduced.

* fix(bootstrap): repair the six runbook gaps the E2E agent kept reporting

Every one of these was reported by the agent, in its own RUNBOOK GAPS section,
run after run. Nothing read them. Frequencies are across 12 E2E transcripts.

Phase 5 Managed Memory preflight (9 runs). The preview is enabled per-workspace
by Databricks; without it the phase failed with NotImplemented and no
explanation. It now checks first, says so plainly, and lets the rest of the
bootstrap finish — only cross-session memory is lost.

CA bundle written to /proxy-ca-bundle.pem (5 runs). init_inputs_dir read
INPUTS_DIR at call time while the default was applied only at source time, so a
caller that exported it empty got an unwritable root path — which then broke
curl, the installers, and uv's CPython download with UnknownIssuer.

assert_bundle_quickstart_ran false-failed (3 runs). Same root cause: an empty
FIREFLY_PLACEHOLDER_EXPERIMENT_ID makes the check `grep -q ""`, which matches
every line, so a bundle quickstart had already rewritten correctly was declared
broken and the reader was sent back to Phase 3a.

The UC grants could not be run as written (2 runs). They were commented-out SQL
telling the reader to open a warehouse session; a backquoted principal inside
`--json "..."` is command substitution, so nobody could paste them and the
grants were quietly skipped. They now execute through firefly_sql.

A panicking deploy read as success (2 runs). CLI v1.9.0 can nil-deref on bundle
deploy and still exit 0, so later phases failed opaquely on JSON-parsing an
error string. Phase 4 now asserts the app exists, and names stale bundle state
as a cause.

Phase 3c assumed the schema existed, and Phase 8 accepted an empty
DATABRICKS_AGENT_APP_URL — deploying a frontend pointed at nothing, which reads
as a frontend bug rather than a failed Phase 4.

Invariants 15-19 guard all of it, each verified to fail when its fix is
reverted. 15 exists because this suite reported "All runbook invariants hold"
while bootstrap.sh had an unterminated `if`: the one file guaranteed to be
executed was the one file never parsed.

* fix(bootstrap): close the last two shell-state gaps

GUEST_API_SECRET (and the other Phase 8 secrets) were minted at the top of the
phase but only written to state.env at 8d. A shell that died in between left
Vercel holding a value the local side no longer knew — and `vercel env pull`
returns an 11-character redacted placeholder, not the secret, so the only
recovery was a remint. They are now stored at mint time.

V_TOKEN / V_ORG / V_PROJ were derived in Phase 8a and reused in 8e. Run 8e in a
fresh shell and the curl sends "Authorization: Bearer " with nothing after it,
so the step reports that production does not serve $APP_ORIGIN for a deployment
that is entirely correct — a false failure on the exact check added for #19.

Both phases now call firefly_vercel_context, so there is one derivation and a
new shell cannot change the answer. Invariant 8 was checking for the
$VERCEL_TOKEN preference as literal text in BOOTSTRAP.md; it now follows the
logic into the library, and still fails if the preference is dropped there.
…xisted for

Caught by the agent on the very run that shipped it: "Phase 5's preflight
reported the memory preview as `on`, but setup returned NotImplemented."

The probe called `/api/2.0/memory-stores`, which returns `Error: Not Found` on
this workspace — that matches none of `not enabled|NotImplemented|501`, so the
check fell through to "on" every time. The path is wrong, so the probe could
never have worked; it only ever added a confident wrong answer in front of the
same failure.

Phase 5 now runs setup_memory_store.py and classifies what comes back: success,
a missing preview (skip with an explanation, bootstrap continues), or a real
error (surface it). The operation is the only reliable signal for whether the
operation will work.

Invariant 18 was checking for the preflight variable, so it happily passed a
probe that never functioned. It now requires both surfaces to classify the
failure and forbids the broken probe from returning — verified to fail when it
is reintroduced.
…roblem

Reported from a real fresh-workspace run. Phase 6c said samples.wanderbricks was
"not readable from this workspace", which reads as a permanent entitlement
problem. It was a race:

  12:04:46Z  Phase 4 finished
  ~12:05:00Z Phase 6c probed samples.wanderbricks  -> empty
  12:05:39Z  the samples.wanderbricks schema was created
  12:05:41Z  its first tables appeared

A re-run eight minutes later seeded all 16 tables. The samples catalog is
provisioned asynchronously on a new workspace and this phase arrived ~40 seconds
early.

Two defects made that unreadable. The probe used `2>/dev/null`, discarding the
server's own message, and then reported ONE status for four different causes:
schema absent, schema empty, real permission denial, and warehouse/SQL error. So
the output could not distinguish "wait a minute" from "you lack SELECT" — and it
picked the wording that sends people hunting for entitlements they already have.

Phase 6c now keeps stderr and classifies: source-not-ready, source-empty,
source-denied, source-error, each with the server's message. It also waits up to
FIREFLY_SEED_SOURCE_WAIT (180s) for the source to appear, which would have made
the reported run succeed outright — while breaking out immediately on a denial,
since that will never resolve by waiting.

"Next steps — no UC data" no longer applies to the not-ready cases: sending
someone to fix grants when the data is merely late is the same wrong turn in
document form.

Invariant 20 requires the classification and the wait, and fails if the
discarded-stderr probe returns.
The link was wrong in two independent ways, and each is enough on its own.

Its audience is GUEST users. They reach the app through a tokenised one-time
login and have no Databricks workspace access at all, so a workspace URL is dead
for every one of them — it renders as an inviting external link and goes nowhere
they can follow.

And it said "Genie One" while GENIE_MCP_MODE now defaults to a space, so it named
a backend that never saw the question. Labelling it from the URL path fixed the
wording without touching the real problem: a dead link with the right label is
still a dead link.

Attribution is plain text now — "Powered by Genie", no href. Removed with it:
genieOneUrl through /api/config, the context field, four call sites in
message.tsx and chat.tsx, buildGenieOneUrl, and the GENIE_ONE_URL env var, which
is gone from the bundle comments, the runbook, the runner's note, and the
user-facing env table in src/app/docs/solutions/agent/page.tsx.

Genie's own citation links are untouched — those are answer content, not
attribution, though they share the guest-access problem and are worth a separate
look.

Invariant 21 fails if genieOneUrl, a hand-rebuilt /one?o= link, or the env var
returns.
…d earlier

Found by the E2E agent on the first run after the gap prompt started asking about
detours and misleading output, not just workarounds. It reported:

  uv install skips checksum verification on macOS: installer prints
  "skipping sha256 checksum verification (it requires the 'sha256sum' command)"
  — macOS has shasum

That is a supply-chain check quietly declining to run. The astral.sh installer
verifies its download with sha256sum; macOS 14.8.7 (the clean test VM, and plenty
of user machines) has no sha256sum anywhere, only shasum. So the installer says it
is skipping verification and installs the binary anyway, with the message buried
mid-install. This is the DEFAULT path, not an edge case.

Worth recording why it took a VM to find: this laptop is on a newer macOS that
ships /sbin/sha256sum, so the problem is invisible here. The first fix I wrote
appeared to work locally because the helper correctly no-opped — it never
exercised the shim at all. Confirmed against the VM (macOS 14.8.7: absent) and
then re-tested with /sbin excluded from PATH.

firefly_ensure_sha256sum installs a two-line shim delegating to `shasum -a 256`,
which is byte-identical for both hashing and -c check mode (verified), and no-ops
where sha256sum already exists. It restores the installer's own verification
rather than working around it.

Invariant 22 requires the shim to precede the install in both surfaces — after it
would change nothing — and fails if it is moved or removed.
…reated

Passing --app-name for an app that already exists makes quickstart bind Lakebase
from that app's existing configuration and ignore --lakebase-create-new. The
requested project is never created, Phase 3a still reports PASS, and every later
summary prints $LAKEBASE_NAME — a resource that does not exist.

Caught concretely across two passes of one run: pass 1 created
firefly-lb-0727083127, pass 2 asked for firefly-lb-0727090103, quickstart bound
the first, and the summary named the second. Anyone going to look for the name
they were given finds nothing there.

The bound project is discoverable — quickstart writes agent-build/.env and patches
databricks.yml, and both carry the endpoint path
projects/<name>/branches/<name>-branch/... So Phase 3a now reads the name that
was actually bound, warns when it differs from the request, explains why (an
existing app wins), and reports the bound name from then on. Reality wins over the
request, out loud.

Same class as the "not readable from this workspace" message: output that is
confident and points at the wrong thing. Invariant 23 requires the reconcile in
both surfaces and requires it to run AFTER quickstart, where there is something to
read.
Both found by the E2E agent on iteration 1, and both are the class that only
shows up when you ask "did any message point you at the wrong cause".

pnpm advertises `Update available! 10.34.5 -> 12.0.0-alpha.16` after every
install — the EXACT alpha the pin exists to avoid, because it ignores
onlyBuiltDependencies and fails with ERR_PNPM_IGNORED_BUILDS. A reader who
follows the tool's own advice breaks their build. That is worse than noise: the
advice is the failure. NPM_CONFIG_UPDATE_NOTIFIER=false is now exported alongside
the pin (one place, since the pin is the single source of truth), and the runbook
says to ignore the banner if it appears in a shell that skipped Phase 0a.

`neonctl me` reports `Projects Limit: 0`, which reads as "you cannot create
projects". It is an unset quota field; create and reuse both work with it at 0,
as Phase 7 goes on to prove. Documented so nobody goes hunting for a plan upgrade
that is not needed.

Invariant 24 requires the notifier suppression and the runbook note.

Iteration 1 also showed 0 RECURRED gaps: all 13 fixes marked fixed in the harness
registry held on a live run, including the uv checksum shim and the Lakebase
reconcile.
…in advance

Iteration 1 pass 2 found both holes in the fix I shipped an hour earlier, which is
the loop working as intended.

The reconciled name was only `export`ed in the current shell. Non-secret answers
are re-sourced from inputs.env on a resumed run or a fresh shell, so the
requested-but-never-created name came straight back into the summary. It is now
persisted via firefly_store_input — added to the shared library because
store_input lived only in bootstrap.sh, so the runbook had no way to persist
anything. That asymmetry is the same drift the library exists to prevent.

And the create path still read as though it would provision the named instance,
right up until the post-hoc warning. If the app already exists, its binding is
going to win and that is knowable BEFORE quickstart runs, so
firefly_warn_existing_app_wins says so up front. Detecting a surprise after the
fact is better than not detecting it; not being surprised is better than both.

Invariant 23 now requires the reconcile, the persistence, and the advance warning.
Each verified to fail when removed.
Seven separate E2E agents flagged this, each rediscovering it, because the runbook
never mentioned it once:

  Error: Failed to get package info: Error: Failed to fetch dist-tags from npm

It is the Vercel CLI's own background update check reaching public npm, which a
corporate proxy blocks. The command then succeeds. But it goes to stderr, carries
an "Error:" prefix, and prints BEFORE the real output — so it reads like an auth or
install failure, and it has stopped readers mid-phase.

I had it marked "accepted" in the harness triage registry, which quietly meant *I*
had accepted it while the reader was still being ambushed by it every run. That is
not the same thing, and seven reports were the evidence.

Now documented at Phase 1d where vercel is first used, with the operative rule:
judge vercel by its exit code and its output, never by that line.

Also exports VERCEL_TELEMETRY_DISABLED=1 as best-effort suppression, marked
UNVERIFIED in the code — the failure only reproduces on a proxied network, so
whether it silences the message is not something I have confirmed. The note is the
part to rely on.

Invariant 25 fails if the explanation disappears.
…ing LAKEBASE_NAME as settled

Iteration 2 pass 2. The agent confirmed the Lakebase reconcile now "warned and
reconciled correctly", and pointed at what was left.

The Phase 0 input table still presented LAKEBASE_NAME as the canonical value, so a
reader carries the requested name in their head until 3a quietly replaces it. It is
now labelled a request that an existing app's binding overrides — in both the ask
and the defaults table.

And the IP allowlist was only reported at Phase 9. Phases 1-8 all succeed against a
workspace that has one enabled: the app deploys, the frontend deploys, guest login
works, and then every Databricks data call from Vercel 403s. Meeting that after
everything looks fine is how it gets read as an application bug — which is the
defect #78 already exists to prevent. New Phase 0b checks it in one command, before
any of it is built.
…there

Iteration 3 caught a defect I introduced in iteration 2. I moved the IP-allowlist
check to Phase 0 so a reader would meet it before building anything — and it calls
`databricks`, which Phase 1b installs. It could not run as written; the E2E agent
had to defer it past firefly_install_databricks_cli itself.

Early placement is worthless if the step cannot execute there. It now sits at the
end of Phase 1b, the first point where the CLI exists AND is authenticated, which
is still before every phase that builds anything.

Invariant 26 fails if any runbook step invokes the databricks CLI before Phase 1b
installs it — the general form of the mistake, not just this instance.
The agent's fuller diagnosis of the Phase 0b ordering bug named a second flaw that
survived my fix:

  databricks api get … 2>/dev/null yields empty stdin; the Python one-liner raises
  JSONDecodeError: Expecting value. That reads like a bad/empty API response or
  auth failure; the actual cause is simply that the CLI is not installed.

That shape — discard stderr, pipe into a parser that assumes success — converts
every failure into the same misleading message. I wrote it three separate times
today: the seed-source probe, the Phase 5 preflight, and this allowlist check.

The allowlist check now keeps stderr and says which of "no response", "the API said
X", or a real answer it got. Invariant 27 forbids the shape in general, and on its
first run it found TWO more pre-existing instances I had not touched — the Phase 6b
service-principal lookup and the Phase 7 Neon org id — where a failed call became a
traceback instead of "no match". Both hardened.

Written the invariant before, so the pattern cannot come back a fourth time.
… ran

A trial workspace answers the ip-access-lists API with "IP access list is not
available in the pricing tier of this workspace". Phase 1b surfaced that
correctly. Phase 9 discarded stderr, failed to parse, and printed

    ok: no enabled IP allowlist on this workspace - the app can reach it

Same API, opposite conclusion, and the wrong one was a confident all-clear on
the control that decides whether the data plane works at all -- printed in the
phase people read when something has already gone wrong.

Both phases now call one helper, firefly_ip_allowlist_status, which returns
three outcomes rather than two: none, enabled:<labels>, or unknown:<reason>.
"Could not determine" is a distinct answer from "there is none", so Phase 9
now says so and points at itself as the first thing to re-check on a later 403.

The existing invariant missed this in two ways, both now covered: the discard
and the parse were separate statements, and `except: raise SystemExit` counted
as a guard while printing nothing -- which is what produced the empty string
the caller read as "none". A handler that exits silently does not prevent a
wrong answer, it manufactures one. Empty fallbacks that feed a self-correcting
action rather than a claim are still allowed, but must now declare themselves
with SAFE-EMPTY and say why.

Found by the first fresh-trial run; a pricing tier is a condition the reusable
test workspace cannot produce.
…failure

Follow-up to the previous commit, from the same trial workspace. Collapsing the
pricing-tier response into "could not check the IP allowlist. The API said:
Error: ..." is accurate but reads like expired auth, and sends the reader after
a problem that does not exist. The tier simply has no IP-allowlist feature, so
none can be enabled and the data-plane risk the check exists for cannot apply.

firefly_ip_allowlist_status now distinguishes that from a real failure with a
fourth outcome, unavailable:<reason>, and both phases report it as reassurance.
A genuine failure (expired token, no response) still reads as unknown and still
refuses to imply safety.

Also: Phase 0a's confirmation greps the two CA vars it omitted. The bridge sets
CURL_CA_BUNDLE and REQUESTS_CA_BUNDLE and Phase 9's smoke tests depend on the
first, but the documented `env | grep` listed neither, so a reader could not
verify the trust var the later phases rely on. Invariant 28 now derives the
expected set from corp-network.sh rather than restating it, so adding an export
cannot silently outrun the confirmation step.
…e 0 claims to

BOOTSTRAP.md told readers those dirs "are added to PATH in Phase 0", the comment
inside firefly_bridge_corp_network said "on PATH from Phase 0 onward", and Phase
0a promised that re-sourcing the helpers is enough to restore a fresh shell. All
three were false. The function only mkdir'd the directories; the export lived in
scripts/bootstrap.sh, which a reader following the runbook by hand never runs.

The cost landed after Phase 1: databricks, uv and gh were not findable in any new
shell, and the reader had to invent `export PATH="$HOME/bin:$HOME/.local/bin:$PATH"`
at the top of each one. Two independent runs reported it, the second calling it an
undocumented workaround.

The export now lives in the sourced helper, prepended idempotently because the
file is re-sourced once per shell across ten phases. Invariant 29 proves the claim
against the helper in a fresh bash and zsh, so a claim that only holds for the
runner cannot pass again.
… deleted"

With --app-name, quickstart.py answers an app that has not been created yet with

    Could not fetch app details: App with name '...' does not exist or is deleted

and then suggests `databricks bundle deployment bind` / `bundle deploy`. On a first
run nothing is wrong: Phase 4 creates the app and no bind is needed. But the message
reads as a recovery path for an app someone deleted, and a run reported following it.

firefly_warn_existing_app_wins already pre-empted the opposite case, where an existing
app's Lakebase binding silently wins. It now covers both directions, so the reader is
told which way the run will go before quickstart's own output has to be interpreted.
Invariant 30 guards the pre-empt and specifically its absent-app branch, since a
warning that only fires when the app exists would leave this exactly as it was.
…as fixing

Self-inflicted, and worse than the bug it replaced. firefly_ip_allowlist_status did

    raw="$(databricks api get /api/2.0/ip-access-lists --profile "$prof" 2>&1)"

On a tier without the feature the CLI exits 1, and an assignment from a failing
command substitution aborts the shell under `set -e` -- in bash as well as zsh, not
just zsh as first reported. So the branch written to handle that tier gracefully
instead stopped Phase 1b and Phase 9 dead on exactly the workspaces that reach it.
A run had to restart Phase 1 mid-phase (1a-1b done, 1c-1f re-run), take a second
pass at Phase 9, and wrap the call in an undocumented `set +e` both times. Printing
a wrong "ok" was less costly than this.

firefly_sql had the same shape: `out="$(...)"; rc=$?` with a full rc check below
that errexit never reached, so its own diagnostic was unreachable in precisely the
case it exists for. Four detection probes in corp-network.sh -- where a non-zero
exit is the normal "not configured" answer -- had it too.

Invariant 31 asserts each helper still EMITS its diagnostic under `set -e` in both
shells. Asserting mere survival cannot fail: calling it as `fn || true` suppresses
errexit for the whole function body, which is how the first version of this
invariant passed against code I had deliberately broken.
…loys

Phase 6c redeploys the app with genie_mcp_mode=space immediately after Phase 4
deployed it, and the Apps API refuses the overlap:

    400 Cannot update app ... as there is a pending deployment in progress for
        less than 20 minutes

The run recovered because `bundle run` waits, but the error arrived mid-phase next
to an unrelated "WAL recovery failed" and read as a hard failure in a phase that had
otherwise worked. firefly_wait_app_deploy_settled polls until the active deployment
is terminal, then the deploy proceeds; on timeout it still attempts the deploy,
because a stuck poll must not be the thing that stops the phase. Invariant 32
requires the wait to appear BEFORE the space-mode deploy in both the runbook and the
runner, so the race cannot come back for whichever one was missed.

store_secret dropped only `^export KEY=`, so a legacy `KEY=...` line or one with
leading whitespace survived and state.env held two assignments. Sourcing takes the
last, which is why Phase 9's summary printed a stale GENIE_SPACE_ID while the
deployment had used the correct one. It now drops every form, and read_secret says
so when it still finds duplicates instead of returning one of two answers silently.

That warning goes straight to stderr rather than through warn(), which echoes to
stdout: every caller reads VALUE="$(read_secret KEY)", so routing it through warn
folded the warning text into the value. Invariant 33 catches that, and did -- it
failed against this change before the printf was made explicit.

Also documents that a partly populated schema is normal and triggers incremental
completion rather than a seed from empty.
… a shell trap

The runbook only ever said that bootstrap.sh saves answers to inputs.env, and showed
nothing to anyone working the phases by hand. So a run invented the loop, and what it
reached for was

    for k in ...; do firefly_store_input "$k" "${!k}"; done

${!k} is bash-only indirect expansion and raises `bad substitution` under zsh, the
default shell on macOS. That exact hazard was already documented on read_secret, which
is the point: leaving the safe form unwritten is what let it recur somewhere new.

Phase 0 now shows firefly_store_inputs, which persists every [ASK] key (or a named
subset) in either shell, and says plainly not to reach for ${!k}. Invariant 34 keeps
the helper's list in step with the [ASK] rows and fails on any phase that teaches
${!k} without the warning.

The helper's own first version had a portability bug of the same family: `for k in
$keys` stores nothing under zsh, which does not word-split unquoted parameters. It
uses positional parameters now. Testing both shells is what caught it -- bash alone
reported all 15 keys stored.
Phase 6c created space 01f18a78…, round-tripped 16 tables through it and granted
CAN_RUN on it -- then a single GET failed, and it printed

    space … is not readable — staying on Genie One

cleared GENIE_SPACE_ID and shipped the app on Genie One, which guest users cannot
use. The same GET succeeded minutes later. Reads after a create are eventually
consistent, so one failure means "not yet", not "not there".

space_readable retries for up to 90s and keeps stderr, so a real cause can be named;
the old check discarded it and could only ever say "not readable" whatever went
wrong. When it does fall back it now says which space it gave up and where that space
came from, because losing a curated space is a real cost and must not read as "no
space was wanted". Invariant 35 requires the retry, the kept stderr, and the
explanation, so renaming the old single-GET check cannot pass.

Also: quickstart prints its `bundle deployment bind` suggestion whether or not the
app exists, and the earlier pre-empt only defused the app-absent path, so on the
app-exists path it still read as an actionable next step. Both paths say to ignore it.

Invariant 30 now requires both branches to PRINT that, not merely contain the words:
its first version counted any matching line, so an explanatory comment satisfied it
while the reader saw nothing -- an assertion measuring source text instead of output.
Phases 6, 6b and 6c read WAREHOUSE_ID, SP_CLIENT_ID, GUEST_SP_CLIENT_ID,
GENIE_MCP_MODE and GENIE_SPACE_ID straight out of the shell. A second pass in a fresh
terminal arrived with them empty, and not one of the resulting errors named the empty
variable:

    WAREHOUSE_ID=""       -> "No API found for 'PATCH /permissions/warehouses/'"
                             i.e. apparently a wrong API route
    GUEST_SP_CLIENT_ID="" -> "Principal: ServicePrincipalName() does not exist"
                             i.e. apparently a SCIM problem
    GENIE_MCP_MODE=""     -> the space-mode gate silently skipped, so the app never
                             received genie_space_id at all

The silent skip is the worst of the three: nothing failed, so nothing got investigated,
and the app shipped on Genie One where guest users cannot query it.

firefly_restore_phase6_context rederives each value from state.env and then from the
workspace; firefly_require refuses to build a request from an empty variable so the
message names the variable rather than the symptom. Phase 6 persists WAREHOUSE_ID for
6b and 6c instead of assuming the shell survived, and the space-mode gate now has an
else branch that states what was given up and why.

Invariant 36 checks position, not presence: BOOTSTRAP.md has several restore call sites,
so requiring merely one passed with a phase boundary still unguarded.

Also fixes invariant 33, which wrote its probe lines into the repo's real
.firefly-bootstrap/state.env on every run -- init_state_dir derives STATE_FILE from
${1:-${REPO_DIR:-$PWD}} and ignored the variable the test was setting. 45 lines of test
data had accumulated there. A test must not write where the thing it tests writes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant