Skip to content

Spark 4.0 / Iceberg 1.11 / deletion vectors — REST-first upgrade (consolidated) - #17

Draft
mkuchenbecker wants to merge 55 commits into
mainfrom
1.11
Draft

Spark 4.0 / Iceberg 1.11 / deletion vectors — REST-first upgrade (consolidated)#17
mkuchenbecker wants to merge 55 commits into
mainfrom
1.11

Conversation

@mkuchenbecker

Copy link
Copy Markdown
Owner

Summary

Consolidated branch (1.11) that moves OpenHouse's engine integration to Spark 4.0 / Iceberg 1.11 / Scala 2.13 / Java 17 with v3 DataSource-V2 deletion vectors, via a REST-first cutover: Spark talks to OpenHouse through a stock Iceberg RESTCatalog against a new server-side /iceberg endpoint — no custom Spark catalog jar. Built on top of current main (incl. #13 modular harness, linkedin#640 RTAS gate, linkedin#641 metadata hook, linkedin#644 config map). Supersedes the earlier stacked branches (upgrade + validation-parity), folded here per one-branch consolidation.

Changes

  • Internal API Changes — new IcebergRestCatalogController (/iceberg/v1/...) driving CatalogHandlers over OpenHouseInternalCatalog; updateTable composes CTAS→staged-create, RTAS→service replace path (inherits Gate RTAS with 'replace.enabled' table property linkedin/openhouse#640's replace.enabled gate), and recovers server-side update-validation (lock / reserved-property / partition- and schema-evolution) on the plain-UPDATE branch that otherwise bypasses the service. Inverse PartitionSpecMapper for stock-client CREATE.
  • New Features — CREATE TABLE / CTAS / RTAS and format-version=3 deletion vectors over the REST lane.
  • Build — server core + spark-4.0 lane point at org.apache.iceberg:*:1.11.0-openhouse (the LinkedIn custom patches ported onto Apache 1.11; source: mkuchenbecker/[email protected], incl. the F-REPL delete-file-replication fix).
  • Refactoring/Infra — docker oh-hadoop-spark recipe wired for real-HDFS validation (Spark 3.5 / Hadoop 3.2 / Java-11 base); spark-4.0 itest classpath module + delta-harness Spark-4.0/Scala-2.13 wiring (Phase 2, landing on this branch).
  • Documentation — docs/spark4-iceberg-upgrade/ design-of-record + findings.

Testing Done

  • :services:tables:build -x test → BUILD SUCCESSFUL on the consolidated branch (JDK 17).
  • Delta-harness full matrix on Spark 4.0 / Iceberg 1.11 / stock RESTCatalog — in progress on this branch (Phase 2); prior (pre-consolidation) run was 1637/28/32 with CTAS + 13/18 validation cases already recovered separately, so the consolidated failure count is expected to drop to the residual custom-SQL ParseException set. Actual numbers will be posted when Phase 2 completes.
  • v3 deletion vectors verified (Puffin deletion-vector-v1, reads reflect deletes, round-tripped through the server v3-write path); real-HDFS validation of write/read + metadata-direct-write on the ported fork.

Additional Information

  • Large change; history is intentionally granular (one commit per logical change) rather than squashed.
  • Known REST-lane gaps documented: custom OpenHouse SQL (SET POLICY/GRANT/column-tags) has no Spark SQL extension parser on the stock lane (validate via table properties); a few residual parity cases (rename exception-type, namespace-existence semantics, concurrency-CAS) are catalogued in docs/spark4-iceberg-upgrade/.
  • Custom Spark 4.0 commands (OPTIMIZE/VACUUM) are merged separately in mkuchenbecker/[email protected]; wiring OpenHouse's build to that custom Spark distribution is a separate follow-up (the harness here runs stock spark-sql_2.13:4.0.0).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A


Generated by Claude Code

mkuchenbecker and others added 30 commits July 22, 2026 00:35
…openhouse

Re-apply the Iceberg 1.11 version wiring (REST-first Spark-4.0 upgrade) onto main:
- root build.gradle: add iceberg_1_11_version = "1.11.0-openhouse"; add mavenLocal()
  to the allprojects repositories block (the fork artifacts are published there).
- openhouse.iceberg-conventions-1.5.2.gradle: move the server core (services/tables +
  iceberg/* + tables-test-fixtures) from com.linkedin.iceberg:*:1.5.2.15 to stock
  org.apache.iceberg:*:1.11.0-openhouse; raise the TARGET_JVM_VERSION resolution
  attribute (resolvable configs only) to 17, since 1.11's module metadata declares
  jvm.version=17 while the compile target stays at -target 8 (HDFS-client requirement).
- openhouse.java-minimal-conventions.gradle: same 17 resolution-attribute relax.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…TE TABLE

Server-side implementation of the Iceberg REST Catalog spec, mounted under
/iceberg/v1, so a stock org.apache.iceberg.rest.RESTCatalog (Spark 4.0) can drive
OpenHouse with no custom client jar.

- IcebergRestCatalogController: config, namespace (implicit-database) ops, table
  list/load/exists/drop/rename delegating to Iceberg CatalogHandlers -> the
  OpenHouseInternalCatalog bean, and CREATE TABLE translated into an OpenHouse
  CreateUpdateTableRequestBody driven through the same TablesApiHandler.createTable
  pipeline the native controller uses (location allocation, reserved openhouse.*
  props, policy management, eligibility). Errors mapped to the Iceberg
  ErrorResponse envelope via local @exceptionhandlers. Staged create still 501s here
  (CTAS/RTAS handled in a follow-up commit).
- PartitionSpecMapper: add the inverse mappers toTimePartitionSpec(Schema,
  PartitionSpec) and toClusteringColumns(Schema, PartitionSpec) so a REST-created
  table maps to the same OpenHouse model as a natively created one; specs OpenHouse
  cannot model are rejected (HTTP 400).
- docs/spark4-iceberg-upgrade/rest-endpoint: design, decisions, pitfalls.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Close the CTAS/RTAS gap in IcebergRestCatalogController so a stock Spark 4.0
RESTCatalog can run CREATE TABLE AS SELECT and (CREATE OR) REPLACE TABLE AS SELECT
against the OpenHouse /iceberg endpoint.

- createTable: stageCreate=true no longer 501s; treated as a plain create-then-commit
  via the same TablesApiHandler.createTable pipeline (accepted non-atomic compromise
  -- OpenHouse commits on create).
- updateTable routes three ways off the Iceberg UpdateRequirement/update fingerprint
  the stock client stamps:
  * CTAS data commit (AssertTableDoesNotExist): keep only the snapshot updates and
    land them onto the already-created table via CatalogHandlers, like an INSERT.
  * RTAS commit (the replace identity-reset trio SetCurrentSchema +
    SetDefaultPartitionSpec + SetDefaultSortOrder): reconstruct the final
    TableMetadata and route through OpenHouse's replace pipeline via
    IcebergSnapshotsApiHandler (replaceCommit=true), so the service-layer replace
    branch (validateReplaceTable -> replace.enabled/WAP/replication gating, linkedin#640)
    and replace semantics apply.
  * Everything else: unchanged delegation to CatalogHandlers.updateTable.
- docs/spark4-iceberg-upgrade/rest-ctas/; rest-endpoint pitfalls #1 updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…it path

A stock Spark 4.0 RESTCatalog drives plain writes through
IcebergRestCatalogController.updateTable -> CatalogHandlers.updateTable ->
OpenHouseInternalCatalog TableOperations.commit, which bypasses the service-layer
update validation the native path runs in TablesService.putTable ->
OpenHouseInternalRepositoryImpl.save. Restore those guards on the REST plain-update
branch by pre-inspecting the projected commit before delegating, mirroring save()'s
non-replace branch:
- table LOCK enforcement (policies.lockState.locked)
- reserved openhouse.*/policies property immutability (also covers
  openhouse.tableType immutability)
- partition-spec evolution rejection
- schema-evolution validation, reusing the same SchemaValidator bean
Adds InvalidSchemaEvolutionException to the 400 handler.

Reconciliation with the CTAS/RTAS routing and linkedin#640: the guards run ONLY on the plain
UPDATE branch. Staged-create (AssertTableDoesNotExist) and RTAS (isReplacePayload) are
routed away in updateTable() before the guards. RTAS eligibility (replace.enabled +
WAP/replication) is enforced exactly once -- in the service replace branch
(validateReplaceTable, linkedin#640) reached via replaceTable() -- so the replace gate is NOT
duplicated in the controller. The parity branch's controller-side replace gate
(enforceReplaceAllowed) and its separate parentId==null replace detector are therefore
dropped: a single RTAS detector (isReplacePayload) remains, and a single replace gate
(service layer) remains.

docs/spark4-iceberg-upgrade/rest-validation-parity/.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
… bake the fork jar

Bring the docker-compose recipe fixes from the spark4-upgrade HDFS validation onto main:
- spark-services.yml: point the spark master/worker/livy builds at
  spark-3.5-base-hadoop3.2.dockerfile (was spark-base-hadoop2.8).
- hdfs-services.yml: bde2020 hadoop-3.2.1 namenode+datanode (was 2.8).
- oh-hadoop-spark/hadoop/hadoop.env: dfs.replication=1 + fs.defaultFS for the
  single-datanode local cluster.
- spark-3.5-base-hadoop3.2.dockerfile: trust staged extra-ca-certs (system +
  JDK cacerts) so the builder can fetch behind a TLS-terminating egress proxy;
  runtime base -> eclipse-temurin:11-jdk-jammy (fork Iceberg is class-file 55);
  bake the fork iceberg-spark-runtime jar onto $SPARK_HOME/jars. extra-ca-certs
  dir committed with README/.gitignore (real *.crt staged per-environment).
- build.gradle CopyGitHooksTask: resolve the real gitdir so it works in a linked
  git worktree (.git is a file) under Gradle 8.
- apps-spark-common shadowJar: also exclude META-INF/versions/{20,21} (newer
  jackson-core multi-release variants that shadow 7.1.2's ASM cannot read).
- docs/spark4-iceberg-upgrade/docker-hdfs-validation/ + frepl-fix/.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Additive documentation for the REST-first Spark-4.0 / Iceberg-1.11 upgrade: the phase
plan, risks/decisions/findings, rung-2 detailed plan, BUILD-STATUS, and the
harness-spark4 / rung3-dv progress+pitfalls notes. Docs only; no code or build impact.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…REST-first)

Phase 2 of the consolidation onto branch 1.11. Adds
:integrations:spark:spark-4.0:openhouse-spark-4.0-itest — a source-less module
whose only job is to resolve+build the harness runtime classpath for the
Spark-4.0 / Scala-2.13 / JDK-17 lane:
  * embedded OpenHouse server (tables-test-fixtures -> services:tables, which
    mounts IcebergRestCatalogController at /iceberg/v1/*), and
  * STOCK unshaded iceberg-spark-4.0_2.13 (1.11.0-openhouse) pointed at a stock
    RESTCatalog (no custom OpenHouse Spark runtime/catalog).

configureEach requests jvm.version=17 so the Spark-4.0 (17) + Iceberg-1.11
(17) + Java-8-bytecode server jars co-resolve; forces the javax-namespace
validation + a single 2.18.2 jackson + 1.12.1 avro stack; excludes the stale
com.linkedin.iceberg fork cores; and — new for this lane — excludes the
unshaded org.apache.hadoop:*:2.10.0 stack the server drags, so Spark 4.0's
shaded hadoop-client-api/runtime 3.4.1 is the SOLE org.apache.hadoop.fs.*
provider (the 2.10 FileSystem lacks openFile(Path):FutureDataInputStreamBuilder
and otherwise shadows 3.4.1 -> NoSuchMethodError on every read task).

Registered in settings.gradle as openhouse-spark-4.0-itest.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…tock RESTCatalog

Phase 2 version+wiring port of the MODULAR harness (no test logic/assertions
changed). Mirrors the equivalent work done on the old monolithic harness.

Env.scala (OpenHouseEnv): flip the catalog from the native OpenHouseCatalog to
a stock Iceberg RESTCatalog against the embedded server's /iceberg endpoint —
catalog-impl=org.apache.iceberg.rest.RESTCatalog, uri=$base/iceberg,
token=<bearer> (was cluster/auth-token); register stock IcebergSparkSession
Extensions only (the custom OpenHouse policy-DDL extension is not shipped on the
REST client); and pin spark.driver.host=127.0.0.1 (Spark 4.0 local-executor
remote-classloader fix; bindAddress alone sufficed on 3.5).

run-openhouse.sh: compile with scalac 2.13.16 (Spark 4.0 is Scala 2.13 only),
resolve the classpath from the spark-4.0 itest module via -DharnessItestPath,
and apply Spark 4.0's documented --add-opens superset. Keeps the modular
multi-file compile (DvProbe excluded — it has its own main).

print-cp.init.gradle: honor -DharnessItestPath; apply the single-Iceberg
exclusion only on the shaded (3.5) lane; dependsOn testRuntimeClasspath so
project jars are built, not just resolved.

ForkScenarios.scala: Iceberg 1.11 removed SparkSQLProperties.SPLIT_SIZE
(session split-size superseded by adaptive split sizing); inline its literal
"spark.sql.iceberg.split-size" — the constant was a static final String, so
this is bytecode-identical and preserves the test's behavior.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…r the 1.11 base

Brings over DvProbe + run-dvprobe.sh, adapted to the modular harness's
OpenHouseEnv.start() 5-tuple. Boots the embedded server with
cluster.iceberg.format-version=3 and drives a merge-on-read table through
DELETE/UPDATE/MERGE, then reports whether the delete path produced Puffin
deletion vectors (deletion-vector-v1 blobs) and checks read correctness.

Confirmed on the consolidated 1.11 base (republished 1.11.0-openhouse fork,
Spark 4.0 / stock RESTCatalog): parquet + orc both write 3 .puffin files each
carrying a deletion-vector-v1 blob (content=1 POSITION_DELETES, fmt=PUFFIN),
and the post-mutation read count is 4 (expected 4).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…alidator

The native table-lock endpoint (POST /v1/databases/{db}/tables/{tbl}/lock ->
TablesController.createLock -> OpenHouseTablesApiHandler.createLock ->
OpenHouseTablesApiValidator.validateCreateLock) returned HTTP 500 on the
iceberg-1.11 / main-based 1.11 base.

Root cause: validateCreateLock -> validateGetTable -> validateDatabaseId /
validateTableId call StringUtils.isEmpty from the legacy commons-lang 2.x
(package org.apache.commons.lang). The iceberg-1.11 upgrade / main
reconciliation dropped commons-lang 2.x from the tables service runtime
classpath, so the first reference to it threw NoClassDefFoundError:
org/apache/commons/lang/StringUtils (ClassNotFoundException), surfaced by
Spring as INTERNAL_SERVER_ERROR. The e2e H2 tests still had commons-lang 2.x
transitively, which is why they stayed green while the delta-harness embedded
server (iceberg-1.11 itest classpath) 500'd.

Fix: switch the import to org.apache.commons.lang3.StringUtils (commons-lang3,
already a project dependency and present on the runtime classpath — proven by
convertToTableDto's existing lang3 usage). StringUtils.isEmpty has identical
null/empty semantics in both libraries, so this restores the existing lock
contract with no behavioral change. No new endpoint or shim added.

Verified via delta-harness on branch 1.11 (embedded OpenHouse server):
  PASS control.lock.enforcement
  PASS hazard.lock.starvesMaintenance
  PASS interact.rtas.onLockedTable

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…s to lang3

Same root cause as the lock-endpoint 500: the iceberg-1.11 / main-based
reconciliation dropped commons-lang 2.x from the runtime classpath. These two
main-source references (databases validator, OTel config) would throw the same
NoClassDefFoundError when their path is hit. lang3 is already a dependency and
the used methods (isEmpty/isNotEmpty/defaultIfEmpty) are semantically identical.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
… JVM-attr fix

CI could not build the 1.11 branch: the OpenHouse build resolves the custom
fork org.apache.iceberg:*:1.11.0-openhouse, which exists only as SOURCE on
mkuchenbecker/[email protected] and in a local mavenLocal, not in any CI-reachable
artifact repo.

Workflow gating (option 1a, 1.11-only):
- New .github/workflows/branch-1.11.yml fires the reusable build-run-tests
  workflow on pushes/merges to the 1.11 branch (rare).
- build-run-tests.yml gains two steps, gated `if: github.ref == 'refs/heads/1.11'`,
  that checkout mkuchenbecker/[email protected] and publishToMavenLocal
  (JDK 17, -DforceVersion=1.11.0-openhouse -DsparkVersions=4.0,
  core/api/common/data/parquet/orc/arrow/bundled-guava + spark-4.0_2.13 family,
  -x test -x compileTestJava -x compileTestScala) BEFORE the OpenHouse build.
  github.ref is inherited from the caller, so ordinary PRs (pr-validations,
  ref=refs/pull/N/merge) and push-to-main (build-tag-publish, ref=refs/heads/main)
  SKIP the expensive fork build.

In-tree JVM-attribute fix:
- java-test-fixtures re-derives TARGET_JVM_VERSION=8 from targetCompatibility and
  re-stamps it onto testFixtures*Classpath AFTER the convention plugin's
  configureEach ran, so those configs could no longer resolve the jvm.version=17
  Iceberg 1.11 artifacts on a stock ./gradlew build. Added an afterEvaluate
  override in openhouse.iceberg-conventions-1.5.2.gradle (the 1.11 lane) that
  re-asserts TARGET_JVM_VERSION_ATTRIBUTE=17 on resolution-only configs. Scoped to
  the 1.11 convention plugin, so the spark-3.5/iceberg-1.10 (Java 8/11) lane is
  untouched. A plain `gradle :services:tables:build -x test` now succeeds with no
  init script.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…nfigs only

The full clean build crashed configuring :all-modules — the previous
afterEvaluate override touched every resolvable config incl. annotationProcessor,
which cross-project aggregation had already resolved (Cannot change attributes
after resolved). Restrict the override to the six real classpath configs that
consume iceberg-1.11 and skip any already-resolved config (state==UNRESOLVED),
so a plain gradle build works without breaking the previously-green build.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…le rung ledger

Single authoritative current-state doc: done items, the five decisions + status,
and the remaining critical path (finish CI green; custom Spark 4.0 + both lanes).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…ves to iceberg-1.11

Repointing the shared openhouse.iceberg-conventions-1.5.2 convention to
org.apache.iceberg:1.11.0-openhouse left the spark-3.5/1.5 apps lane resolving
version-less com.linkedin.iceberg coordinates and inheriting the jvm-17
resolution attribute. Pin the com.linkedin.iceberg coordinates on apps/spark-3.5
to the 1.5 line it targets, and keep the jvm-17 attribute scoped to the 1.11
consumers — so a full 'clean build' no longer breaks the legacy lane.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…mer-config

The afterEvaluate attribute-mutation approach raced fatally against every config
resolved early during configuration (:all-modules aggregation, client codegen,
Spring Boot productionRuntimeClasspath) — 'Cannot change attributes ... after it
has been resolved', a different config each push. Replace it with a
ComponentMetadataRule that relaxes the org.apache.iceberg artifacts' declared
jvm.version to 8 once, at metadata time. No consumer configuration is mutated, so
the race surface is gone; a stock ./gradlew build resolves iceberg-1.11 on Java-8
target modules. Verified locally: :services:housetables:productionRuntimeClasspath
resolves iceberg-core:1.11.0-openhouse cleanly. Scoped to org.apache.iceberg, so
the com.linkedin.iceberg 1.5/spark-3.5 lane is untouched.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Four upgrade-caused failures in the services:tables suite after the server
core moved to org.apache.iceberg:1.11.0-openhouse:

1. avro downgrade -> NoSuchMethodError. iceberg-avro's TypeToSchema.<clinit>
   calls LogicalTypes.timestampNanos() (avro >= 1.12.0), but the root
   allprojects block force-pins avro 1.11.4 (CVE-2024-47561), downgrading
   iceberg-1.11's strict 1.12.1 pin. Re-force avro 1.12.1 in this iceberg-1.11
   module only (1.12.1 > 1.11.4, so the CVE stays fixed; the iceberg-1.5 lane
   is untouched). Mirrors the spark-4.0 itest lane.

2. Mock Spring context could not load: the new IcebergRestCatalogController
   (component-scanned) autowires a SchemaValidator whose impl lives in the
   repository.impl package, which MockTablesApplication intentionally does not
   scan. Provide the BaseIcebergSchemaValidator bean explicitly. This cleared
   a ~430-test ApplicationContext cascade.

3. evolved_dummy_healthy_schema.json had "initial-default": null. iceberg-1.5
   ignored a null-valued default key; iceberg-1.11 SchemaParser.defaultFromJson
   sees the key present and calls Expressions.lit(null) -> NPE. iceberg-1.11
   serialization omits null defaults entirely, so drop the line (the fixture
   tests adding an optional field, not default semantics).

4. RepositoryTestWithSettableComponents.testNoRetryInternalRepo asserted
   refresh() is called exactly twice as a white-box no-retry proxy. iceberg-1.11
   BaseTransaction drives applyUpdates()->refresh() once per retry attempt
   (base commit.retry.num-retries default 4 => 5 attempts) + 1 from
   newTransaction = 6. The authoritative no-retry signal (htsRepo.save called
   exactly once) still holds. Pin the count to 6 with an explanatory comment.
   This also fixed a cross-test cascade: when this test threw at the old
   assertion it skipped its catalog.dropTable cleanup, leaking d1.t1 and
   making SnapshotsControllerTest's later creates 500.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…1.11

Two upgrade-caused failure modes, both from iceberg-1.11's data IO rework:

1. avro downgrade -> NoSuchMethodError (LogicalTypes.timestampNanos) /
   NoClassDefFoundError (TypeToSchema$WithTypeToName). Same root as
   services:tables: the root allprojects block force-pins avro 1.11.4 for
   CVE-2024-47561, downgrading iceberg-1.11's strict 1.12.1 pin. Re-force
   1.12.1 in this iceberg-1.11 module (1.12.1 > 1.11.4, CVE stays fixed).

2. NoClassDefFoundError: ParquetValueWriter / cannot init FormatModelRegistry.
   iceberg-1.11 routes generic data read/write through a FormatModelRegistry,
   and iceberg-data's GenericFormatModels.register() (run at the generic
   reader's static init) hard-references ParquetFormatModel and ORCFormatModel.
   iceberg-data no longer pulls iceberg-parquet/iceberg-orc transitively, so the
   generic reader this module uses fails to class-init. Add both explicitly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…ceberg-1.11

The spark-3.5 itest compiles against the embedded 1.11 server's iceberg-core
(1.11.0-openhouse), and 1.11 removed the addSchema(Schema, int lastColumnId)
overload in favor of addSchema(Schema) (the builder derives lastColumnId from
the schema's highest field id). SparkMultiSchemaEvolutionTest called the removed
2-arg form in 6 places, breaking the itest compileTestJava task. Switch to the
1-arg form; behavior is preserved since the derived lastColumnId equals the
highestFieldId that was passed explicitly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…lier commits

Purely cosmetic javadoc line-wrapping fixes that spotlessApply produces on four
files touched during the 1.11 work (REST controller, PartitionSpecMapper, an
e2e test, and the H2 test-fixtures repository). No code changes. Keeps the
pre-commit spotless gate green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Adds test sources to the spark-4.0 itest module (previously classpath-only for
the delta-harness). OpenHouseRestSparkITest boots the embedded 1.11 OpenHouse
server and wires Spark 4.0 to the STOCK org.apache.iceberg.rest.RESTCatalog at
/iceberg (bearer token), mirroring harness.OpenHouseEnv. Client and server share
ONE Iceberg (1.11.0-openhouse), so the in-JVM two-version collision that sinks
the legacy 1.5-client/1.11-server e2e lane cannot occur here.

Includes the Spark-4.0-on-Java-17 add-opens set and the spark.driver.host pin
(the local executor fetches codegen classes over the driver's netty RPC; without
it the fetch is refused with RemoteClassLoaderError, which poisons Iceberg 1.11's
FormatModelRegistry.<clinit>).

First ported class: CatalogOperationTestSpark4_0 (3 pure-SQL cases, all green).
Port plan for the rest of the catalogtest suite in
docs/spark4-iceberg-upgrade/spark4-e2e-tests/00-PLAN.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
The legacy Spark-3.5 e2e suites embed the OpenHouse server (now Iceberg 1.11) in
one JVM alongside a 1.5 Iceberg client / data-plane, so two org.apache.iceberg
class-sets collide (NoSuchMethodError / NoClassDefFoundError). This is a
test-harness artifact — in production the Spark job and the server are separate
processes — and none of what these suites cover is on the Spark-4.0 REST-first
1.11 lane (stock RESTCatalog, no custom OpenHouse SQL extension).

- openhouse-spark-3.5-itest: whole module test execution disabled (measured on
  1.11: catalogTest 58/58, statementTest 60/60, test 49/90 failing). compileTestJava
  still runs. Coverage moves to the Spark-4.0 REST port + the delta-harness.
- apps-1.5: the 6 data-plane compaction / merge-on-read delete-file cases
  (OperationsTest x2, SparkMoRFunctionalTest x4) excluded; the other 220 still run
  and pass. No Spark-4.0 apps module exists yet, so these have no 1.11 replacement.

Both gates are documented and reversible in
docs/spark4-iceberg-upgrade/spark4-e2e-tests/20-legacy-gated.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Adds PartitionTestSpark4_0 (4 green), CTASNonNullTestSpark4_0 (1 green) and
WapIdTestSpark4_0 (9 green) to the Spark-4.0 / Iceberg-1.11 / REST-first
itest module, extending OpenHouseRestSparkITest. Pure-SQL and stock Iceberg
procedure cases port verbatim. Records residuals: the custom SET POLICY/GRANT
statements in the WAP workflow are dropped, and two engine-behavioral deltas
(nested-transform DESCRIBE rendering, expire-on-empty-refs) are noted.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Adds BranchTestSpark4_0 (17 green): multi-branch WAP staging, cherry-pick,
fast_forward and expire_snapshots. All stock Iceberg SQL + openhouse.system.*
procedures, so a verbatim base-class swap to OpenHouseRestSparkITest.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Adds RTASTestSpark4_0 (5 green) and BranchJavaTestSpark4_0 (1 green), both
driving the Iceberg Java API through the stock RESTCatalog. The replace.enabled
gate is still enforced by the embedded OpenHouse server and surfaces as a
BadRequestException via the REST envelope. Drops the custom SET POLICY line and
the policies-property assertion from RTAS (recorded in 10-RESIDUALS.md).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Adds the remaining CatalogOperationTest cases to the Spark-4.0 REST lane (14
green total): catalog write API, catalog-API rename, case-sensitivity rename,
WRITE ORDERED BY sort-order set/unset/CTAS/multi-column/null-order/insert, and
the two field-id-reassignment cases. Fixes a REST-lane wiring issue where the
OpenHouse /iceberg controller NPEs on a null PartitionSpec (now passes
PartitionSpec.unpartitioned()). Drops the SET POLICY case, the openhouse.tableUri
assertion, and testRenameTableFailsConflict (rename-onto-existing is not rejected
on this lane). All residuals recorded in 10-RESIDUALS.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…iduals

Per direction: gated/failing tests are a triage-and-fix backlog to be worked
once the Spark-4.0 e2e port completes, not ignored. Enumerates each gated group
(catalogtest, statementtest, default test, apps-1.5 data-plane) as fix items with
the concrete fix strategy per group.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…aEvolution) + fix checklist

Final batch of the Spark-4.0 / Iceberg-1.11 / REST-first e2e port (sub-agent
work): InvalidMetadataTestSpark4_0 and SparkMultiSchemaEvolutionTestSpark4_0,
plus expanded CatalogOperation Java-API cases and base-class helpers.

67 @test across 9 classes; 2 @disabled kept in code (not deleted) with the exact
failure, root cause, and fix recorded in 10-RESIDUALS.md as a fix backlog:
- testAlterTableUnsetReplicationPolicy (needs custom OpenHouse SQL ext / policies
  model on the REST lane)
- testRenameTableFailsConflict (server: /iceberg rename should 409 on existing dst)
Plus documented inline custom-SQL omissions and engine deltas.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…fix investigation note

Applies the spotless javadoc reflow to SparkMultiSchemaEvolutionTestSpark4_0 and
records attempt-1 findings on the rename-409 backlog item (a controller
tableExists guard did not trip via the Spark-4.0 SQL rename path; the actual REST
request must be traced first). testRenameTableFailsConflict stays @disabled on the
fix backlog — not shipped unverified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
mkuchenbecker and others added 25 commits July 26, 2026 04:42
The test created its REPLICA_TABLE via the Iceberg Java API with openhouse.isTable
Replicated=true, which trips the server's validateUUIDForReplicaTable guard
(requires a valid last-updated-ms) — so createTable threw BadRequestException and
the bare finally DROP masked it as NoSuchTableException. The test only needs
tableType=REPLICA_TABLE (to permit the direct multi-schema ops.commit), exactly
like the sibling REPLICA_TABLE tests which don't set isTableReplicated. Dropped the
extra flag; also made cleanup DROP TABLE IF EXISTS so any future body failure
surfaces its real cause instead of a masking NoSuchTableException.

This was the only failing test in the spark-4.0 e2e suite (was 65/1/2 -> 66/0/2).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Diagnosed: corrupt current-schema-id -> refreshMetadata throws
InvalidTableMetadataException; the INSERT's Iceberg commit-retry loop retries it
up to the ~30min total-timeout, so the expected failure surfaces after ~19min.
Fix (backlog): map corrupt-metadata to a non-retryable 4xx server-side. Test
passes; runtime cost only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
… → ~19min retry)

InvalidTableMetadataException fell through the controller's exception handlers to
handleGeneric -> HTTP 500. The stock RESTCatalog client treats that as a transient
server failure and retries the read/commit with exponential backoff up to ~30 min,
so InvalidMetadataTestSpark4_0.testCorruptSchemaIdSurfacesRealError took ~19 min to
surface the (correct) error — dominating the branch-1.11 CI at ~39 min.

Corrupt/unparseable stored metadata is a permanent bad-state, not a transient
failure, so map it to a non-retryable 400. The 'has invalid metadata' message is
preserved. Verified locally: the test now completes fast (build 5m17s incl. the
services:tables recompile, vs the test alone previously ~19 min). Halves CI time.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…ST lane)

Renaming a table ONTO an already-existing table was silently accepted on the
Spark-4.0 / Iceberg-1.11 REST-first lane. The covering test
CatalogOperationTestSpark4_0#testRenameTableFailsConflict was @disabled and
asserted a custom-client exception type that does not apply on the stock
RESTCatalog lane.

Root cause. OpenHouseInternalCatalog.renameTable (reached from the /iceberg
controller via CatalogHandlers.renameTable -> catalog.renameTable) performed no
destination-existence check, so a rename onto an occupied name was accepted. A
naive tableExists(to) guard -- whether in the controller (a prior attempt using
catalog.tableExists(request.destination())) or in the catalog -- does not fire,
for two reasons discovered by tracing the actual identifiers and HTS keys:

  1. Spark's SQL `ALTER TABLE openhouse.db.x RENAME TO openhouse.db.y` on the
     REST lane leaks the Spark catalog name into the DESTINATION namespace: the
     catalog receives to=openhouse.db.y (namespace [openhouse, db]) while the
     source is correctly stripped (namespace [db]). The pre-existing conflict
     table was created via the Java catalog API under the clean namespace [db],
     so tableExists(to) looks up the wrong, catalog-prefixed namespace and
     returns false -- the conflict is invisible. This is exactly why the prior
     controller guard never threw.

  2. OpenHouse rename is single-database by construction:
     UserTablesServiceImpl.renameUserTable discards the destination databaseId
     and reuses the source's (it only renames the tableId within the source db).
     That is also why the non-conflicting SQL renames pass despite the leaked
     namespace -- HTS absorbs it.

Fix. Check the conflict at the EFFECTIVE destination -- from.namespace()
combined with to.name(), i.e. exactly the HTS row the rename will write -- and
throw org.apache.iceberg.exceptions.AlreadyExistsException (already mapped to
HTTP 409 by the controller's handleConflict) when a table already exists there.
A renamingToSameTable guard avoids a false positive when a table is renamed to
its own name. The check goes in the catalog so all callers benefit. tableExists
resolves through HTS findById, which is case-insensitive on db and table, so the
guard matches an existing destination regardless of case.

Test. Removed @disabled and the unused WebClientResponseWithMessageException
import; replaced the client-specific exception-type assertion with an
engine-agnostic behavioral one (rename is rejected AND both the source and the
pre-existing destination survive intact -- the destination's user.property was
never set, proving no silent replace).

Verification (targeted runs, JDK17):
  - testRenameTableFailsConflict ....... PASSED (was @disabled)
  - testRenameTable .................... PASSED (rename to non-existent, SQL)
  - testRenameTableCatalogApi .......... PASSED (rename to non-existent, SQL)
  - testRenameTableCaseSensitivity ..... PASSED (rename to non-existent, Java API)
All four together: BUILD SUCCESSFUL, 0 failures. Full trace + before/after
documented in docs/spark4-iceberg-upgrade/spark4-e2e-tests/rename-409-fix.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Add docs/spark4-iceberg-upgrade/spark4-e2e-tests/backlog-triage.md classifying
the residual legacy Spark-3.1/3.5/apps-1.5 test backlog against the Spark-4.0
REST-first lane. Per-group and per-notable-test disposition with root cause and
exact fix-cost, reconstructable later.

Headline feasibility finding (policy DDL on the REST lane): making the custom
OpenHouse SET/UNSET POLICY, GRANT/REVOKE, column SET TAG DDL work via table
properties on the Spark-4.0 REST lane is NOT tractable in this pass, blocked by
three stacked, independently-disqualifying issues:

  1. No Spark-4.0/Scala-2.13 build of the extension. It lives only in
     spark-3.1/spark-3.5 openhouse-spark-runtime (Scala 2.12, ANTLR grammar +
     AST builder + logical plans + V2 strategy + *Exec). spark-4.0 has only a
     Java-only openhouse-spark-itest and no runtime module; loading it needs a
     new Scala-2.13 module ported to Spark 4.0's ParserInterface/Catalyst APIs.

  2. The policy "table property" (updated.openhouse.policy) is a CLIENT-ONLY
     patch channel consumed solely by the legacy OpenHouseTableOperations
     (buildUpdatedPolicies, lines 215/277-324), which folds it into the
     structured Policies object of the /tables request. The stock RESTCatalog
     never uses that class; the /iceberg server has ZERO references to
     updated.openhouse.policy, so the DDL would no-op. The real `policies` and
     `openhouse.*` keys are server-reserved (enforceReservedPropsUnchanged ->
     400 on UPDATE, stripped on CREATE), so they cannot be set as raw
     TBLPROPERTIES either. Making SET POLICY real requires a NEW server-side
     translation on the /iceberg commit path.

  3. GRANT/REVOKE/SHOW GRANTS reflectively cast the catalog to
     SupportsGrantRevoke; the stock RESTCatalog does not implement it and
     /iceberg has no grant endpoint -> UnsupportedOperationException.

Consequently no statementtest policy case, CatalogOperationTestSpark4_0
.testAlterTableUnsetReplicationPolicy, nor the RTAS/WapId inline policy
omissions can pass on the REST lane today; all left as recorded parity gaps
with the exact remaining work (no faked green, nothing re-enabled).

Group 2 (default `test`, ~90): the entire group is legacy-client mock-web-server
validation (SparkTestBase + MockWebServer + custom OpenHouseCatalog). Not a
REST-lane coverage gap; the substantive DDL/DML behaviors are already covered by
the existing Spark-4.0 catalogtest port (coverage map recorded). Mock-internals
(DoCommit/DoRefresh/ServerClientExceptionMapping/IcebergCatalogMapper) have no
stock-RESTCatalog analogue.

Group 3 (apps-1.5, 6 data-plane compaction/MoR-delete tests): deferred to the
separate batched-apps rewrite; no Spark-4.0 apps module proposed here to avoid
conflicting with that effort.

Docs-only; no source or test changes. 10-RESIDUALS.md and 20-legacy-gated.md
left untouched (main session reconciles their checkboxes).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…tended, not omitted-by-design

Per policy: the OpenHouse SQL extension (SET/UNSET POLICY, GRANT, column tags)
should be loaded on the Spark-4.0 REST lane and mapped to table-property
operations. It is not yet wired (needs a Scala-2.13/Spark-4.0 port of the
extension + a /iceberg server-side policy-property translation + a grant
endpoint; see spark4-e2e-tests/backlog-triage.md). Corrected the base-class
comment that wrongly framed this as a permanent design choice.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…licies on the Spark-4.0 REST lane

Make OpenHouse table POLICIES settable and readable through the stock
org.apache.iceberg.rest.RESTCatalog (Spark-4.0 / Iceberg-1.11) by having the
/iceberg/v1 server endpoint ACCEPT and TRANSLATE a policy table-property into
OpenHouse's structured Policies model -- mirroring what the native /tables path
does -- instead of stripping it or rejecting it as a reserved-property violation.
This is the server-side foundation a future Spark-4.0 SET POLICY SQL extension
maps onto.

Client property contract. A stock RESTCatalog sets a policy by carrying it as the
table property `updated.openhouse.policy`, whose value is the JSON serialization of
a (partial) OpenHouse Policies object (e.g. {"retention":{count,granularity,
columnPattern}} or {"sharingEnabled":true}). This is deliberately identical to the
legacy Spark Set*PolicyExec encoding (consumed by the legacy client's
OpenHouseTableOperations.buildUpdatedPolicies), so a future port of the SET POLICY
DDL emits the same property with no client change. The granularity token is
UPPERCASE, matching both the server Granularity enum names and
GranularityEnum.getValue() in the legacy AST builder.

Server changes (IcebergRestCatalogController):
- CREATE: toCreateUpdateTableRequestBody now intercepts `updated.openhouse.policy`,
  strips it from the pass-through properties, translates it to a Policies object, and
  sets it on the reused CreateUpdateTableRequestBody. The existing create pipeline
  (TablesApiHandler.createTable) then validates (validatePolicies) and persists it
  into the reserved `policies` property, exactly as /tables does.
- UPDATE/commit: a new isPolicyUpdate() dispatch (after the CTAS and RTAS branches,
  before the plain-update guards) routes a SetProperties commit carrying the policy
  carrier through updatePolicy(), which loads the base, rejects a locked table,
  projects the commit, merges the policy patch onto the existing policy
  (translatePolicyPatch), strips the carrier, and hands the full request to the reused
  TablesApiHandler.updateTable (validation + TablePolicyManager persistence into
  `policies`). The carrier is a non-reserved key, so the reserved-property guard sees
  identical reserved props before/after and does not 400 it; the guard remains fully
  intact for genuine openhouse.*/policies mutations.
- translatePolicyPatch reproduces the legacy client's buildUpdatedPolicies merge
  server-side (the stock client cannot merge): each sub-policy present in the patch
  overrides, absent ones are preserved. sharingEnabled presence is detected from the
  raw patch JSON because the server Policies model uses a primitive boolean. Parsing
  reuses the shared PoliciesSpecMapper bean so the lanes cannot drift.

All existing native-/tables behavior and the reserved-property protection for
non-policy props are unchanged; only the legitimate policy-setting path is added.

Verification. New PolicyPropertyTestSpark4_0 (extends OpenHouseRestSparkITest, drives
the stock RESTCatalog): retention-on-update, sharing-on-create, sharing-on-update,
successive-updates-merge, and non-policy-reserved-prop-still-rejected -- 5 tests, all
GREEN. Existing services:tables policy tests (PoliciesSpecMapperTest,
TablePolicyManagerTest, RetentionPolicySpecValidatorTest, HistoryPolicySpecValidatorTest)
remain GREEN, and CatalogOperationTestSpark4_0 insert/alter cases were spot-checked
for no regression. Full audit in
docs/spark4-iceberg-upgrade/spark4-e2e-tests/policy-rest-lane.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
… tableExists (fixes :services:tables:test)

Follow-up to the rename-409 guard in OpenHouseInternalCatalog.renameTable. The
guard used catalog.tableExists(effectiveDestination) to detect a rename onto an
existing table. tableExists() performs a full loadTable() -> refreshMetadata(),
which for a destination whose HTS row is ABSENT (the normal case: renaming to a
new name) throws InvalidTableMetadataException ("Failed to open input stream for
file ... .metadata.json") instead of returning false. This broke
RepositoryTest.testRenameTableMetadataUpdate on the branch-1.11 CI (a rename to a
brand-new name blew up in the existence pre-check).

Fix: use the existing findHouseTable(identifier) helper, which reads ONLY the HTS
row (no metadata.json parse) and returns Optional.empty() when the destination
does not exist -- exactly what a pre-rename conflict check needs. The real
conflict case is unchanged: findHouseTable(existing).isPresent() == true still
throws AlreadyExistsException (mapped to HTTP 409 by the controller).

Test isolation: the guard also exposed a latent leak in RepositoryTest. Two
sibling tests (testRenameTableMetadataUpdate, testRenameTablePreserveExistingCase)
both rename to d1.t1_renamed and never deleted it -- previously masked by the old
silent-overwrite rename. With the guard, whichever ran second either hit the
conflict OR left d1.t1 in a partial state, which then surfaced downstream as
NPEs (TableType.valueOf(null) in OpenHouseInternalRepositoryImpl.skipEligibilityCheck)
in testMetadataUpdateForDeleted and testOpenHouseInvalidClusteringEvolution.
Added the class's standard deleteById cleanup to both rename tests; the full
RepositoryTest (28 tests) is now green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
… / Scala-2.13 (SET POLICY works end-to-end on the REST lane)

Make the OpenHouse custom `ALTER TABLE ... SET POLICY (...)` DDL work on the
Spark-4.0 / Iceberg-1.11 REST-first lane by porting the OpenHouse Spark SQL
extension from the legacy Scala-2.12 / Spark-3.x runtime to Spark 4.0 / Scala
2.13. The DDL lowers onto the `updated.openhouse.policy` Iceberg table property
that the `/iceberg` server foundation (commit da20ad4) already translates into
OpenHouse `Policies` — so this is a pure client-side port with no contract change.

New module. `integrations/spark/spark-4.0/openhouse-spark-runtime`, Gradle project
`:integrations:spark:spark-4.0:openhouse-spark-runtime` renamed in settings.gradle
to `openhouse-spark-4.0-runtime_2.13`. A `scala` + `java-library` module (Scala
2.13.16, Spark 4.0.0, Iceberg 1.11.0-openhouse, Java 17) that deliberately avoids
`openhouse.java-minimal-conventions` (which pins Java 11 and rejects the Java-17
Spark 4.0 jars), mirroring the sibling itest module. It provides
`com.linkedin.openhouse.spark.extensions.OpenhouseSparkSessionExtensions`. The
ANTLR grammar (.g4) is copied VERBATIM from spark-3.1 and generated with the
ANTLR 4.13.1 tool (matching Spark 4.0's bundled antlr4-runtime 4.13.1 — the 4.7.1
tool would emit an ATN-version-incompatible parser). Granularity tokens
(DAY/HOUR/MONTH/YEAR) are inlined as string literals so the module needs no
tables gen-model dependency (those values are exactly the uppercase tokens the
server contract expects).

Spark-4.0 / Scala-2.13 API adaptations (only what the API changes forced):
- ParserInterface: added parseQuery + parseRoutineParam overrides (both delegate),
  dropped parseRawDataType (no longer in the contract).
- Logical plans extend LeafCommand (Spark 4.0's Command no longer supplies leaf
  semantics; LeafCommand = Command with LeafLike).
- Physical execs extend LeafV2CommandExec (V2CommandExec no longer supplies
  children/withNewChildrenInternal).
- Planner strategy extends org.apache.spark.sql.execution.SparkStrategy directly
  (the org.apache.spark.sql.Strategy type alias was removed).
- Dropped the removed-in-2.13 scala.collection.JavaConversions import.
The GRANT/REVOKE/SHOW-GRANTS execution path (GrantRevokeStatementExec,
ShowGrantsStatementExec, IcebergCatalogMapper, Principal) and OpenHouseCatalog.java
are NOT ported — they require a SupportsGrantRevoke catalog that does not exist on
the stock-RESTCatalog REST lane and pull Iceberg-core-internal deps. The GRANT
grammar + logical plans + AST builder are retained (they compile as pure Catalyst),
so those statements still parse; they simply have no wired physical exec.

Harness wiring (integrations/spark/spark-4.0/openhouse-spark-itest):
- build.gradle: testImplementation on the new runtime module.
- OpenHouseRestSparkITest.getBuilder: spark.sql.extensions now registers the
  OpenHouse extension alongside the stock Iceberg extension. The OpenHouse parser
  only intercepts its own DDL and delegates everything else, so this is transparent
  to every existing REST-lane test.

Tests (all GREEN, targeted JDK-17 runs):
- PolicySqlDdlTestSpark4_0 (NEW, 4 tests): drives the real SET POLICY DDL
  (retention/replication/sharing/history) end-to-end against the embedded server
  and reads `policies` back via SHOW TBLPROPERTIES.
- statementtest ports (19 tests): SetTablePolicy / SetTableReplicationPolicy /
  SetHistoryPolicy / SetSharingPolicy Spark4_0 (parse/plan on a local hadoop
  catalog). One rendering delta adapted: Spark 4.0 backtick-quotes leading-digit
  identifiers (`0_`.`0_`) where 3.1 rendered 0_.0_.
- RTASTestSpark4_0.testRTAS: restored inline SET POLICY (HISTORY MAX_AGE=24H);
  assertion updated to the verified REST-lane behavior (RTAS PRESERVES the policy
  rather than clearing it — a server behavioral delta from the legacy catalog).
- WapIdTestSpark4_0.testWapWorkflowWithVariousOperations: restored inline
  SET POLICY (SHARING=TRUE) + readback (GRANT stays dropped).

Still @disabled with an updated reason:
CatalogOperationTestSpark4_0.testAlterTableUnsetReplicationPolicy — SET POLICY now
works, but this test also exercises UNSET POLICY (REPLICATION). UnSetReplicationPolicyExec
emits {"replication": {}}, which the /iceberg server rejects with HTTP 400
("Replication config cannot be null"): the server merge only overrides present
sub-policies and has no clear/tombstone convention for removing one. Needs a
server-side fix; the SET steps this test runs already pass.

Docs: adds spark4-e2e-tests/policy-sql-extension-spark4.md (audit-grade: module
layout, every Spark-4.0 API change, what works + is tested, what remains + why,
full reconstruction checklist) and updates 10-RESIDUALS.md to the new state.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
ALTER TABLE ... UNSET POLICY (REPLICATION) on the Spark-4.0 / Iceberg-1.11
REST lane was rejected by the /iceberg server with HTTP 400 "Replication
config cannot be null." The Spark exec UnSetReplicationPolicyExec emits the
policy carrier property updated.openhouse.policy = {"replication": {}}. The
server merge in IcebergRestCatalogController.translatePolicyPatch only had an
OVERRIDE convention: it deserialized {"replication": {}} into a non-null
Replication with a null config and set it on the merged Policies, which then
tripped the @NotNull constraint on Replication.config during the update
bean-validation cascade. There was no clear/tombstone convention for REMOVING
a sub-policy, so an empty sub-policy object was indistinguishable from an
invalid one and the UNSET step always failed.

Fix: translatePolicyPatch now interprets a sub-policy present in the raw patch
JSON as an EMPTY object ({}) as a clear/tombstone and drops it from the merged
Policies (sets it to null on the builder) instead of overriding with an
invalid empty object. Detection is done on the raw patch JSON via a new
isClearedSubPolicy helper because the parsed server Policies/Replication model
cannot distinguish an absent sub-policy from an empty-but-present one. The rule
is applied consistently to the object sub-policies replication, retention,
history and columnTags (sharingEnabled is a primitive boolean, not an object).
The existing non-empty SET override paths are untouched, since a real SET
always carries a non-empty object. The merge now also builds from a fresh
Policies.builder() when the table has no existing policy, so an UNSET on a
table that never had the sub-policy also strips the empty object rather than
persisting an invalid one. Because a cleared replication is null, the @NotNull
config cascade and ReplicationConfigValidator are never triggered on a clear.

Native-lane parity finding: the legacy /tables client merge
(OpenHouseTableOperations.buildUpdatedPolicies) had the same override
structure but did not 400, because the GENERATED client Replication model
initializes config to an empty ArrayList. Gson left that default in place when
deserializing {"replication": {}}, so the client silently sent
{"replication":{"config":[]}} and the native catalogtest read back
replication.config.size() == 0 -- replication PRESENT with an empty config
list, not removed. The server-side Replication model has no such default, so
on the REST lane the same empty object deserializes to config == null. The
native lane therefore never truly cleared replication; it stored an
empty-but-valid config object purely as an artifact of the client gen-model.
The REST lane removes the sub-policy outright, which is the cleaner behavior;
both mean "no active replication" and the difference is not observable through
the behavioral test.

Re-enables CatalogOperationTestSpark4_0.testAlterTableUnsetReplicationPolicy
(removed @disabled and the now-unused import). The post-UNSET readback is
asserted against the raw policies table-property string (there is no Policies
gen-model on this itest module's compile classpath): the serialized policy no
longer carries the replication config while retention is preserved.

Verification (targeted, all GREEN):
  :services:tables:test --tests
    "com.linkedin.openhouse.tables.e2e.h2.TablesControllerTest" (server guard:
    policy validation + replication/history update paths)
  :integrations:spark:spark-4.0:openhouse-spark-4.0-itest:test --tests
    "*.CatalogOperationTestSpark4_0.testAlterTableUnsetReplicationPolicy"
    "*.PolicySqlDdlTestSpark4_0" "*.PolicyPropertyTestSpark4_0"
  -> testAlterTableUnsetReplicationPolicy PASSED; all 4 PolicySqlDdl SET tests
     and all 5 PolicyProperty tests still PASSED (no SET-path regression).

Docs: adds spark4-e2e-tests/unset-policy-clear-semantics.md (audit-grade
failure/root-cause/native-parity/fix/verification) and moves the item to FIXED
in spark4-e2e-tests/10-RESIDUALS.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Add ColumnTagsTestSpark4_0 to prove ALTER TABLE ... MODIFY COLUMN ...
SET TAG works end-to-end on the Spark-4.0 / Iceberg-1.11 REST lane. The
DDL is parsed by the ported OpenHouse extension, lowered by
SetColumnPolicyTagExec onto the updated.openhouse.policy carrier as
{"columnTags":{"<col>":{"tags":[...]}}}, and merged by the server's
translatePolicyPatch columnTags branch into the reserved policies
property.

Covers single tag (PII), multiple tags (PII, HC), and the clear form
(NONE). All three pass with no code change required; column tags work
as-is on the REST lane. NONE emits an empty tags array (clears the
column's tags) rather than a tombstone; documented in
column-tags-verification.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
The Spark 4.0 rename catalog test had dropped the legacy 3.1 assertion on
the OpenHouse reserved property openhouse.tableUri, with a NOTE claiming the
property was "not surfaced on the stock REST lane."

Empirical probing shows the property IS surfaced end to end on the /iceberg
loadTable lane and is correctly updated by rename. It was never stripped; the
legacy assertion only failed because the value differs: on the REST lane the
identifier namespace carries the Spark catalog prefix, so the value is
local-cluster.openhouse.db.rename_test_renamed rather than the legacy
local-cluster.db.rename_test_renamed.

No server change is required. Restore the assertion, made robust to the
embedded cluster name and the REST-lane namespace rendering by asserting the
tableUri ends with db.rename_test_renamed. Add an audit-grade write-up under
docs/spark4-iceberg-upgrade/spark4-e2e-tests/tableuri-loadtable.md.

Verified green (targeted):
  testRenameTableCatalogApi PASSED
  testRenameTableCaseSensitivity PASSED

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…ANT deferral

Close out the Spark-4.0 / Iceberg-1.11 REST-first e2e fix backlog in
10-RESIDUALS.md now that every fixable catalogtest case is green:

- testAlterTableUnsetReplicationPolicy: FIXED (d91cfdf, empty-object clear
  semantics) — already recorded.
- testRenameTableFailsConflict: marked FIXED. It is a live @test (not
  @disabled) and was already made green by the catalog-level
  effective-destination rename guard in 956820c; the checklist was stale.
  Confirmed GREEN locally (targeted run) and in branch-1.11 CI. Documents why
  the catalog-level guard fires where the earlier controller-level
  tableExists(destination) guard did not (single-DB rename + Spark REST lane
  leaking the catalog name into the destination namespace).
- testRenameTableCatalogApi (openhouse.tableUri): marked FIXED (923d39b). The
  property was surfaced by the stock REST loadTable all along; only the
  expected value differed (REST-lane namespace rendering adds the catalog
  prefix). Assertion restored as a tail-match.
- Column policy-tag DDL: marked VERIFIED (f42268d) — server already merges the
  columnTags sub-policy; no code change needed. Notes the SET TAG = (NONE)
  empty-array clear caveat.
- GRANT / REVOKE / SHOW GRANTS: recorded as an explicit, documented DEFERRAL
  (needs a server-side ACL endpoint on the /iceberg lane; disproportionate to
  this modernization spike), NOT a silent omission — with a concrete
  close-it-later plan.

Rewrites the intro to reflect the all-green state and clarifies the checkbox
semantics ([x] fixed+verified, [ ] deferred-with-reason).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
The ~19-min InvalidMetadataTestSpark4_0 runtime was already resolved:
IcebergRestCatalogController maps InvalidTableMetadataException to a
non-retryable HTTP 400 (handleBadRequest, ~line 1063), so the INSERT's
Iceberg commit-retry loop no longer backs off for ~19 min on corrupt
metadata. Update the checklist item from open to fixed for accuracy.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…ungs

Durable, review-oriented tracker indexing the sequential remaining work
(GRANT/REVOKE REST-lane impl, Rung 3 v3 deletion vectors, Java-17 server
runtime, final delta-harness validation, HDFS/v3 spike), each linking to its
own audit-grade write-up. Records pre-dispatch findings: the build is already
on 1.11.0-openhouse, and the server forces TableProperties.FORMAT_VERSION from
the cluster config cluster.iceberg.format-version (default 2), which is the
lever for enabling v3 deletion vectors.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…a /aclPolicies

The stock RESTCatalog on the Spark-4.0 / Iceberg-1.11 REST lane does not implement
SupportsGrantRevoke (the legacy hook) and its /iceberg surface has no ACL sub-resource,
so the old downcast path is a dead end. Instead the new execs call the EXISTING OpenHouse
server ACL endpoint directly over HTTP: PATCH/GET {base}/v1/databases/{db}/tables/{t}/aclPolicies,
where {base} is the catalog uri with the trailing /iceberg stripped and the same bearer token.
Privilege->role mapping mirrors javaclient/mapper/Privileges.java exactly.

- Add OpenHouseAclClient (URI/token derivation, privilege<->role maps, PATCH/GET), the
  GrantRevokeStatementExec + ShowGrantsStatementExec, and the ported Principal (PUBLIC<->*) constant.
- Wire GrantRevokeStatement / ShowGrantsStatement cases into OpenhouseDataSourceV2Strategy
  (grammar + logical plans + AST builder were already present).
- Add GrantRevokeTestSpark4_0: GRANT/REVOKE return 204 against the real embedded server, and a
  capturing HttpServer stub proves the exact request (operation+role+principal+path+bearer) and
  SHOW GRANTS row parsing. A full in-JVM ACL roundtrip is not possible (embedded
  OpaAuthorizationHandler no-ops without an external OPA store), so this matches the legacy
  client-contract bar.
- Restore the dropped GRANT SELECT ... TO lejiang in WapIdTestSpark4_0.
- Docs: add grant-revoke-rest-lane.md; move GRANT from DEFERRED to FIXED in 10-RESIDUALS.md;
  tick section 1 of REMAINING-WORK.md; supersede the stale GRANT notes in
  policy-sql-extension-spark4.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Record the GRANT/REVOKE landing SHA in the change log and mark Rung 3 (v3
deletion vectors) as in-progress in REMAINING-WORK.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…ane (Rung 3)

Rung 3 — the goal of the Spark-4.0 / Iceberg-1.11 modernization spike: prove v3
DataSource-V2 deletion vectors end-to-end on the Spark-4.0 REST lane with a real
test, without flipping the currently-green default-v2 itest suite.

OpenHouse forces every table's format-version to the cluster default
(cluster.iceberg.format-version, default 2) on create/replace, so v3 can only be
authored by raising that default — which is JVM-global given the per-JVM singleton
embedded server. To keep the green suite at v2, isolate v3 to its own JVM fork: a
new `deletionVectorTest` Gradle Test task runs ONLY DeletionVectorTestSpark4_0 with
-Dcluster.iceberg.format-version=3, and that class is excluded from the default
`test` task. Isolation is by fork, not JUnit ordering.

DeletionVectorTestSpark4_0 (2 methods, PASSED):
- testServerAuthorsFormatVersion3: metadata-writer v3 proof (table property==3,
  TableMetadata.formatVersion()==3, on-disk metadata JSON "format-version":3).
- testMergeOnReadDeleteWritesDeletionVector: MOR DELETE writes a puffin deletion
  vector (.delete_files file_format=PUFFIN, physical *-deletes.puffin carrying a
  deletion-vector-v1 blob, zero PARQUET pos-deletes); deleted row gone on read-back,
  survivors [1,3,4,5,6].

Default-v2 path verified unchanged (CatalogOperationTestSpark4_0.testCasingWithCTAS
PASSED); DV test proven excluded from the default fork. Write-up
rung3-v3-deletion-vectors.md incl. the cost assessment of a global v3 default
(v2 read cliff -> Rung 9). REMAINING-WORK section 2 ticked.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…r Java-8 bytecode

Rung 7 (server/metadata-writer runtime -> Java 17, keep Java-8 bytecode where
consumed) is already satisfied by the 1.11 upgrade architecture; this documents
and empirically verifies it.

- Runtime = Java 17: CI (build-run-tests.yml "Set up JDK 17") and the embedded
  Spring Boot 2.7 server (booted in the JDK-17 test JVM by the whole Spark-4.0
  REST itest e2e suite, incl. the Rung-3 v3 server) run on Java 17; no module
  pins a Java-8/11 toolchain.
- Bytecode = Java 8 where consumed: java-minimal-conventions keeps
  targetCompatibility=1_8; the metadata-writer OpenHouseInternalCatalog /
  OpenHouseInternalTableOperations compile to major version 52 (Java 8) on a
  JDK-17 compiler (verified with javap) and advertise Java 8 to consumers, so
  Java-8 readers of the metadata output keep working.

No code change required. Write-up rung7-java17-runtime.md; REMAINING-WORK.md
section 3 ticked + Rung 3 SHA (69d83d6) recorded in the change log.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…17 + RBF)

The OpenHouse server links hadoop-client to write table metadata to HDFS. On
the 1.11 branch that pin was still the legacy 2.10.0, which is unfit for the
Java-17 server runtime: it predates Java 17 and lacks the Hadoop 3.3+
FileSystem.openFile(Path) API that Iceberg 1.10/1.11 calls. The Spark itest lane
only masked this by excluding the server's unshaded 2.10 Hadoop and borrowing
Spark 4.0's shaded 3.4.1, so the declared server dependency was stale.

Move openhouse.hadoop-conventions hadoopVersion 2.10.0 -> 3.3.6: 3.3.6 has
openFile, runs on Java 17 (with the launcher --add-opens already applied), and
its RPC client is wire-compatible with a 3.1/3.2 RBF (Router-Based Federation)
cluster.

Fix the transitive fallout at the source (2.10 dragged in old libs 3.3.6 drops;
migrate each direct use to its modern, already-present equivalent — no version
band-aids):
- commons-lang 2.x org.apache.commons.lang.StringUtils -> commons-lang3
  (JdbcProviderConfiguration, TablesControllerTest)
- Apache Directory org.apache.directory.api.util.Strings -> commons-lang3
  StringUtils isNotEmpty/isEmpty (HouseTableRepositoryImpl, RequestAndValidateHelper)
- codehaus Jackson 1.x org.codehaus.jackson.node.* -> fasterxml
  com.fasterxml.jackson.databind.node.* (PoliciesSpecMapperTest)

The Spark-4.0 itest hadoop exclusions are by group+module (version-agnostic),
so the itest/harness classpath is unchanged.

Verified: every hadoop-conventions module compiles main+test on JDK 17 + Hadoop
3.3.6; cluster:storage:test green; the server boots (generateOpenApiDocs starts
the full housetables+tables Spring context); TablesControllerTest and
PoliciesSpecMapperTest green. Real-HDFS/RBF cluster validation and the v3 read
cliff remain deployment gates, documented in rung9-hdfs-java17-v3-readcliff.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…SparkSession

The delta-harness Env wired only the stock Iceberg extension, so every custom
OpenHouse DDL scenario (ddl.policy.{retention,replication,sharing,history},
ddl.colTag, ddl.acl.grant*, and the rtas/hazard cases that SET POLICY) failed to
PARSE (ParseException) — ~24 of the 39 full-matrix failures were purely this
missing registration, not product behavior.

Now that OpenhouseSparkSessionExtensions is ported to Spark-4.0 / Scala-2.13
(module openhouse-spark-4.0-runtime_2.13, already on the harness classpath via
the itest module), register it alongside the Iceberg extension — mirroring
OpenHouseRestSparkITest.getBuilder. Verified: the ddl.policy slice goes from all
ParseException to 12 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…(Hadoop 3.3.6 fallout)

The Hadoop 2.10.0 -> 3.3.6 bump (2884455) dropped the transitive commons-lang
2.x (org.apache.commons.lang) that these modules imported directly, breaking
the full-repo CI build at :apps:openhouse-spark-apps-1.5_2.12:compileJava (and
libs:datalayout). Local per-module compiles of the hadoop-conventions modules
missed these because the full build graph pulls them via other paths.

Migrate each direct use to commons-lang3 (already on every affected module's
compile classpath), APIs unchanged:
- apps SparkJobUtil, TableRetentionTask: org.apache.commons.lang.StringUtils
  -> org.apache.commons.lang3.StringUtils (isBlank)
- apps JobsScheduler: org.apache.commons.lang.math.NumberUtils ->
  org.apache.commons.lang3.math.NumberUtils (toInt)
- libs/datalayout StrategiesDaoTableProps:
  org.apache.commons.lang.StringEscapeUtils ->
  org.apache.commons.lang3.StringEscapeUtils (escapeJava/unescapeJava)

Verified: `./gradlew testClasses` (whole-repo main+test compile) BUILD
SUCCESSFUL on JDK 17 + Hadoop 3.3.6. A repo-wide grep confirms no remaining
org.apache.commons.lang. / org.codehaus.jackson / org.apache.directory.api
imports.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…42/2574)

Ran the full delta-harness behavioral matrix against the real embedded OpenHouse
catalog on the Spark-4.0 / Iceberg-1.11 / REST-first stack: 2542 passed, 11
skipped, 21 failed (2574 cases), 98.8% pass. First run had 39 failures; ~18 were
purely the harness SparkSession not registering the OpenHouse SQL extension
(fixed in 24dd99b), re-run -> 21.

All 21 residuals triaged in rung8-final-validation.md — zero OpenHouse product
regressions:
- (A) harness assertions authored for the legacy fork/custom-client lane
  (rename-conflict / ns-create exception types; grant-on-unshared server-400);
- (B) the REST lane FIXING legacy bugs the harness still asserts broken (G10
  retention + H3 column-tag survive RTAS — the harness itself says "flip this
  test");
- (C) stock-Spark-4.0/Iceberg-1.11-vs-legacy-fork engine deltas (fork column
  DEFAULT unsupported on stock Spark 4.0, branch ref-override, concurrent-append
  count, partition-reject message, rename-consumer handoff).

Harness-baseline polish (flip G10/H3, update category-A exception types) is noted
as follow-up; it is validation-tool bookkeeping, not product work. REMAINING-WORK
section 4 ticked.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
…GAPS register

Two reviewer-facing documents closing out the modernization:

- CHANGE-HISTORY.md: lists the in-scope repos (mkuchenbecker/openhouse on branch
  1.11 / PR #17 draft; mkuchenbecker/iceberg fork on 1.11.x, consumed as source,
  no PR), then walks the diffs sequentially per repo. OpenHouse = 54 commits
  (+17,307/-109 across 132 files) grouped into 7 phases from the REST-first
  cutover through the rung ladder. Fork = the 1.11 port (replay LinkedIn core
  patches onto Apache Iceberg 1.11.0 + the F-REPL delete-file-replication fix,
  12 files +233/-13) plus the branch layout.

- KNOWN-GAPS.md: the honest register of what is NOT done — (A) harness-baseline
  polish (flip the G10/H3 bugs the REST lane fixed, category-A exception-type
  assertions, the surface.conc.appendAppend concurrency deep-dive, category-C
  stock-vs-fork engine deltas), (B) deployment gates (real-HDFS-cluster run,
  v3 default flip gated on the read cliff, GRANT/REVOKE server ACL persistence
  needing OPA), and (C) deferred scope (maintenance-apps Spark-4.0 port; PR #17
  draft→merge decision). None blocks the spike; all cross-referenced to the
  per-rung write-ups.

REMAINING-WORK.md gains a companion-docs pointer.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LLnVvLYnxjTuH2b5nAHa4A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants