Lakehouse Platform API
Integrate with the Edgent Lakehouse: push events and data in, query and search it back out — all governed by the same tenant and read-permission model the UI enforces.
Base URL: https://lakehouse.vforce360.ai/api/v1
Open the API console → — a Postman-style client over the full surface, or grab the OpenAPI 3.0 spec ↗ for Postman, Insomnia, or a client-code generator. Connecting a SQL client or BI tool instead? See the SQL connectivity guide →
A token authenticates the API. Driving a surface in a browser needs a session instead — see production verification → for the identity CI and agents use to sign in to the live product non-interactively, what it is allowed to do, and how its use is audited.
Getting started
Every request is scoped to a tenant and gated by ReBAC read permissions: you can only query, browse, and search tables you can read. Interactive access authenticates via SSO at the edge (oauth2-proxy); service and integration callers present an Authorization: Bearer token and select their tenant with X-Tenant-Id. Errors follow RFC-7807 (problem+json): every failure carries title and detail.
How tenant scoping resolves: a browser SSO session is pinned to its own tenant and cannot spoof another via a header. A token call has no browser identity, so it is scoped by X-Tenant-Id (or by a <tenant>.lakehouse.vforce360.ai host). The test harness below exposes both paths — pick bearer token + tenant to exercise a real tenant.
Try it now — the public demo tenant
demo.lakehouse.vforce360.ai serves a real tenant, publicly readable and read-only — no credentials needed. Every read example on this page works against it as-is, and the test harness below targets it by default. Query it right now:
curl -X POST https://demo.lakehouse.vforce360.ai/api/v1/compute/sql \
-H 'Content-Type: application/json' \
-d '{ "sql": "SELECT continent, year, avg_life_exp FROM demo.gold.gapminder_by_continent ORDER BY year LIMIT 10" }'curl -X POST https://demo.lakehouse.vforce360.ai/api/v1/search \
-H 'Content-Type: application/json' \
-d '{ "query": "configuration management", "mode": "keyword" }'Tip: the demo tenant’s SQL catalog is demo (browse it with GET /catalog/tree); the search corpus is a separate document set. Your own tenant exposes its own catalogs.
Writes (PUT/PATCH/DELETE, event ingestion, admin) return 403 on the demo tenant — authenticate against a provisioned tenant to integration-test writes.
Authenticate a request (production & other tenants)
Present a platform token and name the tenant. This is the service/integration path — it works from the harness below (choose bearer token + tenant), from curl, or from any HTTP client:
curl -X POST https://lakehouse.vforce360.ai/api/v1/compute/sql \
-H 'Authorization: Bearer <your-token>' \
-H 'X-Tenant-Id: acme' \
-H 'Content-Type: application/json' \
-d '{ "sql": "SELECT * FROM main.gold.orders LIMIT 10" }'Equivalently, drop X-Tenant-Id and target the tenant host directly: https://acme.lakehouse.vforce360.ai/api/v1/compute/sql. Reads are ReBAC-filtered to what the token’s identity may read.
Event ingestion (primary integration path)
Push CloudEvents 1.0 envelopes; the platform routes each event type to a bronze table and projects typed, queryable views from the payload. This is how VForce360, VForce Flow, and customer apps feed the lakehouse.
curl -X POST https://lakehouse.vforce360.ai/api/v1/events/ingest \
-H 'Authorization: Bearer <your-token>' \
-H 'X-Tenant-Id: acme' \
-H 'Content-Type: application/json' \
-d '{
"specversion": "1.0",
"id": "evt-0001",
"source": "my-app",
"type": "com.example.order.created",
"time": "2026-07-15T12:00:00Z",
"data": { "order_id": "o-42", "total": 129.5 }
}'Events of type com.example.order.created land in a table for that type; the projection glue shreds data into typed columns you can query with SQL immediately.
Query & compute
Run read-only SQL over your readable estate (fully-qualified catalog.schema.table names):
curl -X POST https://lakehouse.vforce360.ai/api/v1/compute/sql \
-H 'Content-Type: application/json' \
-d '{ "sql": "SELECT continent, year, avg_life_exp FROM demo.gold.gapminder_by_continent ORDER BY year LIMIT 10" }'Large scans can use /compute/sql/distributed (Spark). Browse what you can read with GET /catalog/tree.
Search
Document search supports modes: default semantic (ranked by meaning) or "mode": "keyword" (pure lexical — every term must appear; works without the embedding model).
curl -X POST https://lakehouse.vforce360.ai/api/v1/search \
-H 'Content-Type: application/json' \
-d '{ "query": "phosphate rock exports", "mode": "keyword", "k": 10 }'Mnemo
Natural language → governed SQL, grounded strictly in tables the caller can read. The generated SQL is returned (never executed server-side); run it through /compute/sql.
curl -X POST https://lakehouse.vforce360.ai/api/v1/ai/query \
-H 'Content-Type: application/json' \
-d '{ "question": "how many pages per document type?" }'API reference & test harness
Generated from the live routing table — every endpoint the deployment serves is listed (… endpoints), and CI fails if the curated annotations drift from the code. Set the target, auth, and tenant once below; then click any row to fill path parameters, edit the body, and send the request against the live API.
Custom headers
Loading reference…
SQL connectivity
Connect DBeaver, DataGrip, psql, ODBC tools, and BI platforms directly to the lakehouse. Up to two SQL planes are served — a Databricks-parity Hive/Thrift endpoint and a PostgreSQL wire-protocol endpoint — plus Superset for dashboarding via SSO. The coordinates below are the ones this deployment publishes; a plane this environment does not run says so rather than showing an endpoint you cannot reach. Both SQL planes authenticate with a personal access token and enforce the same tenant and read-permission model as the web catalog: you only ever see tables you are granted.
UAT test instructions → — numbered verification steps per plane, with expected results and a troubleshooting table.
Personal access tokens
Every SQL connection authenticates with a personal access token (PAT) used as the password. Mint and manage yours in the product UI:
- Open Settings → Personal access tokens (
/settings/tokens). - Enter a name (e.g. DBeaver), an optional expiry in days, and pick scopes — for SQL clients,
read:sqlis enough. Leaving every scope unchecked mints a full-access token; grant only what the client needs. - Click Create token. The secret (
vflh_…) is shown exactly once — copy it immediately. Reloading the page removes it from view permanently, on the server as well as in the UI. - The same page lists your tokens with prefix, state, and last-used time, and offers Rotate (new secret, old one dies immediately) and Revoke.
Your token is a password on these endpoints — treat it like one. The planes require TLS, so it never crosses the wire in the clear.
Your client will log it if you let it. DBeaver, DataGrip, beeline, and most JDBC tooling print the full connection string — password included, in cleartext — at DEBUG or TRACE level, and those logs land in a workspace directory, a CI artifact, or a pasted bug report. This has already happened to us once. Keep SQL clients at their default log level while connecting; if a token has appeared in a log, in a screenshot, or in a chat message, treat it as exposed and rotate or revoke it on /settings/tokens (Rotate mints a new secret and kills the old one immediately) rather than deciding who might have read it.
Subject format caveat (Hive plane) — #2011: a token’s subject is the identity it belongs to. Tokens minted in the UI are bound to your sign-in email, but the Hive/Thrift plane cannot currently authenticate subjects containing @ (the email is truncated at the @). Until the identity unification in #2011 lands, the Hive plane needs a token bound to a no-@ subject (e.g. jane-doe rather than jane.doe@example.org) — ask your platform administrator to provision one. The pg-wire plane is unaffected: email subjects work there as-is.
Hive / Thrift-HTTP plane (Databricks parity)
The Spark SQL gateway speaks HiveServer2 Thrift — the same protocol shape as a Databricks SQL warehouse, so any tool with a Databricks/Hive connector works. Each user gets an isolated Spark engine; SQL runs with full Spark semantics over the Iceberg medallion.
PostgreSQL wire-protocol plane (pg-wire)
The lakehouse also answers the PostgreSQL wire protocol directly — no Spark warm-up, sub-second answers for lookups, and compatibility with the enormous Postgres client ecosystem. Each lakehouse catalog appears as a Postgres database; your tenant comes from the token, never from anything you type.
Superset (BI dashboards) via SSO
Apache Superset is available for dashboarding without any driver setup — it connects to the SQL planes server-side, and you sign in with your existing platform account:
Known limitations (current)
These are real, open issues a connecting user can hit today. Each links to its tracker:
| Limitation | Plane | Workaround | Issue |
|---|---|---|---|
| Email (@) subjects cannot authenticate | Hive/Thrift | admin-provisioned token on a no-@ subject | #2011 |
| Tenant subjects don’t land in their namespace; GUI browsing needs fully-qualified names | Hive/Thrift | #spark.sql.defaultCatalog=lakehouse + lakehouse.tenant_<t>.… names | #2018 |
| Extended query protocol fails on every statement (stock pgJDBC cannot connect) | pg-wire | pgJDBC 42.7.4 exactly, or preferQueryMode=simple | #2021 |
Empty-name objects in events/partners crash GUI tree views | pg-wire | limit displayed databases to your catalog; don’t expand those two | #2025 |
SQL connectivity — UAT test instructions
Execute each plane’s steps in order and tick the box when the observed result matches the expected one. A mismatch is a finding: note the step number, the exact error text, and check the troubleshooting table — several rough edges are known, open issues, and hitting one with the documented symptom counts as expected behavior for this round of UAT.
Client setup details (drivers, walkthrough screenshots-level steps) live in the SQL connectivity guide.
Prerequisites
- A platform account that can sign in to the web app, on a tenant with data you are granted to read.
- For the pg-wire plane:
psqlor DBeaver/DataGrip with pgJDBC 42.7.4 (orpreferQueryMode=simpleset — #2021). - For the Hive plane: DBeaver or DataGrip with an Apache Hive 3.x+ driver (class
org.apache.hive.jdbc.HiveDriver— a MySQL or Generic JDBC profile will not work, and step 1 below has you prove that), and an admin-provisioned no-@ subject token (#2011 — UI-minted email-subject tokens cannot authenticate on this plane yet). - A client whose log level is at its default. At DEBUG/TRACE these tools print the connection string with your token in cleartext; a token that lands in a log has to be rotated.
Plane 1 — Personal access tokens (product UI)
PAT lifecycle
| # | Action | Expected result | Pass |
|---|---|---|---|
| 1 | Sign in to the web app and open Settings → Personal access tokens (/settings/tokens). | The page lists your existing tokens (or an empty state) and a “Create a token” form. | |
| 2 | Create a token named uat-<date> with expiry 1 day and only the read:sql scope checked. | A one-time secret reveal appears showing a vflh_… value with a Copy button. Copy it — this is the password for every SQL step below. | |
| 3 | Reload the page. | The secret is gone and cannot be re-displayed. The token row shows its prefix, active state, scopes, and expiry. | |
| 4 | After finishing all planes below: click Revoke on the UAT token. | The row flips to revoked; any subsequent SQL connection with it is refused (SQLSTATE 28000). |
Plane 2 — pg-wire (PostgreSQL protocol)
Plane 3 — Hive / Thrift-HTTP (Spark SQL)
Plane 4 — Superset via SSO
Troubleshooting
| Symptom | Cause | Fix | Issue |
|---|---|---|---|
Hive plane connects, then the tree is empty/broken and the log shows SELECT SCHEMA_NAME FROM information_schema.schemata failing | the client is on a MySQL or Generic JDBC driver profile, which discovers schemas through information_schema. Spark SQL has none and never will | switch to the Apache Hive driver profile (org.apache.hive.jdbc.HiveDriver); schema discovery then goes through Thrift metadata (SHOW DATABASES, SHOW NAMESPACES) | — |
Hive plane: SHOW DATABASES returns nothing, so the deployment looks like it has no data | the session is on Spark’s built-in spark_catalog, which is empty — either no catalog fragment on the URL, or one copied from a dbt profile | pin #spark.sql.defaultCatalog=lakehouse. spark_catalog is the dbt-only workaround (PyHive issues USE default on connect — #1564) and must not be copied into a human or BI client | #2018 |
| Your token appeared in a client log, a screenshot, or a pasted stack trace | JDBC tools print the whole connection string, password included, at DEBUG/TRACE level | treat it as exposed: Rotate or Revoke on /settings/tokens, then reconnect with the new secret. Keep SQL clients at default log level | — |
| pgJDBC: “Something unusual has occurred…” / cannot connect at all, or “Error operating ExecuteStatement” on every statement | the pg-wire extended query protocol is broken; stock pgJDBC uses it by default | pin pgJDBC 42.7.4 exactly, or add preferQueryMode=simple to the URL | #2021 |
| DBeaver/DataGrip/VS Code crashes while expanding the database tree | events/partners catalogs project empty-string schema/table names | limit displayed databases to your catalog; do not expand events/partners | #2025 |
| Connection succeeds but the catalog tree is empty / every SELECT is refused (42501) | the subject has no read grants — ReBAC filters the estate to what you may read | admin: POST /api/v1/admin/authz/grant (viewer on the catalog) then POST /api/v1/admin/authz/backfill-parents to cascade it | #2018 |
| Hive plane refuses your login even though the token works on pg-wire | email subjects are truncated at @ on the Thrift plane | use an admin-provisioned token bound to a no-@ subject | #2011 |
Hive plane: SHOW DATABASES empty or lands in the wrong catalog; unqualified table names not found | tenant subjects do not yet default into their tenant namespace | add #spark.sql.defaultCatalog=lakehouse to the JDBC URL and use lakehouse.tenant_<t>.… fully-qualified names | #2018 |
| First Hive query hangs ~a minute, client may print a warm-up/engine line | your dedicated Spark engine is cold-starting | wait — it completes and subsequent queries are fast; do not cancel | — |
SQLSTATE 28000 at connect | bad, expired, or revoked token — or the client skipped TLS | re-check the token in /settings/tokens; ensure sslmode=require | — |
SQLSTATE 25006 on CREATE/INSERT/UPDATE | the pg-wire endpoint is read-only by design | expected — writes go through the platform APIs, not this plane | — |
| Superset panels show a connection-limit error on first paint | dashboard fan-out is throttled to protect the shared SQL gateway | refresh — panels render in waves | — |
Embedded dashboards
A dashboard from the analyst workspace can be framed inside a product surface as a deep-linkable object, authenticated by a short-lived guest token that the host application mints on the server. The browser never holds a workspace credential, and the token carries the row-level scoping the reader is entitled to.
The worked example is live and is the reference implementation for every embed that follows: the RCSD community transparency dashboard, embedded → (demonstration site; every record behind it is synthetic).
Design and acceptance criteria: #2015.
The shape, in one paragraph
The host page renders an iframe pointed at the workspace’s embedded route for one dashboard uuid. The embedding SDK asks the host application’s own backend for a guest token; that backend logs in to the workspace as a service account with a single permission, composes the row-level rules from the caller’s entitlement, and returns only the token. The token lives five minutes and the SDK renews it through the same endpoint. Nothing else crosses to the browser, and there is no workspace login page anywhere in the flow — a guest token is not a session, so the frame never negotiates one.
browser host application (server) analyst workspace
| | |
|-- POST /guest-token ------>| |
| |-- login (service account) ------>|
| |<-- access token -----------------|
| |-- GET csrf_token --------------->|
| |<-- csrf + session cookie --------|
| | compose RLS from entitlement |
| |-- POST guest_token (+rls) ------>|
| |<-- guest token (5 min) ----------|
|<-- guest token ------------| |
| |
|== iframe: /embedded/<uuid>, X-GuestToken on every request ===>|What the browser can and cannot see
| Value | Reaches the browser? | Why |
|---|---|---|
| Dashboard embed uuid | yes | It is the iframe URL. Public by construction; it grants nothing on its own. |
| Workspace origin | yes | The frame has to point somewhere. |
| Guest token | yes | Five-minute life, scoped to one dashboard uuid, carrying row-level predicates the browser did not choose and cannot alter without invalidating the signature. |
| Service account username / password | no | Server-side environment only, from a Secret. |
| Workspace access token | no | Exists for the duration of one server-to-server mint and is never returned. |
| Guest-token signing secret | no | The host application never holds it at all. The workspace signs; the host asks. |
| The reader’s entitlement | no | Derived server-side per request. It leaves only baked into the token, as predicates. |
Verifiable from the outside: load the live page with the network panel open. The only request to the host application is POST /api/superset/guest-token; there is no login redirect, no oauth round trip, and no credential in the document source. The regression suite asserts this rather than describing it — it searches the rendered document for every credential in its fixture.
Why minting is server-side
Two designs were available. The host could hold the signing secret and mint the JWT itself — fewer moving parts, one fewer network hop. It does not, and the reason is the blast radius of the thing that leaks.
- With the signing secret, anyone who reaches the host’s environment can write a token with any scope, for any dashboard, for any identity.
- With a service account, the same attacker gains the ability to ask for a token whose scope the host application decides. That account can do nothing else: it cannot read a chart, list a dataset, open SQL Lab, or view the dashboard it mints tokens for.
Its whole permission set is two entries: can_grant_guest_token, and can_read on SecurityRestApi, which is the CSRF-token endpoint and nothing else. The workspace enforces CSRF on the mint even for a bearer-authenticated caller carrying no cookie, so minting is a double submit: fetch a CSRF token, send it back with the session cookie it arrived with. Two extra requests were judged cheaper than a CSRF exemption nobody would revisit.
Row-level scoping
The guest token carries the boundary. Everything the frame reads is filtered by rules the host composed from the caller’s entitlement, and the rules obey four invariants that are unit-tested in the host application because everything else trusts their answer.
- The entitlement is server-derived; the request only narrows. A caller may ask to see less than it is entitled to. A caller asking for more is refused — HTTP 403, nothing minted, and the response names what it refused. Not clamped quietly: a request for data outside a reader’s grants is a fact worth surfacing.
- An empty scope produces a predicate that matches nothing (
1 = 0), never an absent rule. Dropping the rule would widen the token to everything, which is the failure mode where a bug in the narrowing code is indistinguishable from success. - A rule only ever attaches to a dataset that carries its column. Row-level security is injected as a
WHEREclause, so a rule over a dataset without the column turns that panel into an error — and an errored panel reads to a member of the public exactly like a suppressed one. - Malformed identifiers are refused, not escaped. The key space is closed and known. A quote inside a school id is not a reader with an unusual school.
On the live example the reader is the public audience, and its entitlement is the district’s published school roster — read through the same governed path, with the same server-side credential, that produces every other figure on the site. It is derived, not declared, so a school that appears in the warehouse ahead of its publication does not quietly appear inside the frame. The minted rules look like this:
{ "dataset": 7, "clause": "school_id IN ('sch-02', 'sch-08', …)" }
{ "dataset": 11, "clause": "school_id IN ('sch-02', 'sch-08', …)" }Guest-token RLS is not the tenant boundary and must never be sold as one. The tenant boundary is the query engine session: the connection’s token names the subject, the engine is pinned to that tenant’s namespace and handed only that tenant’s storage key, and the authorization plugin gates the statement. The token’s RLS is a second, narrower filter applied inside an already-scoped connection, and it is how a reader’s entitlement is expressed.
Enabling embedding on a dashboard
Embedding is off until a dashboard is given an embed uuid and an origin allow-list. The uuid is not the id in a workspace URL, and it is not the native_filters_key that appears when someone shares a filtered view — that is filter state, not an identity. It comes from the dashboard’s embedded record:
POST /api/v1/dashboard/{id}/embedded { "allowed_domains": ["https://<host>"] }
GET /api/v1/dashboard/{id}/embedded -> { "result": { "uuid": "…" } }On this platform that call is made by a declarative provisioning script that also reconciles the guest role, the minter account and the dashboard’s filters, and prints the uuid and the scoped dataset ids for the host application’s configuration. Those two values are instance state and belong in deployment configuration, never in source: a re-provisioned workspace would otherwise serve the wrong dashboard, or scope the wrong dataset, with nothing on any screen to say so.
Who may frame it
Two independent allow-lists, and neither is ever a wildcard. The embedded record’s allowed_domains is checked by the workspace; the browser is governed by the Content-Security-Policy frame-ancestors directive.
content-security-policy: … ; frame-ancestors 'self' https://rcsd-demo.vforce360.aiThe legacy X-Frame-Options header had to be removed, not widened: it defaults to SAMEORIGIN, and its ALLOW-FROM form is dead in every current browser, so no allow-list can widen it. Dropping it and moving the decision to frame-ancestors is strictly stronger than the default, because the default set no frame-ancestors at all.
Cross-origin requests stay off. CORS is not enabled and does not need to be: the browser never calls the workspace API cross-origin. The host page calls its own backend, and everything inside the frame is same-origin to the workspace. Enabling CORS would exist only to serve a caller the design deliberately refuses to have.
Filters and deep links
Two separate problems hide behind “the embed has no filters”, and both had to be fixed.
The dashboard had no filters to show. A dashboard with an empty native filter configuration embeds without a filter bar no matter what the embedding SDK asks for. Filters are provisioned onto the dashboard, each scoped to exactly the charts whose dataset carries its column — a filter in scope over a dataset without the column makes that panel error, which is indistinguishable from suppression.
The filter bar assumed a scroll the embed does not have. The host page grows the frame to the dashboard’s full height so the page keeps the only scrollbar. The workspace’s vertical filter bar is built for the opposite arrangement — a sticky rail beside a scrolling viewport — so in a frame with no internal scroll its Apply button lands at the bottom of six thousand pixels of dashboard. The bar is configured horizontal, which puts the filters and Apply in a strip at the top, where they belong when the dashboard itself does not scroll.
The link is the view
The host URL carries the filter values, not a handle to them:
https://rcsd-demo.vforce360.ai/analytics/rcsd-v2?school=sch-east&grade=11A cold load of that address applies those values in the embedded filter bar, so the person you send it to sees what you saw. Try it →
This is deliberately not the workspace’s own share link. That link carries a native_filters_key, a handle to filter state cached against the session that created it; sent to anyone else it resolves to nothing and the dashboard opens on its defaults, silently. A link that only works for its author is not a deep link.
Known gap, measured. The round trip is one-way today: a filter changed inside the frame does not write back into the host URL. Superset 4.1.1’s frontend implements no dataMask channel at all, so the embedding SDK’s observeDataMask callback never fires. The host page says so in as many words rather than promising a round trip it does not have; the call is left in place and will begin working on the next workspace upgrade.
A filter can never widen scope. The filter bar decides what a reader is looking at inside what they may see; the token’s RLS decides what they may see. A reader who selects something outside their entitlement gets no rows, never somebody else’s rows.
Honest states
Four different facts, four different messages. Collapsing any pair of them into one is the defect the platform UI standard exists to prevent.
| State | What the reader sees |
|---|---|
| Not configured | “This deployment is not connected to an analytics workspace” — a missing configuration, stated as one. Not an error, and not an empty dashboard. |
| Unreachable | The failure and a retry, and nothing else on screen. No explainer stacked around it. |
| Refused | “This view is outside what this page may show”, naming what was refused. A different fact from unreachable, and a different fact from empty. |
| Loading | A skeleton the size of what is coming, and a sentence naming what is being waited on — the workspace starting and the query engine warming. Never a bare spinner. |
| No data | The dashboard’s own, per panel, inside the frame. Emptiness here is per-panel, so it belongs to the panel. |
Checklist for the next embed
- Enable embedding on the dashboard and record the uuid and the origin allow-list. Never a wildcard.
- Confirm the workspace has a guest role of its own with a pruned, read-only permission set — never the role an anonymous visitor already gets.
- Confirm the guest-token signing secret is a real secret from a Secret store. Upstream ships a published default and an instance running on it can be forged against by anyone.
- Add a server-side token endpoint. Never mint in the browser.
- Derive the entitlement server-side, compose one rule per scoped dataset, and make the endpoint refuse rather than clamp.
- Unit-test the refusal: assert that a request beyond the entitlement mints nothing, and that an empty scope produces a predicate matching nothing.
- Give the dashboard native filters and a horizontal filter bar, and carry filter values in your own URL.
- Verify in a browser: the dashboard renders, the network shows only your token endpoint, and the token expires when it says it does.
Production verification
Every surface on this platform sits behind Keycloak and oauth2-proxy. A personal access token authenticates the API; the browser path needs a session, which is a different credential entirely — so for a long time nobody, and no automation, could check that a shipped change actually behaved on the live authenticated view. Work shipped on image digests and stub runs instead (#2132).
There is now a dedicated verification identity and a Playwright harness that uses it to sign in to production non-interactively and drive the real thing. CI runs it and an agent runs it, through the same script, so “verified on production” means one thing whoever says it.
SQL connectivity → covers the other direction: connecting a client to the data planes.
Run it
One entry point, from a checkout of the repository, on a machine with cluster access. It reads the credential out of the secret store itself — you never see it, type it, or store it.
scripts/verify-live.sh # production
scripts/verify-live.sh --target sandbox # sandbox
scripts/verify-live.sh --spec catalog-sample-width-2126 # one specIn CI it is the verify-live workflow: dispatchable on demand, and run automatically after a release rolls to production. The workflow calls the same script with the same arguments — deliberately not a reimplementation of it, because the moment CI has its own private recipe the two claims diverge.
If the credential cannot be read, the run fails. It never falls back to an unauthenticated session: a green report over a logged-out view is worse than no report.
How the session is established
The harness performs the actual login. There is no bypass anywhere in the path, which is what makes the result worth anything:
- Playwright navigates to the target path on the real host.
- oauth2-proxy redirects to Keycloak (realm
vforce-flow) — a real authorization code flow. - The harness fills Keycloak’s identity-first form: email, Sign In, then password, Sign In.
- Keycloak redirects back through
/oauth2/callback; oauth2-proxy mints its redis-backed session and the app loads signed in.
Every layer runs: PKCE, the JWKS check the API performs on the forwarded access token, the tenant isolation gate, and the ReBAC decision on each read. The session is indistinguishable from a person’s except in what it is permitted to do.
Fresh per run, held between none. Each test starts from a cold browser context and signs in again; no storageState is written, so there is no session artifact on disk to go stale or to leak.
What the identity can and cannot do
The subject is svc-verify@edgentllc.com — a service identity, not a person, and unmistakable as one in any trail it appears in.
| It can | It cannot |
|---|---|
| Sign in to the hosts under test through the normal login, as any staff address does. | Hold any Keycloak realm role, client role or group beyond the realm default. |
Read the catalogs whose surfaces are under test — orchestration (the Catalog Sample Data grids, #2126) and cedms (the Review queue, #2129). | Read anything else. Not datasets, events, forensics, isd, main, rcsd or scratch; not another tenant. |
| Observe. That is the entire job. | Write anything, ever. Twice over: it holds only viewer relations, so ReBAC denies writes on its own — and independently of that, the API refuses every non-idempotent request from a declared verification subject before the router sees it, and records the attempt. |
| Prove its own ceiling, live, on every run. | Be a platform administrator or a tenant administrator. The reference spec asserts both against /api/v1/me each run rather than trusting a one-time setup. |
| Be recognized in the audit chain as verification traffic. | Read the audit chain. A credential that could check it was audited would be a credential holding audit-read authority. |
Adding a surface to the verification scope means adding a grant, which is a reviewed pull request: verificationGrants in the chart, pinned by a test that fails if the relation is anything but viewer, if the object is outside the declared scope, or if the subject is not a declared verification identity.
Where the secret lives
In the platform secret store, and nowhere else: Kubernetes Secret vforce-verification-identity in namespace vforce-lakehouse-dev, keys username and password.
Not in this repository, not in a values file, not in a memory note, not in a chat message, and deliberately not as a GitHub Actions secret — a second copy in a second store is a second rotation story and a second leak surface. One store, one copy.
This is a rule with a history. The previous end-to-end credential leaked because its plaintext was written into a notes file, and it was a personal-account full-access token. This one is neither personal nor writable, and its value is read only at the moment a run needs it.
Rotation: reset the password in Keycloak and update the Secret. Nothing in git changes, no deploy is required, and no session is disturbed — the harness holds none.
Its use is audited
A verification session is a real session and emits like any other. A test identity that leaves no trace is a hole, not a convenience.
- The login is a Keycloak
LOGINevent, swept into the tenant’s tamper-evident chain by the platform’s Keycloak audit sweeper exactly like a person’s. - Every request-path entry the session produces carries
verification: truein its detail, so an operator can separate harness traffic from human traffic without keeping a list of robot addresses in their head. - A refused write is recorded as
access_deniedwith reasonverification_readonly_refused— a harness that tried to write is precisely the event worth having.
The marker is stamped from the identity the API verified, never from a header a caller can type, so nobody can launder their traffic as verification traffic — or strip the mark off their own.
Write a live spec
Copy frontend/e2e-live/verification-identity.spec.ts. Every live spec is three lines of setup and then ordinary Playwright:
import { expect, test } from '@playwright/test';
import { assertSignedIn, openAs } from './session';
test('the thing you actually want to know', async ({ page }) => {
await openAs(page, '/catalog?sel=orchestration.events.project_events&tab=sample');
await assertSignedIn(page);
// ...assert a behavior on the real surface
});assertSignedIn is not ceremony. The way a live suite silently becomes worthless is that the browser lands on an anonymous or logged-out view, the surface renders its empty state, nothing contradicts the assertions, and the run reports green having verified nothing. Asserting whose session it is, before measuring anything, is what makes a green run mean something.
Two rules the existing specs follow and yours should: assert geometry and behavior, not the presence of a CSS rule — a grid can paint plausibly while its layout is wrong — and fail loudly on a surface with nothing to measure rather than passing over an empty table.
Out of scope, and staying that way
Two things this mechanism deliberately does not do, both named in #2132:
- Forging identity headers past the auth layer. An agent considered it, and correctly refused. It would also invalidate every isolation claim the platform makes about resolving a tenant from the Host header.
- Using the owner’s own credentials. Automation runs as itself, under its own ceiling, with its own name in the ledger.