diff --git a/.github/workflows/converged-database-lab.yml b/.github/workflows/converged-database-lab.yml
new file mode 100644
index 0000000..61a16bb
--- /dev/null
+++ b/.github/workflows/converged-database-lab.yml
@@ -0,0 +1,69 @@
+name: converged-database-lab
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'labs/converged-database-lab/**'
+ - '.github/workflows/converged-database-lab.yml'
+ pull_request:
+ paths:
+ - 'labs/converged-database-lab/**'
+ - '.github/workflows/converged-database-lab.yml'
+ schedule:
+ - cron: '17 6 * * *' # nightly: substantive freshness, not cosmetic
+ workflow_dispatch:
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ defaults:
+ run:
+ working-directory: labs/converged-database-lab
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Free disk space
+ run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
+
+ - name: Boot lab (build + init)
+ run: |
+ docker compose up -d --build oracle
+ # Health check polls every 15s with up to 40 retries + 120s start_period.
+ # We wait up to 90 × 15s = 22.5 min, which covers the cold-start image pull
+ # and DB initialization in CI. Break early once healthy; fail fast on unhealthy.
+ for i in $(seq 1 90); do
+ status=$(docker inspect -f '{{.State.Health.Status}}' lab-oracle 2>/dev/null || echo "starting")
+ [ "$status" = "healthy" ] && echo "Healthy after $((i * 15))s" && break
+ if [ "$status" = "unhealthy" ]; then
+ echo "Container is unhealthy — printing last 100 log lines and aborting"
+ docker logs lab-oracle --tail 100
+ exit 1
+ fi
+ echo "[$i/90] status=$status — sleeping 15s"
+ sleep 15
+ done
+ final=$(docker inspect -f '{{.State.Health.Status}}' lab-oracle)
+ if [ "$final" != "healthy" ]; then
+ echo "Container never became healthy (status=$final)"
+ docker logs lab-oracle --tail 100
+ exit 1
+ fi
+
+ - name: Verify domain initialized
+ # Use a heredoc piped to sqlplus to avoid all shell-quoting issues.
+ # The grep checks for 'customers=200' in the output.
+ run: |
+ docker exec lab-oracle bash -c "sqlplus -s / as sysdba <<'ENDSQL'
+ ALTER SESSION SET CONTAINER=FREEPDB1;
+ SELECT 'customers='||COUNT(*) FROM lab_user.customers;
+ ENDSQL
+ " | grep -q 'customers=200'
+
+ - uses: actions/setup-python@v5
+ with: { python-version: '3.11' }
+
+ - name: Run validator
+ run: |
+ pip install -r validator/requirements.txt
+ python validator/run.py
diff --git a/README.md b/README.md
index 82acc2f..11494dd 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,58 @@
-# terraform-oci-ocloud-asset-database
+# Oracle UMT Developer Hub
-[](https://img.shields.io/badge/license-UPL-green) [](https://sonarcloud.io/dashboard?id=oracle-devrel_terraform-oci-ocloud-asset-database)
+[](https://oss.oracle.com/licenses/upl/)
-## THIS IS A NEW, BLANK REPO THAT IS NOT READY FOR USE YET. PLEASE CHECK BACK SOON!
+Technical resources for developers learning **Unified Model Theory (UMT)** and converged data modeling on **Oracle AI Database 26ai** — *model the domain once, project the access for every consumer.*
-## Introduction
-MISSING
+This hub collects hands-on, **runnable** labs and companion material for the UMT / converged-database content series. Nothing here is slideware: every lab boots a real Oracle AI Database 26ai container and proves its claims by executing them — document, relational, graph, vector, spatial, and full-text in **one engine, one transaction, one optimizer.**
+
+> **One truth. Many shapes. Every claim runs.**
+
+## What You'll Find
+
+This repository is organized so that each runnable asset lives in its own folder under a category. More labs and companion material will be added as the series grows.
+
+### 🧪 Labs (`/labs`)
+
+Self-contained, runnable labs that validate converged-model claims against a live Oracle AI Database 26ai. Each lab includes a Docker environment, a validator, and its own README and quickstart, and runs itself nightly in CI so the proofs stay honest.
+
+| Name | Description | Link |
+| --- | --- | --- |
+| converged-database-lab | Runnable proofs for the converged-database article series — JSON Relational Duality, single-table vs. converged modeling, graph, vector, spatial, and full-text claims, each executing against a free Oracle AI Database 26ai container. | [](./labs/converged-database-lab) |
+
+*More labs coming as the content series expands.*
## Getting Started
-MISSING
-### Prerequisites
-MISSING
+Each lab is self-contained. To run the converged-database lab:
-## Notes/Issues
-MISSING
+```bash
+cd labs/converged-database-lab
+docker compose up -d --build oracle # ~2 GB image; first boot seeds the domain (~2 min)
+pip install -r validator/requirements.txt
+python validator/run.py # runs every module, prints ASSERT results
+```
-## URLs
-* Nothing at this time
+See the lab's own [README](./labs/converged-database-lab) for details, troubleshooting, and the module map.
+
+## About Unified Model Theory
+
+Relational and document are not competing paradigms — they are **orthogonal projections of the same truth.** UMT models the domain once as a normalized **canonical form**, then projects whatever **shape** each consumer needs — a document, a graph, a vector, a relational result — from that single source, with no copies to drift out of sync. On a converged engine, the access pattern still drives the model; you just stop walking through one-way doors to serve it.
## Contributing
-This project is open source. Please submit your contributions by forking this repository and submitting a pull request! Oracle appreciates any contributions that are made by the open source community.
+
+This project welcomes contributions from the community. Before submitting a pull request, please [review our contribution guide](./CONTRIBUTING.md).
+
+## Security
+
+Please consult the [security guide](./SECURITY.md) for our responsible security vulnerability disclosure process.
## License
-Copyright (c) 2024 Oracle and/or its affiliates.
-Licensed under the Universal Permissive License (UPL), Version 1.0.
+Copyright (c) 2026 Oracle and/or its affiliates.
+
+Released under the Universal Permissive License v1.0 as shown at .
-See [LICENSE](LICENSE.txt) for more details.
+## Disclaimer
-ORACLE AND ITS AFFILIATES DO NOT PROVIDE ANY WARRANTY WHATSOEVER, EXPRESS OR IMPLIED, FOR ANY SOFTWARE, MATERIAL OR CONTENT OF ANY KIND CONTAINED OR PRODUCED WITHIN THIS REPOSITORY, AND IN PARTICULAR SPECIFICALLY DISCLAIM ANY AND ALL IMPLIED WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, AND FITNESS FOR A PARTICULAR PURPOSE. FURTHERMORE, ORACLE AND ITS AFFILIATES DO NOT REPRESENT THAT ANY CUSTOMARY SECURITY REVIEW HAS BEEN PERFORMED WITH RESPECT TO ANY SOFTWARE, MATERIAL OR CONTENT CONTAINED OR PRODUCED WITHIN THIS REPOSITORY. IN ADDITION, AND WITHOUT LIMITING THE FOREGOING, THIRD PARTIES MAY HAVE POSTED SOFTWARE, MATERIAL OR CONTENT TO THIS REPOSITORY WITHOUT ANY REVIEW. USE AT YOUR OWN RISK.
\ No newline at end of file
+ORACLE AND ITS AFFILIATES DO NOT PROVIDE ANY WARRANTY WHATSOEVER, EXPRESS OR IMPLIED, FOR ANY SOFTWARE, MATERIAL OR CONTENT OF ANY KIND CONTAINED OR PRODUCED WITHIN THIS REPOSITORY, AND IN PARTICULAR SPECIFICALLY DISCLAIM ANY AND ALL IMPLIED WARRANTIES OF TITLE, MERCHANTABILITY, AND FITNESS FOR A PARTICULAR PURPOSE. FURTHERMORE, ORACLE AND ITS AFFILIATES DO NOT REPRESENT THAT ANY CUSTOMARY SECURITY REVIEW HAS BEEN PERFORMED WITH RESPECT TO ANY SOFTWARE, MATERIAL OR CONTENT CONTAINED OR PRODUCED WITHIN THIS REPOSITORY. IN ADDITION, AND WITHOUT LIMITING THE FOREGOING, THIRD PARTIES MAY HAVE POSTED SOFTWARE, MATERIAL OR CONTENT TO THIS REPOSITORY WITHOUT ANY WARRANTY OF ANY KIND, INCLUDING THAT THE CONTENT IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING. ANY OPEN SOURCE SOFTWARE IS PROVIDED BY THE APPLICABLE LICENSOR "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
diff --git a/labs/README.md b/labs/README.md
new file mode 100644
index 0000000..c357bcb
--- /dev/null
+++ b/labs/README.md
@@ -0,0 +1,9 @@
+# Labs
+
+Self-contained, runnable labs that validate converged-model claims against a live **Oracle AI Database 26ai**. Each lab ships a Docker environment, a validator, and its own README, and runs itself nightly in CI so the proofs stay honest.
+
+| Name | Description | Link |
+| --- | --- | --- |
+| converged-database-lab | Runnable proofs for the converged-database article series — JSON Relational Duality, single-table vs. converged modeling, graph, vector, spatial, and full-text claims, each executing against a free Oracle AI Database 26ai container. | [./converged-database-lab](./converged-database-lab) |
+
+*More labs coming as the content series expands.*
diff --git a/labs/converged-database-lab/.env.example b/labs/converged-database-lab/.env.example
new file mode 100644
index 0000000..5482b33
--- /dev/null
+++ b/labs/converged-database-lab/.env.example
@@ -0,0 +1,2 @@
+ORACLE_PASSWORD=LabAdmin2026
+LAB_PASSWORD=LabUser2026
diff --git a/labs/converged-database-lab/.gitignore b/labs/converged-database-lab/.gitignore
new file mode 100644
index 0000000..ea6ec7b
--- /dev/null
+++ b/labs/converged-database-lab/.gitignore
@@ -0,0 +1,7 @@
+.env
+*.log
+__pycache__/
+validator/results.json
+validator/.venv/
+docker/.data/
+*.pyc
diff --git a/labs/converged-database-lab/README.md b/labs/converged-database-lab/README.md
new file mode 100644
index 0000000..c112901
--- /dev/null
+++ b/labs/converged-database-lab/README.md
@@ -0,0 +1,144 @@
+# converged-database-lab
+
+
+
+Runnable proofs for the converged-database article series — every technical claim in the articles executes here, nightly, against a free container.
+
+**One truth. Many shapes. Every claim runs.**
+
+---
+
+## About the name: 26ai vs 23.26.x
+
+Oracle AI Database 26ai is the product name — a long-term-support release announced October 2025. Container tags use a year.quarter version string convention, so `gvenzl/oracle-free:23.26.x` tags are the 26ai release line. Prose in this repo always says 26ai; the version string appears only where a tag or connection string requires it.
+
+---
+
+## Quickstart
+
+```bash
+docker compose up -d --build oracle # ~2 GB image; first boot seeds the domain (~2 min)
+pip install -r validator/requirements.txt
+python validator/run.py # runs every module, prints ASSERT results
+```
+
+If the container goes `unhealthy`, check `docker logs lab-oracle` — the most common cause is an ORDS install failure on first boot; `docker compose down -v && docker compose up -d --build oracle` usually resolves it.
+
+**Connection cheatsheet**
+
+| Interface | Connection string |
+|-----------|-------------------|
+| SQL | `LAB_USER/LabUser2026@localhost:1521/FREEPDB1` |
+| MongoDB API | `mongodb://LAB_USER:LabUser2026@localhost:27017/lab_user?authMechanism=PLAIN&authSource=$external&tls=false&loadBalanced=true&retryWrites=false` |
+| ORDS | `http://localhost:8181` |
+
+These are demo credentials. Override via `.env` — see `.env.example`.
+
+---
+
+## The shared domain
+
+All modules run against one deterministically seeded domain. Seeds use an LCG (seed 42) so `docker compose down -v && docker compose up -d --build oracle` produces byte-for-byte identical data every rebuild.
+
+**Tables**
+
+| Object | Rows | Data model exercised |
+|--------|------|----------------------|
+| `customers` | 200 | Relational — primary entity, FK target |
+| `products` | 50 | Relational + JSON (`attributes` column: color, warrantyMonths) |
+| `stores` | 6 | Relational + Spatial (`SDO_GEOMETRY`, WGS-84 coordinates) |
+| `orders` | 1,000 | Relational — transactional fact table |
+| `order_items` | ~2,500 (avg 2.5/order) | Relational — composite PK, line items |
+| `devices` | 40 | Relational — device fingerprints |
+| `customer_devices` | 200 | Relational — M:M link; graph edge source |
+| `referrals` | 64 | Relational — self-referential; graph edge source (60 chain + 4-hop cycle 10→11→12→13→10) |
+| `support_tickets` | 300 | Relational + Vector (`VECTOR(8, FLOAT32)` — 8-dim demo embeddings, see note) |
+
+**JSON collection table**
+
+| Object | Data model exercised |
+|--------|----------------------|
+| `events` | JSON-native collection (Oracle `JSON COLLECTION TABLE`) |
+
+**JSON Relational Duality Views**
+
+| View | Projection |
+|------|------------|
+| `customer_profile_dv` | Customer + nested orders + line items; writeable via MongoDB API |
+| `order_dv` | Order-centric shape over the same rows — second projection of one stored truth |
+
+**Property graph**
+
+| Object | Vertices / Edges |
+|--------|-----------------|
+| `customer_graph` | 240 vertices (200 customers + 40 devices); 264 edges (200 `customer_devices` + 64 `referrals`) |
+
+**Indexes**
+
+| Index | Type | Purpose |
+|-------|------|---------|
+| `idx_orders_customer` | B-tree | Customer order range scans |
+| `idx_items_product` | B-tree | Product-level aggregations |
+| `idx_tickets_customer` | B-tree | Per-customer ticket lookups |
+| `ticket_text_idx` | Oracle Text (SEARCH INDEX) | Full-text keyword search on ticket bodies |
+| `ticket_vec_idx` | Vector IVF (NEIGHBOR PARTITIONS, COSINE) | Approximate nearest-neighbor on embeddings |
+
+**Note on embeddings:** The 8-dim vectors are deterministic demo values generated by the LCG seed. All engine behaviors demonstrated (distance functions, vector indexes, transactions, cross-model joins) are dimension-independent. Module 03 documents the optional real-model flow using `DBMS_VECTOR.LOAD_ONNX_MODEL` with all-MiniLM-L12-v2.
+
+**Oracle AI Database 26ai Free limits:** 2 CPU / 2 GB SGA+PGA / 12 GB data. The compose file caps the container at 2 CPUs and 4 GB (headroom for ORDS and OS overhead).
+
+---
+
+## Modules
+
+| # | Slug | Status | Proofs | Assertions | Links |
+|---|------|--------|--------|------------|-------|
+| 01 | `what-is-a-converged-database` | Live | 4 | 14 | [README](modules/01-what-is-a-converged-database/README.md) · article link lands at publish |
+| 02 | `converged-vs-multi-model` | Arrives with its article | — | — | — |
+| 03 | `vector-db-vs-converged` | Arrives with its article | — | — | — |
+| 04 | `ai-agents-enterprise-data` | Arrives with its article | — | — | — |
+| 05 | `json-graph-vector-relational-one-db` | Arrives with its article | — | — | — |
+| 06 | `polyglot-vs-converged` | Arrives with its article | — | — | — |
+| 07 | `reading-stonebraker-and-pavlo` | Arrives with its article | — | — | — |
+
+---
+
+## Validation contract
+
+Scripts live in `modules/NN-slug/scripts/` and run in lexical order — modules first, then scripts within each module.
+
+- **`*.sql`** — executed statement-by-statement via python-oracledb (thin mode) as `LAB_USER` against `localhost:1521/FREEPDB1`. PL/SQL blocks end with a line containing only `/`; plain SQL ends with `;`. The harness rolls back after each script so demos leave the domain unchanged.
+- **`*.js`** — executed with `mongosh` inside the `lab-oracle` container against the MongoDB API (port 27017). JS scripts auto-commit through the MongoDB API, so they must clean up explicitly — delete what they insert, restore what they update.
+
+**Assertion convention:** SQL scripts assert by SELECTing a string of the form `ASSERT::PASS|FAIL` as the first column. JS scripts `print()` the same string. The validator harvests these lines and reports per-script.
+
+**Results:** written to `validator/results.json` (gitignored).
+
+**CI:** runs on push to `main`/`article/**`, on pull requests, and on a nightly cron (`17 6 * * *`). The job uploads `results.json` as a build artifact.
+
+See [validator/README.md](validator/README.md) for the full authoring contract.
+
+---
+
+## MongoDB comparison profile
+
+```bash
+docker compose --profile mongodb up -d
+```
+
+Boots `mongo:8.2` as a single-node replica set (required for transactions) on host port 27027. Used for head-to-head demos in modules 03 and 06.
+
+**Beta note:** `$vectorSearch`, `$search`, and `$changeStream` are beta in the Oracle 26ai MongoDB API per Oracle documentation. Modules that use these stages label them explicitly.
+
+---
+
+## Related work
+
+- [github.com/rhoulihan/unified-model-theory](https://github.com/rhoulihan/unified-model-theory) — the UMT framework this lab demonstrates
+- Article links added as they publish
+
+---
+
+## License
+
+Universal Permissive License v1.0 — see [LICENSE](LICENSE).
diff --git a/labs/converged-database-lab/docker-compose.yml b/labs/converged-database-lab/docker-compose.yml
new file mode 100644
index 0000000..c8b8610
--- /dev/null
+++ b/labs/converged-database-lab/docker-compose.yml
@@ -0,0 +1,41 @@
+name: converged-database-lab
+
+services:
+ oracle:
+ build:
+ context: ./docker
+ dockerfile: Dockerfile.oracle
+ container_name: lab-oracle
+ ports: ['1521:1521', '8181:8181', '27017:27017']
+ environment:
+ ORACLE_PASSWORD: '${ORACLE_PASSWORD:-LabAdmin2026}'
+ APP_USER: 'LAB_USER'
+ APP_USER_PASSWORD: '${LAB_PASSWORD:-LabUser2026}'
+ volumes:
+ - oracle-data:/opt/oracle/oradata
+ healthcheck:
+ test: ['CMD-SHELL', '/opt/oracle/healthcheck.sh && curl -so /dev/null -w "%{http_code}" http://localhost:8181/ | grep -q "302\|200"']
+ interval: 15s
+ timeout: 10s
+ retries: 40
+ start_period: 120s
+ deploy:
+ resources:
+ limits: { cpus: '2', memory: 4G }
+
+ # Comparison target for head-to-head demos (modules 03/06). Single-node replica set
+ # (transactions require it). Start with: docker compose --profile mongodb up -d
+ mongodb:
+ image: mongo:8.2
+ container_name: lab-mongodb
+ profiles: ['mongodb']
+ command: ['mongod', '--replSet', 'rs0', '--bind_ip_all']
+ ports: ['27027:27017']
+ healthcheck:
+ test: ['CMD', 'mongosh', '--quiet', '--eval', "try { rs.status().ok } catch (e) { rs.initiate().ok }"]
+ interval: 10s
+ timeout: 10s
+ retries: 12
+
+volumes:
+ oracle-data:
diff --git a/labs/converged-database-lab/docker/Dockerfile.oracle b/labs/converged-database-lab/docker/Dockerfile.oracle
new file mode 100644
index 0000000..f0fb77a
--- /dev/null
+++ b/labs/converged-database-lab/docker/Dockerfile.oracle
@@ -0,0 +1,44 @@
+# Oracle AI Database 26ai Free + ORDS + MongoDB API.
+# Note: gvenzl image tags use year.quarter numbering (23.26.x == the 26ai release line).
+FROM gvenzl/oracle-free:23.26.0-faststart
+
+USER root
+COPY scripts/install-ords.sh /tmp/install-ords.sh
+RUN chmod +x /tmp/install-ords.sh && /tmp/install-ords.sh && rm /tmp/install-ords.sh
+RUN chown -R oracle:oinstall /etc/ords
+
+# mongosh: used to verify and exercise the MongoDB API (port 27017) from inside
+# the container — the validator harness runs *.js scripts through it.
+RUN printf '[mongodb-org-8.0]\nname=MongoDB Repository\nbaseurl=https://repo.mongodb.org/yum/redhat/8/mongodb-org/8.0/x86_64/\ngpgcheck=1\nenabled=1\ngpgkey=https://pgp.mongodb.com/server-8.0.asc\n' > /etc/yum.repos.d/mongodb-org-8.0.repo \
+ && microdnf install -y mongodb-mongosh \
+ && microdnf clean all
+
+# Bake Oracle's prebuilt augmented all-MiniLM-L12-v2 ONNX model into the image so
+# every fresh build (and CI) loads identical in-DB embeddings deterministically —
+# no network dependency at init time. This is the official model published by
+# Oracle Machine Learning: the 26ai docs "ONNX Pipeline Models: Text Embedding"
+# page redirects (/pls/topic/lookup?...&id=oml_ai_models_object_storage) to the
+# OML-ai-models object-storage bucket whose index lists this exact URL. The zip
+# (~122.6 MB) unpacks to all_MiniLM_L12_v2.onnx (~133.3 MB, 384-dim output),
+# loaded by docker/init/08-vector-model.sql via DBMS_VECTOR.LOAD_ONNX_MODEL.
+# Placed AFTER the ORDS + mongosh layers so their (slow) cache layers are reused
+# when only the model layer changes. Fails the build loudly if the URL stops
+# resolving rather than baking a broken image.
+ARG MINILM_URL=https://adwc4pm.objectstorage.us-ashburn-1.oci.customer-oci.com/p/TtH6hL2y25EypZ0-rrczRZ1aXp7v1ONbRBfCiT-BDBN8WLKQ3lgyW6RxCfIFLdA6/n/adwc4pm/b/OML-ai-models/o/all_MiniLM_L12_v2_augmented.zip
+RUN microdnf install -y unzip \
+ && microdnf clean all \
+ && mkdir -p /opt/oracle/models \
+ && curl -fsSL -o /tmp/minilm.zip "$MINILM_URL" \
+ && unzip -o /tmp/minilm.zip -d /opt/oracle/models \
+ && test -s /opt/oracle/models/all_MiniLM_L12_v2.onnx \
+ && rm -f /tmp/minilm.zip \
+ && chown -R oracle:oinstall /opt/oracle/models
+
+COPY init/ /container-entrypoint-initdb.d/
+
+COPY scripts/entrypoint.sh /opt/oracle/scripts/entrypoint.sh
+RUN chmod +x /opt/oracle/scripts/entrypoint.sh
+
+USER oracle
+EXPOSE 1521 8181 27017
+ENTRYPOINT ["/opt/oracle/scripts/entrypoint.sh"]
diff --git a/labs/converged-database-lab/docker/init/.gitkeep b/labs/converged-database-lab/docker/init/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/labs/converged-database-lab/docker/init/01-lab-user.sql b/labs/converged-database-lab/docker/init/01-lab-user.sql
new file mode 100644
index 0000000..9720c6a
--- /dev/null
+++ b/labs/converged-database-lab/docker/init/01-lab-user.sql
@@ -0,0 +1,16 @@
+-- Lab schema owner. Runs as SYS in FREEPDB1 (gvenzl initdb convention).
+-- The gvenzl image already creates APP_USER (=LAB_USER per compose), so user
+-- creation is wrapped idempotently; the grants always run.
+ALTER SESSION SET CONTAINER = FREEPDB1;
+BEGIN
+ BEGIN
+ EXECUTE IMMEDIATE 'CREATE USER lab_user IDENTIFIED BY "LabUser2026" QUOTA UNLIMITED ON users';
+ EXCEPTION WHEN OTHERS THEN
+ IF SQLCODE != -1920 THEN RAISE; END IF; -- ORA-01920: user exists (created by image)
+ END;
+ EXECUTE IMMEDIATE 'ALTER USER lab_user QUOTA UNLIMITED ON users';
+ EXECUTE IMMEDIATE 'GRANT CREATE SESSION, CREATE TABLE, CREATE VIEW, CREATE PROCEDURE,
+ CREATE SEQUENCE, CREATE MATERIALIZED VIEW, CREATE PROPERTY GRAPH, SODA_APP TO lab_user';
+ EXECUTE IMMEDIATE 'GRANT EXECUTE ON CTXSYS.CTX_DDL TO lab_user';
+END;
+/
diff --git a/labs/converged-database-lab/docker/init/02-schema.sql b/labs/converged-database-lab/docker/init/02-schema.sql
new file mode 100644
index 0000000..1d5e951
--- /dev/null
+++ b/labs/converged-database-lab/docker/init/02-schema.sql
@@ -0,0 +1,87 @@
+ALTER SESSION SET CONTAINER = FREEPDB1;
+ALTER SESSION SET CURRENT_SCHEMA = lab_user;
+
+CREATE TABLE customers (
+ customer_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ email VARCHAR2(120) NOT NULL UNIQUE,
+ full_name VARCHAR2(100) NOT NULL,
+ segment VARCHAR2(20) DEFAULT 'standard' NOT NULL
+ CHECK (segment IN ('standard','premium','vip')),
+ created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
+);
+
+CREATE TABLE products (
+ product_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ sku VARCHAR2(24) NOT NULL UNIQUE,
+ name VARCHAR2(120) NOT NULL,
+ category VARCHAR2(40) NOT NULL,
+ list_price NUMBER(10,2) NOT NULL,
+ attributes JSON
+);
+
+CREATE TABLE stores (
+ store_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ name VARCHAR2(80) NOT NULL,
+ city VARCHAR2(60) NOT NULL,
+ location SDO_GEOMETRY
+);
+
+CREATE TABLE orders (
+ order_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ customer_id NUMBER NOT NULL REFERENCES customers,
+ store_id NUMBER REFERENCES stores,
+ status VARCHAR2(16) DEFAULT 'placed' NOT NULL
+ CHECK (status IN ('placed','shipped','delivered','returned')),
+ order_ts TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
+ total_amount NUMBER(12,2)
+);
+
+CREATE TABLE order_items (
+ order_id NUMBER NOT NULL REFERENCES orders,
+ line_no NUMBER NOT NULL,
+ product_id NUMBER NOT NULL REFERENCES products,
+ qty NUMBER NOT NULL CHECK (qty > 0),
+ unit_price NUMBER(10,2) NOT NULL,
+ PRIMARY KEY (order_id, line_no)
+);
+
+CREATE TABLE devices (
+ device_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ fingerprint VARCHAR2(64) NOT NULL UNIQUE
+);
+
+CREATE TABLE customer_devices (
+ customer_id NUMBER NOT NULL REFERENCES customers,
+ device_id NUMBER NOT NULL REFERENCES devices,
+ first_seen TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
+ PRIMARY KEY (customer_id, device_id)
+);
+
+CREATE TABLE referrals (
+ referrer_id NUMBER NOT NULL REFERENCES customers,
+ referee_id NUMBER NOT NULL REFERENCES customers,
+ referred_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
+ PRIMARY KEY (referrer_id, referee_id),
+ CHECK (referrer_id != referee_id)
+);
+
+CREATE TABLE support_tickets (
+ ticket_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ customer_id NUMBER NOT NULL REFERENCES customers,
+ subject VARCHAR2(200) NOT NULL,
+ body VARCHAR2(4000) NOT NULL,
+ status VARCHAR2(12) DEFAULT 'open' NOT NULL
+ CHECK (status IN ('open','pending','closed')),
+ opened_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
+ -- Deterministic 8-dim demo embeddings (seeded). Engine behavior (distance, indexes,
+ -- transactions, joins) is dimension-independent; module 03 documents the optional
+ -- real-model flow (DBMS_VECTOR.LOAD_ONNX_MODEL, all-MiniLM-L12-v2).
+ embedding VECTOR(8, FLOAT32)
+);
+
+-- JSON collection table: document-native surface of the same engine.
+CREATE JSON COLLECTION TABLE events;
+
+CREATE INDEX idx_orders_customer ON orders (customer_id, order_ts);
+CREATE INDEX idx_items_product ON order_items (product_id);
+CREATE INDEX idx_tickets_customer ON support_tickets (customer_id);
diff --git a/labs/converged-database-lab/docker/init/03-duality-views.sql b/labs/converged-database-lab/docker/init/03-duality-views.sql
new file mode 100644
index 0000000..05002be
--- /dev/null
+++ b/labs/converged-database-lab/docker/init/03-duality-views.sql
@@ -0,0 +1,45 @@
+ALTER SESSION SET CONTAINER = FREEPDB1;
+ALTER SESSION SET CURRENT_SCHEMA = lab_user;
+
+-- Customer profile: one document per customer, orders + items nested.
+CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW customer_profile_dv AS
+SELECT JSON {
+ '_id' : c.customer_id,
+ 'email' : c.email,
+ 'fullName' : c.full_name,
+ 'segment' : c.segment WITH UPDATE,
+ 'orders' : [ SELECT JSON {
+ 'orderId' : o.order_id,
+ 'status' : o.status WITH UPDATE,
+ 'orderTs' : o.order_ts,
+ 'total' : o.total_amount,
+ 'items' : [ SELECT JSON {
+ 'line' : oi.line_no,
+ 'productId' : oi.product_id,
+ 'qty' : oi.qty,
+ 'unitPrice' : oi.unit_price }
+ FROM order_items oi WITH INSERT UPDATE DELETE
+ WHERE oi.order_id = o.order_id ]}
+ FROM orders o WITH INSERT UPDATE DELETE
+ WHERE o.customer_id = c.customer_id ]
+} FROM customers c WITH INSERT UPDATE DELETE;
+
+-- Order-centric shape over the SAME rows: second projection of one truth.
+CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW order_dv AS
+SELECT JSON {
+ '_id' : o.order_id,
+ 'status' : o.status WITH UPDATE,
+ 'orderTs' : o.order_ts,
+ 'total' : o.total_amount WITH UPDATE,
+ 'customer' : ( SELECT JSON { 'customerId' : c.customer_id,
+ 'email' : c.email,
+ 'fullName' : c.full_name }
+ FROM customers c WITH NOUPDATE
+ WHERE c.customer_id = o.customer_id ),
+ 'items' : [ SELECT JSON { 'line' : oi.line_no,
+ 'productId' : oi.product_id,
+ 'qty' : oi.qty,
+ 'unitPrice' : oi.unit_price }
+ FROM order_items oi WITH INSERT UPDATE DELETE
+ WHERE oi.order_id = o.order_id ]
+} FROM orders o WITH INSERT UPDATE DELETE;
diff --git a/labs/converged-database-lab/docker/init/04-graph.sql b/labs/converged-database-lab/docker/init/04-graph.sql
new file mode 100644
index 0000000..214df36
--- /dev/null
+++ b/labs/converged-database-lab/docker/init/04-graph.sql
@@ -0,0 +1,21 @@
+ALTER SESSION SET CONTAINER = FREEPDB1;
+ALTER SESSION SET CURRENT_SCHEMA = lab_user;
+
+-- SQL/PGQ property graph over the SAME relational tables. No export, no sync.
+CREATE PROPERTY GRAPH customer_graph
+ VERTEX TABLES (
+ customers KEY (customer_id)
+ PROPERTIES (customer_id, email, full_name, segment),
+ devices KEY (device_id)
+ PROPERTIES (device_id, fingerprint)
+ )
+ EDGE TABLES (
+ customer_devices KEY (customer_id, device_id)
+ SOURCE KEY (customer_id) REFERENCES customers (customer_id)
+ DESTINATION KEY (device_id) REFERENCES devices (device_id)
+ PROPERTIES (first_seen),
+ referrals KEY (referrer_id, referee_id)
+ SOURCE KEY (referrer_id) REFERENCES customers (customer_id)
+ DESTINATION KEY (referee_id) REFERENCES customers (customer_id)
+ PROPERTIES (referred_at)
+ );
diff --git a/labs/converged-database-lab/docker/init/05-text-vector.sql b/labs/converged-database-lab/docker/init/05-text-vector.sql
new file mode 100644
index 0000000..ddd6d49
--- /dev/null
+++ b/labs/converged-database-lab/docker/init/05-text-vector.sql
@@ -0,0 +1,18 @@
+ALTER SESSION SET CONTAINER = FREEPDB1;
+ALTER SESSION SET CURRENT_SCHEMA = lab_user;
+
+-- Oracle Text index on ticket bodies: keyword search in the same engine/transaction.
+-- SYNC (ON COMMIT): the index syncs inside the committing transaction, so a
+-- committed row is immediately findable via CONTAINS — transactional
+-- read-after-write search (module 02 proves this). The 26ai Free default for
+-- CREATE SEARCH INDEX is MAINTENANCE AUTO with deferred background sync
+-- (ctx_user_indexes showed IDX_SYNC_TYPE = MANUAL), under which a probe row was
+-- NOT visible to CONTAINS immediately after COMMIT.
+CREATE SEARCH INDEX ticket_text_idx ON support_tickets (body)
+ PARAMETERS ('SYNC (ON COMMIT)');
+
+-- Vector index. IVF (NEIGHBOR PARTITIONS) — works within Free-tier memory without
+-- carving VECTOR_MEMORY_SIZE; module 03 demonstrates HNSW + memory sizing.
+CREATE VECTOR INDEX ticket_vec_idx ON support_tickets (embedding)
+ ORGANIZATION NEIGHBOR PARTITIONS
+ DISTANCE COSINE;
diff --git a/labs/converged-database-lab/docker/init/06-seed.sql b/labs/converged-database-lab/docker/init/06-seed.sql
new file mode 100644
index 0000000..c1d0c37
--- /dev/null
+++ b/labs/converged-database-lab/docker/init/06-seed.sql
@@ -0,0 +1,124 @@
+ALTER SESSION SET CONTAINER = FREEPDB1;
+ALTER SESSION SET CURRENT_SCHEMA = lab_user;
+
+DECLARE
+ -- All item declarations must precede subprogram bodies (PLS-00103 otherwise).
+ seed NUMBER := 42;
+ v_vec VARCHAR2(400);
+ v_norm NUMBER;
+ TYPE t_f IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
+ f t_f;
+ v_total NUMBER;
+ v_items PLS_INTEGER;
+ v_price NUMBER;
+ -- Local functions cannot be referenced inside SQL (PLS-00231), so every
+ -- nxt/pick value is computed into these before its INSERT.
+ v_cust PLS_INTEGER;
+ v_store PLS_INTEGER;
+ v_prod PLS_INTEGER;
+ v_qty PLS_INTEGER;
+ v_ts TIMESTAMP;
+ FUNCTION nxt RETURN NUMBER IS -- LCG: deterministic across runs
+ BEGIN
+ seed := MOD(seed * 1103515245 + 12345, 2147483648);
+ RETURN seed / 2147483648;
+ END;
+ FUNCTION pick(n IN PLS_INTEGER) RETURN PLS_INTEGER IS
+ BEGIN RETURN TRUNC(nxt * n) + 1; END;
+BEGIN
+ FOR i IN 1..200 LOOP
+ INSERT INTO customers (email, full_name, segment)
+ VALUES ('customer'||i||'@example.com', 'Customer '||i,
+ CASE WHEN MOD(i,17)=0 THEN 'vip' WHEN MOD(i,5)=0 THEN 'premium' ELSE 'standard' END);
+ END LOOP;
+
+ FOR i IN 1..50 LOOP
+ v_price := ROUND(10 + nxt*490, 2);
+ INSERT INTO products (sku, name, category, list_price, attributes)
+ VALUES ('SKU-'||LPAD(i,4,'0'), 'Product '||i,
+ CASE MOD(i,5) WHEN 0 THEN 'audio' WHEN 1 THEN 'compute'
+ WHEN 2 THEN 'storage' WHEN 3 THEN 'network' ELSE 'accessory' END,
+ v_price,
+ JSON('{"color":"'||CASE MOD(i,3) WHEN 0 THEN 'black' WHEN 1 THEN 'silver' ELSE 'blue' END||
+ '","warrantyMonths":'||(12*(MOD(i,3)+1))||'}'));
+ END LOOP;
+
+ INSERT INTO stores (name, city, location) VALUES ('Downtown Austin','Austin', SDO_GEOMETRY(2001,4326,SDO_POINT_TYPE(-97.7431,30.2672,NULL),NULL,NULL));
+ INSERT INTO stores (name, city, location) VALUES ('Domain North','Austin', SDO_GEOMETRY(2001,4326,SDO_POINT_TYPE(-97.7220,30.4019,NULL),NULL,NULL));
+ INSERT INTO stores (name, city, location) VALUES ('Frisco','Dallas', SDO_GEOMETRY(2001,4326,SDO_POINT_TYPE(-96.8236,33.1507,NULL),NULL,NULL));
+ INSERT INTO stores (name, city, location) VALUES ('Galleria','Houston', SDO_GEOMETRY(2001,4326,SDO_POINT_TYPE(-95.4613,29.7392,NULL),NULL,NULL));
+ INSERT INTO stores (name, city, location) VALUES ('Pearl','San Antonio', SDO_GEOMETRY(2001,4326,SDO_POINT_TYPE(-98.4815,29.4426,NULL),NULL,NULL));
+ INSERT INTO stores (name, city, location) VALUES ('Online','-', NULL);
+
+ FOR i IN 1..40 LOOP
+ INSERT INTO devices (fingerprint) VALUES (RAWTOHEX(UTL_RAW.CAST_FROM_NUMBER(1000+i)));
+ END LOOP;
+
+ -- Device links: ring devices 1-5 shared by clique customers (fraud pattern),
+ -- the rest one device per ~5 customers.
+ FOR i IN 1..200 LOOP
+ IF i <= 15 THEN
+ INSERT INTO customer_devices (customer_id, device_id) VALUES (i, MOD(i,5)+1);
+ ELSE
+ INSERT INTO customer_devices (customer_id, device_id) VALUES (i, 6 + MOD(i,34));
+ END IF;
+ END LOOP;
+
+ -- Referral chains + one cycle 10→11→12→13→10
+ FOR i IN 1..60 LOOP
+ INSERT INTO referrals (referrer_id, referee_id) VALUES (i, i+60);
+ END LOOP;
+ INSERT INTO referrals (referrer_id, referee_id) VALUES (10,11);
+ INSERT INTO referrals (referrer_id, referee_id) VALUES (11,12);
+ INSERT INTO referrals (referrer_id, referee_id) VALUES (12,13);
+ INSERT INTO referrals (referrer_id, referee_id) VALUES (13,10);
+
+ FOR i IN 1..1000 LOOP
+ v_items := pick(4);
+ v_total := 0;
+ v_cust := pick(200);
+ v_store := pick(6);
+ v_ts := TIMESTAMP '2026-01-01 00:00:00' + NUMTODSINTERVAL(TRUNC(nxt*150), 'DAY');
+ INSERT INTO orders (customer_id, store_id, status, order_ts, total_amount)
+ VALUES (v_cust, v_store,
+ CASE WHEN MOD(i,29)=0 THEN 'returned' WHEN MOD(i,3)=0 THEN 'delivered'
+ WHEN MOD(i,2)=0 THEN 'shipped' ELSE 'placed' END,
+ v_ts, 0);
+ FOR j IN 1..v_items LOOP
+ v_price := ROUND(10 + nxt*490, 2);
+ v_prod := pick(50);
+ v_qty := pick(3);
+ INSERT INTO order_items (order_id, line_no, product_id, qty, unit_price)
+ VALUES (i, j, v_prod, v_qty, v_price);
+ v_total := v_total + v_price;
+ END LOOP;
+ UPDATE orders SET total_amount = ROUND(v_total,2) WHERE order_id = i;
+ END LOOP;
+
+ FOR i IN 1..300 LOOP
+ v_norm := 0;
+ FOR d IN 1..8 LOOP f(d) := nxt*2 - 1; v_norm := v_norm + f(d)*f(d); END LOOP;
+ v_norm := SQRT(v_norm);
+ v_vec := '[';
+ FOR d IN 1..8 LOOP
+ v_vec := v_vec || TO_CHAR(ROUND(f(d)/v_norm, 6),'FM990.999999') || CASE WHEN d<8 THEN ',' END;
+ END LOOP;
+ v_vec := v_vec || ']';
+ v_cust := pick(200);
+ INSERT INTO support_tickets (customer_id, subject, body, status, embedding)
+ VALUES (v_cust,
+ CASE MOD(i,4) WHEN 0 THEN 'Refund request for damaged item'
+ WHEN 1 THEN 'Cannot sign in after password reset'
+ WHEN 2 THEN 'Shipping delay on recent order'
+ ELSE 'Warranty question for product' END || ' #'||i,
+ 'Ticket '||i||' body: customer reports an issue with their recent experience. '||
+ CASE MOD(i,4) WHEN 0 THEN 'The package arrived damaged and a refund is requested.'
+ WHEN 1 THEN 'Login fails with an authentication error after reset.'
+ WHEN 2 THEN 'The order has not arrived within the promised window.'
+ ELSE 'Customer asks whether the warranty covers accidental damage.' END,
+ CASE WHEN MOD(i,3)=0 THEN 'closed' WHEN MOD(i,7)=0 THEN 'pending' ELSE 'open' END,
+ TO_VECTOR(v_vec, 8, FLOAT32));
+ END LOOP;
+ COMMIT;
+END;
+/
diff --git a/labs/converged-database-lab/docker/init/07-ords-enable.sql b/labs/converged-database-lab/docker/init/07-ords-enable.sql
new file mode 100644
index 0000000..ef3d919
--- /dev/null
+++ b/labs/converged-database-lab/docker/init/07-ords-enable.sql
@@ -0,0 +1,23 @@
+-- NOTE: on first boot this always defers — gvenzl initdb scripts run BEFORE the
+-- entrypoint installs ORDS, so ORDS_METADATA does not exist yet. The entrypoint
+-- (docker/scripts/entrypoint.sh) re-runs this exact enable call via sqlplus right
+-- after `ords install` completes. This file documents the call and covers the
+-- volume-reuse case where ORDS is already installed.
+-- ORDS_ADMIN (not ORDS) is required to enable a schema other than the invoker's.
+-- EXECUTE IMMEDIATE so the missing ORDS_METADATA package is a catchable runtime
+-- error, not an anonymous-block compile error (ORA-06550) the handler can't see.
+ALTER SESSION SET CONTAINER = FREEPDB1;
+BEGIN
+ EXECUTE IMMEDIATE q'[
+ BEGIN
+ ORDS_METADATA.ORDS_ADMIN.ENABLE_SCHEMA(p_enabled => TRUE, p_schema => 'LAB_USER',
+ p_url_mapping_type => 'BASE_PATH',
+ p_url_mapping_pattern => 'lab', p_auto_rest_auth => FALSE);
+ COMMIT;
+ END;]';
+EXCEPTION
+ WHEN OTHERS THEN
+ -- ORDS may not be installed yet on first init; will be configured later by entrypoint.
+ DBMS_OUTPUT.PUT_LINE('ORDS enable deferred: ' || SQLERRM);
+END;
+/
diff --git a/labs/converged-database-lab/docker/init/08-vector-model.sql b/labs/converged-database-lab/docker/init/08-vector-model.sql
new file mode 100644
index 0000000..3125b9b
--- /dev/null
+++ b/labs/converged-database-lab/docker/init/08-vector-model.sql
@@ -0,0 +1,79 @@
+ALTER SESSION SET CONTAINER = FREEPDB1;
+
+-- In-database embeddings infrastructure (foundational; used by the AI articles).
+-- Runs once on first boot (gvenzl initdb), after 01-07. Loads Oracle's prebuilt
+-- augmented all-MiniLM-L12-v2 ONNX model (baked into the image at /opt/oracle/models
+-- by docker/Dockerfile.oracle), embeds all 300 ticket bodies into a real VECTOR(384)
+-- column, and builds an IVF vector index on it. Idempotent: every CREATE is guarded
+-- so a recreated container over a persistent volume re-runs cleanly.
+--
+-- Grants used by the AI modules beyond the base 01-lab-user.sql set:
+-- CREATE MINING MODEL — DBMS_VECTOR.LOAD_ONNX_MODEL registers an ONNX model
+-- READ ON ONNX_MODELS — read the baked .onnx from the image directory
+-- EXECUTE DBMS_VECTOR — VECTOR_EMBEDDING / model load
+-- EXECUTE DBMS_RLS — VPD policies (permission-aware retrieval proofs)
+-- CREATE/DROP ANY CONTEXT — application context for VPD tenant/user predicates
+GRANT CREATE MINING MODEL TO lab_user;
+GRANT CREATE ANY CONTEXT TO lab_user;
+GRANT DROP ANY CONTEXT TO lab_user;
+GRANT EXECUTE ON DBMS_VECTOR TO lab_user;
+GRANT EXECUTE ON DBMS_RLS TO lab_user;
+
+-- Directory over the baked model path; READ for lab_user so the load can open it.
+CREATE OR REPLACE DIRECTORY ONNX_MODELS AS '/opt/oracle/models';
+GRANT READ ON DIRECTORY ONNX_MODELS TO lab_user;
+
+ALTER SESSION SET CURRENT_SCHEMA = lab_user;
+
+-- Load the model into the lab_user schema as MINILM_L12 so VECTOR_EMBEDDING
+-- (MINILM_L12 USING AS data) resolves for lab_user. The augmented model
+-- carries its own tokenizer + pooling, so the metadata only needs to declare the
+-- embedding function and the input attribute name (DATA, matching the AS data
+-- alias used everywhere downstream). DROP first for idempotent re-runs.
+DECLARE
+ v_model_path VARCHAR2(400);
+BEGIN
+ BEGIN
+ DBMS_VECTOR.DROP_ONNX_MODEL(model_name => 'MINILM_L12', force => TRUE);
+ EXCEPTION WHEN OTHERS THEN NULL; /* not yet loaded — nothing to drop */
+ END;
+ DBMS_VECTOR.LOAD_ONNX_MODEL(
+ directory => 'ONNX_MODELS',
+ file_name => 'all_MiniLM_L12_v2.onnx',
+ model_name => 'MINILM_L12',
+ metadata => JSON('{"function":"embedding","embeddingOutput":"embedding","input":{"input":["DATA"]}}'));
+END;
+/
+
+-- Real 384-dim embedding column. The module-01 VECTOR(8) `embedding` column and
+-- its IVF index (ticket_vec_idx) are LEFT UNTOUCHED. Guarded so a recreated
+-- container does not error on the already-present column.
+DECLARE
+ v_exists NUMBER;
+BEGIN
+ SELECT COUNT(*) INTO v_exists FROM user_tab_columns
+ WHERE table_name = 'SUPPORT_TICKETS' AND column_name = 'BODY_VEC';
+ IF v_exists = 0 THEN
+ EXECUTE IMMEDIATE 'ALTER TABLE support_tickets ADD (body_vec VECTOR(384, FLOAT32))';
+ END IF;
+END;
+/
+
+-- Embed all 300 ticket bodies in-database — no external embedding service, no
+-- copy pipeline. ~2.5s on the Free container. Re-runnable: recomputes from body.
+UPDATE support_tickets SET body_vec = VECTOR_EMBEDDING(MINILM_L12 USING body AS data);
+
+COMMIT;
+
+-- IVF (NEIGHBOR PARTITIONS) vector index on the real 384-dim column. IVF needs
+-- NO Vector Pool / VECTOR_MEMORY_SIZE, so it builds within the Free container's
+-- SGA. Guarded for idempotent re-runs.
+DECLARE
+BEGIN
+ EXECUTE IMMEDIATE 'DROP INDEX tickets_bodyvec_ivf';
+EXCEPTION WHEN OTHERS THEN NULL; /* absent — first run */
+END;
+/
+CREATE VECTOR INDEX tickets_bodyvec_ivf ON support_tickets(body_vec)
+ ORGANIZATION NEIGHBOR PARTITIONS
+ DISTANCE COSINE;
diff --git a/labs/converged-database-lab/docker/scripts/entrypoint.sh b/labs/converged-database-lab/docker/scripts/entrypoint.sh
new file mode 100644
index 0000000..2c30059
--- /dev/null
+++ b/labs/converged-database-lab/docker/scripts/entrypoint.sh
@@ -0,0 +1,140 @@
+#!/bin/bash
+# Custom entrypoint: starts Oracle Database and ORDS.
+# Oracle is started via the gvenzl base image entrypoint.
+# ORDS is started after Oracle is healthy.
+
+set -uo pipefail
+
+ORDS_CONFIG="/etc/ords/config"
+ORDS_LOG="/tmp/ords.log"
+ORACLE_PDB="FREEPDB1"
+# The install gate is the pool config INSIDE the container (not a marker on the
+# data volume): /etc/ords/config is container-local, so a recreated container
+# loses the pool config even though the database volume persists. `ords install`
+# is a validate/no-op against a database that already has the ORDS schema, and
+# it regenerates the client-side pool config + wallet either way.
+ORDS_POOL_CONFIG="$ORDS_CONFIG/databases/default/pool.xml"
+
+# Start Oracle using the gvenzl base image entrypoint (runs in background)
+/opt/oracle/container-entrypoint.sh &
+ORACLE_PID=$!
+
+# Wait for Oracle to be ready
+echo "=== Waiting for Oracle to be ready ==="
+until /opt/oracle/healthcheck.sh > /dev/null 2>&1; do
+ sleep 2
+done
+echo "=== Oracle is ready ==="
+
+# Install ORDS when this container has no pool config yet (first run, or a
+# recreated container against an existing database volume).
+if [ ! -f "$ORDS_POOL_CONFIG" ]; then
+ echo "=== No ORDS pool config: installing/validating ORDS ==="
+
+ ADMIN_PWD="${ORACLE_PASSWORD:-LabAdmin2026}"
+
+ # If the database already has ORDS (recreated container, persistent volume),
+ # the installer also needs the ORDS_PUBLIC_USER password to rebuild the local
+ # wallet — and that password was generated on the original install. Reset it
+ # to a known value so the non-interactive install can proceed.
+ sqlplus -s / as sysdba <&1 || {
+ echo "=== ORDS install exited with code $?. Checking logs... ==="
+ ls -la /tmp/*.log 2>/dev/null
+ tail -50 /tmp/*.log 2>/dev/null || true
+ }
+
+ # ORDS-enable the lab schema. The initdb-time attempt (07-ords-enable.sql)
+ # always defers on first boot because gvenzl runs init scripts BEFORE this
+ # ORDS install — without this, the MongoDB API exposes no lab_user database.
+ # ENABLE_SCHEMA is an upsert; re-running on a recreated container is safe.
+ echo "=== Enabling ORDS for LAB_USER schema ==="
+ sqlplus -s / as sysdba <<'SQL'
+ALTER SESSION SET CONTAINER = FREEPDB1;
+BEGIN
+ ORDS_METADATA.ORDS_ADMIN.ENABLE_SCHEMA(p_enabled => TRUE, p_schema => 'LAB_USER',
+ p_url_mapping_type => 'BASE_PATH',
+ p_url_mapping_pattern => 'lab', p_auto_rest_auth => FALSE);
+ COMMIT;
+END;
+/
+SQL
+
+ echo "=== ORDS configuration complete ==="
+fi
+
+# Enable MongoDB API. Runs every boot (not just first run): /etc/ords/config is
+# container-local, so a recreated container would otherwise lose these settings
+# even though the database volume (and the first-run marker) persist.
+# NB: the setting is mongo.tls — mongo.tls.enabled is not recognized by ORDS.
+ords --config "$ORDS_CONFIG" config set mongo.enabled true
+ords --config "$ORDS_CONFIG" config set mongo.port 27017
+ords --config "$ORDS_CONFIG" config set mongo.tls false
+
+# Start ORDS
+echo "=== Starting ORDS ==="
+ords --config "$ORDS_CONFIG" serve \
+ --port 8181 \
+ > "$ORDS_LOG" 2>&1 &
+ORDS_PID=$!
+
+echo "=== ORDS started (PID: $ORDS_PID) ==="
+
+# Wait for ORDS to be ready
+echo "=== Waiting for ORDS health ==="
+for i in $(seq 1 90); do
+ if curl -sf -o /dev/null -w '%{http_code}' http://localhost:8181/ 2>/dev/null | grep -q '302\|200'; then
+ echo "=== ORDS is healthy ==="
+ break
+ fi
+ if [ "$i" -eq 90 ]; then
+ echo "=== ORDS not responding after 3 minutes. Log tail: ==="
+ tail -30 "$ORDS_LOG" 2>/dev/null || true
+ echo "=== Continuing without ORDS ==="
+ fi
+ sleep 2
+done
+
+# Monitor both processes — exit if Oracle dies
+echo "=== Oracle + ORDS running. Monitoring... ==="
+while true; do
+ if ! kill -0 "$ORACLE_PID" 2>/dev/null; then
+ echo "=== Oracle process died ==="
+ kill "$ORDS_PID" 2>/dev/null || true
+ exit 1
+ fi
+ if ! kill -0 "$ORDS_PID" 2>/dev/null; then
+ echo "=== ORDS process died, restarting... ==="
+ ords --config "$ORDS_CONFIG" serve \
+ --port 8181 \
+ > "$ORDS_LOG" 2>&1 &
+ ORDS_PID=$!
+ fi
+ sleep 5
+done
diff --git a/labs/converged-database-lab/docker/scripts/install-ords.sh b/labs/converged-database-lab/docker/scripts/install-ords.sh
new file mode 100644
index 0000000..1debca9
--- /dev/null
+++ b/labs/converged-database-lab/docker/scripts/install-ords.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+# Install ORDS into the Oracle container during image build.
+# This script runs as root during `docker build`.
+
+set -euo pipefail
+
+ORDS_VERSION="${ORDS_VERSION:-latest}"
+ORDS_HOME="/opt/oracle/ords"
+ORDS_CONFIG="/etc/ords/config"
+
+echo "=== Installing ORDS ==="
+
+# Install required packages
+microdnf install -y java-17-openjdk-headless unzip curl && microdnf clean all
+
+# Download ORDS
+mkdir -p "$ORDS_HOME" "$ORDS_CONFIG"
+cd /tmp
+curl -fsSL -o ords.zip "https://download.oracle.com/otn_software/java/ords/ords-latest.zip"
+unzip -q ords.zip -d "$ORDS_HOME"
+rm ords.zip
+
+# Make ords CLI available
+ln -sf "$ORDS_HOME/bin/ords" /usr/local/bin/ords
+
+# Set ORDS config directory
+export ORDS_CONFIG
+ords config set --global config.dir "$ORDS_CONFIG"
+
+echo "=== ORDS installed to $ORDS_HOME ==="
diff --git a/labs/converged-database-lab/modules/01-what-is-a-converged-database/README.md b/labs/converged-database-lab/modules/01-what-is-a-converged-database/README.md
new file mode 100644
index 0000000..a2f039e
--- /dev/null
+++ b/labs/converged-database-lab/modules/01-what-is-a-converged-database/README.md
@@ -0,0 +1,130 @@
+# Module 01 — What Is a Converged Database?
+
+Five runnable proofs for the anchor article's core claims. Every script executes
+against the live lab container and emits machine-checkable assertions
+(`ASSERT::PASS|FAIL`). No screenshots, no trust-me — run them yourself.
+
+---
+
+## Proof 1: `scripts/01-one-transaction-every-model.sql`
+
+**Article claim: one transaction boundary across models.** A single ACID
+transaction inserts a relational order and line item, writes a JSON document to
+the `events` collection table, and updates a vector embedding on a support
+ticket — then rolls the whole thing back. The assertions verify all four writes
+are visible *inside* the uncommitted transaction and all four are *atomically
+gone* after `ROLLBACK`. In a polyglot stack that same unit of work spans three
+engines and has no transaction boundary at all.
+
+Expected assertions:
+
+```
+ASSERT:txn-relational-visible:PASS
+ASSERT:txn-document-visible:PASS
+ASSERT:txn-vector-visible:PASS
+ASSERT:rollback-relational:PASS
+ASSERT:rollback-document:PASS
+ASSERT:rollback-vector:PASS
+```
+
+## Proof 2: `scripts/02-duality-roundtrip.js`
+
+**Article claim: a document and its rows are the same data.** Through the
+MongoDB API, the script reads customer 42 from the `customer_profile_dv` JSON
+Duality View, updates `segment` with a plain `updateOne`/`$set`, then reads the
+**relational table** through a `$sql` aggregation stage in the same session —
+and the document write is already there. No sync job moved it: the document and
+the rows are two projections of one stored truth. The script restores the
+original value before exiting.
+
+Expected assertions:
+
+```
+ASSERT:dv-doc-exists:PASS
+ASSERT:dv-doc-updated:PASS
+ASSERT:dv-sql-sees-doc-write:PASS
+ASSERT:dv-restored:PASS
+```
+
+## Proof 3: `scripts/03-one-optimizer.sql`
+
+**Article claim: one optimizer plans across models.** A single SQL statement
+walks the referral property graph from customer 10 (`GRAPH_TABLE` ... `MATCH`),
+joins the reachable customers to their relational rows and support tickets,
+filters on status, and ranks by `VECTOR_DISTANCE` similarity — graph, relational,
+and vector planned together by one cost-based optimizer. A second query proves
+the seeded 4-hop referral cycle (10→11→12→13→10) with a fixed-quantifier
+`MATCH` back to the starting vertex.
+
+Expected assertions:
+
+```
+ASSERT:converged-query-returns:PASS
+ASSERT:graph-cycle-found:PASS
+```
+
+## Proof 4: `scripts/04-read-your-writes.js`
+
+**Article claim: read-your-writes across APIs.** The script inserts a uniquely
+markered document through the MongoDB API and immediately counts it through SQL
+(`$sql` stage) — same call stack, same second, count = 1. There is no CDC
+pipeline, no search-index refresh, no replication lag window in which another
+API sees stale data: both APIs read the same storage. The probe document is
+deleted before exit.
+
+Expected assertions:
+
+```
+ASSERT:read-your-writes-sql:PASS
+ASSERT:probe-cleaned:PASS
+```
+
+## Proof 5: `scripts/05-one-plan.sql`
+
+**Article claim: one cost-based optimizer produces a single plan spanning
+graph, document, vector, and relational.** `EXPLAIN PLAN` captures the plan for
+one statement that walks the referral graph (`GRAPH_TABLE`), joins relational
+`CUSTOMERS`, anti-joins a document-model predicate on the `EVENTS` JSON
+collection (`NOT EXISTS` on `e.data.type.string()`), and orders by
+`VECTOR_DISTANCE` over `SUPPORT_TICKETS`. The assertions then interrogate
+`PLAN_TABLE`: every model appears as an **ordinary row source in one plan
+tree**. That is the proof — Oracle translates `GRAPH_TABLE` internally to
+equivalent SQL, so the graph shows up as a plain `VIEW` (`CUSTOMER_GRAPH`)
+reading the `REFERRALS` edge table through its primary-key index, costed by the
+same optimizer that plans the joins, the JSON predicate, and the vector sort.
+One cost model. No federation seam. In a polyglot stack there is no such plan,
+because no component can see across the wire.
+
+(The `REFERRALS` index name is system-generated, so the graph assertion
+resolves it through `user_indexes` rather than hard-coding it. `EXPLAIN PLAN`
+rows are transactional, so the harness rollback leaves `PLAN_TABLE` empty.)
+
+Expected assertions:
+
+```
+ASSERT:plan-captured:PASS
+ASSERT:plan-spans-graph:PASS
+ASSERT:plan-spans-relational:PASS
+ASSERT:plan-spans-document:PASS
+ASSERT:plan-spans-vector:PASS
+ASSERT:one-plan-tree:PASS
+```
+
+With proof 5 the module emits **20 assertions across five scripts**.
+
+---
+
+## Run It Yourself
+
+```bash
+docker compose up -d --build oracle
+pip install -r validator/requirements.txt
+python validator/run.py
+```
+
+The validator runs every script and exits 0 only if all assertions pass.
+
+**These proofs leave the seeded domain unchanged.** The SQL scripts run inside a
+transaction the harness rolls back; the JavaScript scripts restore or delete
+everything they touch, so the validator can run any number of times with
+identical results.
diff --git a/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/01-one-transaction-every-model.sql b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/01-one-transaction-every-model.sql
new file mode 100644
index 0000000..0415bb9
--- /dev/null
+++ b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/01-one-transaction-every-model.sql
@@ -0,0 +1,37 @@
+-- Proof 1: one ACID transaction spanning every data model, then atomic rollback.
+-- (Validator runs with autocommit off and rolls back; assertions check both phases.)
+-- NOTE: assertion comments ride INSIDE the SELECTs (after the keyword) because the
+-- harness only fetches rows from statements whose text begins with SELECT/WITH.
+
+INSERT INTO orders (customer_id, store_id, status, total_amount)
+VALUES (1, 1, 'placed', 99.99);
+
+INSERT INTO order_items (order_id, line_no, product_id, qty, unit_price)
+VALUES ((SELECT MAX(order_id) FROM orders), 1, 1, 1, 99.99);
+
+INSERT INTO events (data) VALUES (JSON('{"type":"order_placed","channel":"lab","note":"document write, same txn"}'));
+
+UPDATE support_tickets
+ SET status = 'pending',
+ embedding = TO_VECTOR('[0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5]', 8, FLOAT32)
+ WHERE ticket_id = 1;
+
+SELECT /* all four writes visible inside the SAME uncommitted transaction */
+ 'ASSERT:txn-relational-visible:' ||
+ CASE WHEN EXISTS (SELECT 1 FROM orders WHERE total_amount = 99.99 AND status='placed') THEN 'PASS' ELSE 'FAIL' END FROM dual;
+SELECT 'ASSERT:txn-document-visible:' ||
+ CASE WHEN EXISTS (SELECT 1 FROM events e WHERE e.data.type.string() = 'order_placed') THEN 'PASS' ELSE 'FAIL' END FROM dual;
+SELECT 'ASSERT:txn-vector-visible:' ||
+ CASE WHEN (SELECT VECTOR_DISTANCE(embedding, TO_VECTOR('[0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5]',8,FLOAT32), COSINE)
+ FROM support_tickets WHERE ticket_id = 1) < 0.0001 THEN 'PASS' ELSE 'FAIL' END FROM dual;
+
+ROLLBACK;
+
+SELECT /* and atomically gone after rollback */
+ 'ASSERT:rollback-relational:' ||
+ CASE WHEN NOT EXISTS (SELECT 1 FROM orders WHERE total_amount = 99.99 AND status = 'placed') THEN 'PASS' ELSE 'FAIL' END FROM dual;
+SELECT 'ASSERT:rollback-document:' ||
+ CASE WHEN NOT EXISTS (SELECT 1 FROM events e WHERE e.data.type.string() = 'order_placed') THEN 'PASS' ELSE 'FAIL' END FROM dual;
+SELECT 'ASSERT:rollback-vector:' ||
+ CASE WHEN (SELECT VECTOR_DISTANCE(embedding, TO_VECTOR('[0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5]',8,FLOAT32), COSINE)
+ FROM support_tickets WHERE ticket_id = 1) > 0.0001 THEN 'PASS' ELSE 'FAIL' END FROM dual;
diff --git a/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/02-duality-roundtrip.js b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/02-duality-roundtrip.js
new file mode 100644
index 0000000..0cb0f47
--- /dev/null
+++ b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/02-duality-roundtrip.js
@@ -0,0 +1,20 @@
+// Proof 2: a duality view document and its relational rows are the same data.
+// Plain CRUD via the MongoDB API (no beta stages needed); $sql shows the
+// relational view of the same write in the same engine.
+const col = db.getCollection('customer_profile_dv');
+const before = col.findOne({ _id: 42 });
+print('ASSERT:dv-doc-exists:' + (before && before.fullName === 'Customer 42' ? 'PASS' : 'FAIL'));
+
+// Update segment THROUGH THE DOCUMENT API...
+col.updateOne({ _id: 42 }, { $set: { segment: 'vip' } });
+const after = col.findOne({ _id: 42 });
+print('ASSERT:dv-doc-updated:' + (after.segment === 'vip' ? 'PASS' : 'FAIL'));
+
+// ...and read it back through SQL in the SAME api (one engine underneath):
+const rows = db.aggregate([{ $sql: 'SELECT segment AS "segment" FROM customers WHERE customer_id = 42' }]).toArray();
+print('ASSERT:dv-sql-sees-doc-write:' + (rows.length === 1 && rows[0].segment === 'vip' ? 'PASS' : 'FAIL'));
+
+// restore (JS has no rollback; put the row back explicitly)
+col.updateOne({ _id: 42 }, { $set: { segment: 'standard' } });
+const restored = col.findOne({ _id: 42 });
+print('ASSERT:dv-restored:' + (restored.segment === 'standard' ? 'PASS' : 'FAIL'));
diff --git a/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/03-one-optimizer.sql b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/03-one-optimizer.sql
new file mode 100644
index 0000000..1a96924
--- /dev/null
+++ b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/03-one-optimizer.sql
@@ -0,0 +1,30 @@
+WITH
+/* Proof 3: one cost-based optimizer plans across all models in one statement:
+ open/pending tickets from customers reachable in the referral graph from
+ customer 10, ranked by vector similarity, with relational join context. */
+ring AS (
+ SELECT DISTINCT cid FROM GRAPH_TABLE (customer_graph
+ MATCH (a IS customers) -[IS referrals]->{1,4} (b IS customers)
+ WHERE a.customer_id = 10
+ COLUMNS (b.customer_id AS cid))
+)
+SELECT 'ASSERT:converged-query-returns:' ||
+ CASE WHEN COUNT(*) > 0 THEN 'PASS' ELSE 'FAIL' END
+FROM (
+ SELECT c.customer_id
+ FROM ring r
+ JOIN customers c ON c.customer_id = r.cid
+ JOIN support_tickets st ON st.customer_id = c.customer_id
+ WHERE st.status IN ('open','pending')
+ ORDER BY VECTOR_DISTANCE(st.embedding,
+ TO_VECTOR('[0.35,-0.35,0.35,-0.35,0.35,-0.35,0.35,-0.35]', 8, FLOAT32), COSINE)
+ FETCH FIRST 10 ROWS ONLY
+);
+
+SELECT /* cycle detection from the seeded 4-hop referral ring (10→11→12→13→10) */
+ 'ASSERT:graph-cycle-found:' ||
+ CASE WHEN COUNT(*) > 0 THEN 'PASS' ELSE 'FAIL' END
+FROM GRAPH_TABLE (customer_graph
+ MATCH (a IS customers) -[IS referrals]->{4} (a)
+ WHERE a.customer_id = 10
+ COLUMNS (a.customer_id AS cid));
diff --git a/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/04-read-your-writes.js b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/04-read-your-writes.js
new file mode 100644
index 0000000..3f383ae
--- /dev/null
+++ b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/04-read-your-writes.js
@@ -0,0 +1,13 @@
+// Proof 4: write through the document API, read through SQL — same second, no
+// sync pipeline, no lag window. The polyglot equivalent needs CDC + reindex delay.
+const evts = db.getCollection('events');
+const marker = 'rww-' + Math.floor(Math.random() * 1e9);
+evts.insertOne({ type: 'consistency_probe', marker: marker });
+
+const viaSql = db.aggregate([
+ { $sql: 'SELECT COUNT(*) AS "n" FROM events e WHERE e.data.marker.string() = \'' + marker + '\'' }
+]).toArray();
+print('ASSERT:read-your-writes-sql:' + (viaSql.length === 1 && Number(viaSql[0].n) === 1 ? 'PASS' : 'FAIL'));
+
+evts.deleteOne({ marker: marker });
+print('ASSERT:probe-cleaned:' + (evts.countDocuments({ marker: marker }) === 0 ? 'PASS' : 'FAIL'));
diff --git a/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/05-one-plan.sql b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/05-one-plan.sql
new file mode 100644
index 0000000..2c6f22e
--- /dev/null
+++ b/labs/converged-database-lab/modules/01-what-is-a-converged-database/scripts/05-one-plan.sql
@@ -0,0 +1,78 @@
+DELETE /* Proof 5: one cost-based optimizer produces a single plan spanning
+ graph, document, vector, and relational access in one statement. Oracle
+ translates GRAPH_TABLE internally to equivalent SQL, so the plan shows the
+ graph as an ordinary VIEW row source over the REFERRALS edge table — one
+ cost model, no federation seam. This first statement is an idempotence
+ guard: clear any prior rows for this statement id. */
+FROM plan_table WHERE statement_id = 'm01-one-plan';
+
+EXPLAIN PLAN SET STATEMENT_ID = 'm01-one-plan' FOR
+WITH ring AS (
+ SELECT DISTINCT cid FROM GRAPH_TABLE (customer_graph
+ MATCH (a IS customers) -[IS referrals]->{1,4} (b IS customers)
+ WHERE a.customer_id = 10
+ COLUMNS (b.customer_id AS cid))
+)
+SELECT c.customer_id,
+ c.full_name /* selecting a non-key column defeats join elimination so
+ CUSTOMERS survives as a row source in the plan */
+FROM ring r
+JOIN customers c ON c.customer_id = r.cid
+JOIN support_tickets st ON st.customer_id = c.customer_id
+WHERE st.status IN ('open','pending')
+ AND NOT EXISTS (SELECT /* document-model predicate in the same plan */ 1
+ FROM events e
+ WHERE e.data.type.string() = 'suppression'
+ AND e.data.customerId.number() = c.customer_id)
+ORDER BY VECTOR_DISTANCE(st.embedding,
+ TO_VECTOR('[0.35,-0.35,0.35,-0.35,0.35,-0.35,0.35,-0.35]', 8, FLOAT32), COSINE)
+FETCH FIRST 10 ROWS ONLY;
+
+SELECT /* a real costed plan was produced */ 'ASSERT:plan-captured:' ||
+ CASE WHEN COUNT(*) >= 4 THEN 'PASS' ELSE 'FAIL' END
+FROM plan_table WHERE statement_id = 'm01-one-plan';
+
+SELECT /* GRAPH_TABLE lowers to an ordinary VIEW row source named for the
+ graph (CUSTOMER_GRAPH) that walks the REFERRALS edge table — the CBO
+ reads the edges through their PK index, whose name is
+ system-generated, so resolve it via user_indexes */
+ 'ASSERT:plan-spans-graph:' ||
+ CASE WHEN EXISTS (SELECT 1 FROM plan_table
+ WHERE statement_id = 'm01-one-plan'
+ AND object_name = 'CUSTOMER_GRAPH')
+ AND EXISTS (SELECT 1 FROM plan_table p
+ WHERE p.statement_id = 'm01-one-plan'
+ AND (p.object_name = 'REFERRALS'
+ OR p.object_name IN (SELECT i.index_name
+ FROM user_indexes i
+ WHERE i.table_name = 'REFERRALS')))
+ THEN 'PASS' ELSE 'FAIL' END
+FROM dual;
+
+SELECT 'ASSERT:plan-spans-relational:' ||
+ CASE WHEN EXISTS (SELECT 1 FROM plan_table
+ WHERE statement_id = 'm01-one-plan'
+ AND object_name = 'CUSTOMERS')
+ THEN 'PASS' ELSE 'FAIL' END
+FROM dual;
+
+SELECT /* the JSON collection table appears under its own name */
+ 'ASSERT:plan-spans-document:' ||
+ CASE WHEN EXISTS (SELECT 1 FROM plan_table
+ WHERE statement_id = 'm01-one-plan'
+ AND object_name = 'EVENTS')
+ THEN 'PASS' ELSE 'FAIL' END
+FROM dual;
+
+SELECT /* the table feeding VECTOR_DISTANCE is costed in the same plan */
+ 'ASSERT:plan-spans-vector:' ||
+ CASE WHEN EXISTS (SELECT 1 FROM plan_table
+ WHERE statement_id = 'm01-one-plan'
+ AND object_name = 'SUPPORT_TICKETS')
+ THEN 'PASS' ELSE 'FAIL' END
+FROM dual;
+
+SELECT /* every row source above belongs to a single plan tree */
+ 'ASSERT:one-plan-tree:' ||
+ CASE WHEN COUNT(DISTINCT plan_id) = 1 THEN 'PASS' ELSE 'FAIL' END
+FROM plan_table WHERE statement_id = 'm01-one-plan';
diff --git a/labs/converged-database-lab/validator/README.md b/labs/converged-database-lab/validator/README.md
new file mode 100644
index 0000000..9589a7c
--- /dev/null
+++ b/labs/converged-database-lab/validator/README.md
@@ -0,0 +1,64 @@
+# Validator
+
+Runs every module's demo scripts against the live lab container and reports
+pass/fail per script. This is the gate: a module is not done until the
+validator passes against a fresh `docker compose up`.
+
+## Discovery
+
+Scripts live in `modules/NN-slug/scripts/` and run in lexical order (modules,
+then scripts within each module):
+
+- `*.sql` — executed statement-by-statement via python-oracledb (thin mode) as
+ `LAB_USER` against `localhost:1521/FREEPDB1`. PL/SQL blocks end with a line
+ containing only `/`; plain SQL statements end with `;`.
+- `*.js` — executed with `mongosh` inside the `lab-oracle` container against
+ the MongoDB API (port 27017, `lab_user` database).
+
+Connection overrides: `LAB_DSN`, `LAB_USER`, `LAB_PASSWORD`, `LAB_MONGO_URI`.
+
+## Assertions
+
+SQL scripts assert by SELECTing literal strings of the form
+`ASSERT::PASS|FAIL` as the first column:
+
+```sql
+SELECT 'ASSERT:customer-count:' || CASE WHEN COUNT(*) = 200 THEN 'PASS' ELSE 'FAIL' END
+FROM customers;
+```
+
+JS scripts `print()` the same convention:
+
+```js
+print('ASSERT:dv-count:' + (db.customer_profile_dv.countDocuments({}) === 200 ? 'PASS' : 'FAIL'));
+```
+
+Every script must leave the domain unchanged. SQL scripts run in a single
+transaction that the harness rolls back after the script completes. JS scripts
+go through the MongoDB API (auto-commit), so they must clean up explicitly —
+delete what they insert, restore what they update.
+
+## Authoring rules (SQL comments)
+
+- Standalone `--` comment lines before a statement are stripped by the
+ harness and never attach to the statement that follows — safe to use freely.
+- `--` comments inside a statement (after its first code line) and inline
+ `/* ... */` comments are always safe.
+- Do not end a file with a bare comment block followed by `;` — there is no
+ statement there. (Trailing comment-only text after the last `;` is ignored.)
+- `CREATE TYPE` and `CREATE TYPE BODY` are **not supported** in module scripts.
+ The PL/SQL block detector recognises `DECLARE`, `BEGIN`, and named subprograms
+ (`FUNCTION`, `PROCEDURE`, `PACKAGE`, `TRIGGER`) but not type definitions, so
+ type DDL will be mis-parsed and silently dropped. Use anonymous blocks or
+ stored procedures instead.
+
+## Running
+
+```bash
+docker compose up -d --build oracle # wait for healthy
+pip install -r validator/requirements.txt
+python validator/run.py
+```
+
+Exit code is 0 iff every assertion in every script is PASS and no script
+errored. Full detail lands in `validator/results.json` (gitignored).
diff --git a/labs/converged-database-lab/validator/requirements.txt b/labs/converged-database-lab/validator/requirements.txt
new file mode 100644
index 0000000..f9dede2
--- /dev/null
+++ b/labs/converged-database-lab/validator/requirements.txt
@@ -0,0 +1 @@
+oracledb>=2.4
diff --git a/labs/converged-database-lab/validator/run.py b/labs/converged-database-lab/validator/run.py
new file mode 100644
index 0000000..5ac569a
--- /dev/null
+++ b/labs/converged-database-lab/validator/run.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""Execute every module's scripts against the live lab container and report.
+
+Contract: scripts live in modules/NN-slug/scripts/, run in lexical order.
+ *.sql -> executed statement-by-statement via python-oracledb (thin) as LAB_USER.
+ PL/SQL blocks end with a line containing only '/'; plain SQL ends ';'.
+ SELECT output rows whose first column starts with 'ASSERT:' are assertions.
+ *.js -> executed with mongosh inside the lab-oracle container against the
+ MongoDB API (port 27017). Lines printed as 'ASSERT::PASS|FAIL'
+ are assertions.
+Exit code 0 iff every assertion in every module is PASS and no script errors.
+Writes validator/results.json.
+"""
+import json, os, re, subprocess, sys, time
+import oracledb
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DSN = os.environ.get("LAB_DSN", "localhost:1521/FREEPDB1")
+USER = os.environ.get("LAB_USER", "LAB_USER")
+PWD = os.environ.get("LAB_PASSWORD", "LabUser2026")
+# retryWrites=false is required: the Oracle MongoDB API rejects writes from
+# clients that negotiate retryable writes (mongosh's default).
+MONGO_URI = os.environ.get(
+ "LAB_MONGO_URI",
+ f"mongodb://{USER}:{PWD}@localhost:27017/{USER.lower()}"
+ "?authMechanism=PLAIN&authSource=$external&tls=false&loadBalanced=true&retryWrites=false",
+)
+
+def split_sql(text):
+ """Yield executable statements. PL/SQL blocks end with a line '/'; plain SQL ends ';'.
+
+ Standalone '--' comment lines before a statement's first code line are
+ dropped, so every yielded statement starts with code (otherwise a leading
+ comment would defeat the SELECT/WITH fetch check in run_sql and silently
+ swallow assertions). Comments inside a statement are kept — legal SQL.
+ """
+ buf, in_plsql, has_code = [], False, False
+ for line in text.splitlines():
+ stripped = line.strip()
+ if not has_code and stripped.startswith("--"):
+ continue # leading comments never attach to the next statement
+ if not has_code and re.match(r"^(DECLARE|BEGIN|CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE|PACKAGE|TRIGGER))",
+ stripped, re.I):
+ in_plsql = True
+ if in_plsql and stripped == "/":
+ yield "\n".join(buf); buf, in_plsql, has_code = [], False, False
+ continue
+ buf.append(line)
+ if stripped:
+ has_code = True
+ if not in_plsql and stripped.endswith(";") and not stripped.startswith("--"):
+ stmt = "\n".join(buf).strip().rstrip(";")
+ if stmt:
+ yield stmt
+ buf, has_code = [], False
+ rest = "\n".join(buf).strip()
+ if rest and has_code:
+ yield rest.rstrip("/").strip()
+
+def run_sql(path, conn):
+ asserts, errors = [], []
+ with open(path, encoding="utf-8") as fh:
+ text = fh.read()
+ cur = conn.cursor()
+ for stmt in split_sql(text):
+ try:
+ cur.execute(stmt)
+ if stmt.lstrip().upper().startswith(("SELECT", "WITH")):
+ for row in cur.fetchall():
+ cell = str(row[0]) if row else ""
+ if cell.startswith("ASSERT:"):
+ asserts.append(cell)
+ except Exception as e:
+ errors.append(f"{os.path.basename(path)}: {e}\n stmt: {stmt[:200]}")
+ conn.rollback() # demos must leave the domain unchanged
+ return asserts, errors
+
+def run_js(path):
+ cmd = ["docker", "exec", "-i", "lab-oracle", "mongosh", "--quiet", MONGO_URI,
+ "--file", "/dev/stdin"]
+ with open(path, "rb") as fh:
+ proc = subprocess.run(cmd, stdin=fh, capture_output=True, text=True, timeout=300)
+ out = proc.stdout + proc.stderr
+ asserts = re.findall(r"ASSERT:[\w-]+:(?:PASS|FAIL)", out)
+ errors = [] if proc.returncode == 0 else [f"{os.path.basename(path)}: mongosh rc={proc.returncode}\n{out[-800:]}"]
+ return asserts, errors
+
+def main():
+ t0 = time.time()
+ results, ok = [], True
+ conn = oracledb.connect(user=USER, password=PWD, dsn=DSN)
+ mod_dir = os.path.join(ROOT, "modules")
+ for mod in sorted(os.listdir(mod_dir)):
+ scripts = os.path.join(mod_dir, mod, "scripts")
+ if not os.path.isdir(scripts):
+ continue
+ for script in sorted(os.listdir(scripts)):
+ path = os.path.join(scripts, script)
+ if script.endswith(".sql"):
+ asserts, errors = run_sql(path, conn)
+ elif script.endswith(".js"):
+ asserts, errors = run_js(path)
+ else:
+ continue
+ fails = [a for a in asserts if a.endswith(":FAIL")]
+ status = "PASS" if not fails and not errors else "FAIL"
+ ok = ok and status == "PASS"
+ results.append({"module": mod, "script": script, "status": status,
+ "assertions": asserts, "errors": errors})
+ print(f"[{status}] {mod}/{script} ({len(asserts)} assertions)")
+ for e in errors:
+ print(f" ERROR {e}")
+ conn.close()
+ summary = {"validated_against": "Oracle AI Database 26ai Free (gvenzl 23.26.x image)",
+ "elapsed_s": round(time.time() - t0, 1),
+ "all_pass": ok, "results": results}
+ with open(os.path.join(ROOT, "validator", "results.json"), "w") as f:
+ json.dump(summary, f, indent=2)
+ print(f"\n{'ALL PASS' if ok else 'FAILURES'} in {summary['elapsed_s']}s")
+ sys.exit(0 if ok else 1)
+
+if __name__ == "__main__":
+ main()
diff --git a/sonar-project.properties b/sonar-project.properties
index dc80191..a38c4bc 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,4 +1,4 @@
-sonar.projectKey=oracle-devrel_terraform-oci-ocloud-asset-database
+sonar.projectKey=oracle-devrel_oracle-umt-developer-hub
sonar.organization=oracle-devrel
# This is the name and version displayed in the SonarCloud UI.