This is an automated email from the ASF dual-hosted git repository.
danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 9a629fd8293e feat(agent): add first mutable Flink SQL COW sink path to
Hudi Architect (#20281)
9a629fd8293e is described below
commit 9a629fd8293ef62f5f036038c58d2eb5e8d1cb04
Author: fhan <[email protected]>
AuthorDate: Tue Oct 13 10:44:50 2026 +0800
feat(agent): add first mutable Flink SQL COW sink path to Hudi Architect
(#20281)
* feat(agent): add first mutable Flink SQL COW sink path to Hudi Architect
* fix(agent): align mutable Flink replay and ordering validation
---------
Co-authored-by: fhan <[email protected]>
---
.github/workflows/agent_gateway_ci.yml | 3 +
.../fixtures/hudi-architect-flink-1.20/pom.xml | 8 +-
.../architect/TestFlinkArchitectSqlFixtures.java | 118 ++++++-
hudi-agent-gateway/skills/hudi-architect/README.md | 17 +-
.../skills/hudi-architect/RUNBOOK.md | 17 +-
hudi-agent-gateway/skills/hudi-architect/SKILL.md | 19 +-
.../flink-1.20-hudi-1.2.0-capabilities.md | 32 +-
.../flink-1.20-hudi-1.2.0-capabilities.toml | 171 +++++++++-
.../references/flink-config-templates.md | 57 +++-
.../references/flink-decision-overrides.md | 53 +++-
.../references/flink-question-flow.md | 50 ++-
.../hudi-architect/references/flink-warnings.md | 99 +++++-
.../hudi-architect/validate_flink_capabilities.py | 115 ++++++-
.../skills/hudi-architect/validate_flink_design.py | 344 ++++++++++++++++++---
.../hudi_architect/flink_pr1_scenarios.toml | 9 +-
.../hudi_architect/flink_pr3/mutable_cow.json | 66 ++++
.../hudi_architect/flink_pr3/mutable_cow.sql | 55 ++++
.../tests/test_hudi_architect_skill.py | 288 ++++++++++++++++-
18 files changed, 1363 insertions(+), 158 deletions(-)
diff --git a/.github/workflows/agent_gateway_ci.yml
b/.github/workflows/agent_gateway_ci.yml
index 6d6e5f35e613..aafccf8b6936 100644
--- a/.github/workflows/agent_gateway_ci.yml
+++ b/.github/workflows/agent_gateway_ci.yml
@@ -71,6 +71,9 @@ jobs:
python skills/hudi-architect/validate_flink_design.py \
--input tests/fixtures/hudi_architect/flink_pr2/stable_key.json \
--emit-sql > /dev/null
+ python skills/hudi-architect/validate_flink_design.py \
+ --input tests/fixtures/hudi_architect/flink_pr3/mutable_cow.json \
+ --emit-sql > /dev/null
- name: Test
run: python -m pytest tests -q
- name: Lint
diff --git a/hudi-agent-gateway/fixtures/hudi-architect-flink-1.20/pom.xml
b/hudi-agent-gateway/fixtures/hudi-architect-flink-1.20/pom.xml
index c3226c49fb1f..0714ab8d6fbf 100644
--- a/hudi-agent-gateway/fixtures/hudi-architect-flink-1.20/pom.xml
+++ b/hudi-agent-gateway/fixtures/hudi-architect-flink-1.20/pom.xml
@@ -26,7 +26,7 @@
<packaging>jar</packaging>
<name>Hudi Architect Flink 1.20 fixtures</name>
- <description>Pinned factory and planner fixtures for the first executable
Architect path.</description>
+ <description>Pinned factory and planner fixtures for bounded executable
Architect paths.</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -82,6 +82,12 @@
<include>*.sql</include>
</includes>
</testResource>
+ <testResource>
+
<directory>${project.basedir}/../../tests/fixtures/hudi_architect/flink_pr3</directory>
+ <includes>
+ <include>*.sql</include>
+ </includes>
+ </testResource>
</testResources>
<plugins>
<plugin>
diff --git
a/hudi-agent-gateway/fixtures/hudi-architect-flink-1.20/src/test/java/org/apache/hudi/agent/architect/TestFlinkArchitectSqlFixtures.java
b/hudi-agent-gateway/fixtures/hudi-architect-flink-1.20/src/test/java/org/apache/hudi/agent/architect/TestFlinkArchitectSqlFixtures.java
index a6c008620b5b..89ef68792fb7 100644
---
a/hudi-agent-gateway/fixtures/hudi-architect-flink-1.20/src/test/java/org/apache/hudi/agent/architect/TestFlinkArchitectSqlFixtures.java
+++
b/hudi-agent-gateway/fixtures/hudi-architect-flink-1.20/src/test/java/org/apache/hudi/agent/architect/TestFlinkArchitectSqlFixtures.java
@@ -23,6 +23,7 @@ import org.apache.hudi.configuration.FlinkOptions;
import org.apache.hudi.configuration.OptionsResolver;
import org.apache.hudi.sink.bulk.RowDataKeyGen;
import org.apache.hudi.table.HoodieTableFactory;
+import org.apache.hudi.util.ChangelogModes;
import org.apache.hudi.util.DataTypeUtils;
import org.apache.hudi.util.HoodieSchemaConverter;
@@ -46,6 +47,7 @@ import org.apache.flink.table.types.DataType;
import org.apache.flink.table.types.logical.RowType;
import org.apache.flink.table.types.logical.TimeType;
import org.apache.flink.types.Row;
+import org.apache.flink.types.RowKind;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -56,6 +58,7 @@ import java.sql.Date;
import java.sql.Timestamp;
import java.time.Duration;
import java.util.Collections;
+import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -64,11 +67,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
- * Factory and planner fixtures for the bounded Hudi Architect PR2 path.
+ * Factory and planner fixtures for the bounded Hudi Architect Flink SQL paths.
*
* <p>The fixture depends on the released Hudi 1.2.0 Flink 1.20 bundle and
Flink 1.20.1 rather
* than the enclosing checkout. It plans the exact SQL golden files checked by
the Python
@@ -120,6 +124,61 @@ class TestFlinkArchitectSqlFixtures {
assertTrue(plan.contains("changelogMode=[I]"), plan);
}
+ @Test
+ void testMutableCowSinkAndUpsertPlan() throws Exception {
+ TestContext context = newTestContext();
+ StreamTableEnvironment tableEnv = context.tableEnv;
+ registerMutableSource(context);
+
+ SqlFixture fixture = SqlFixture.load("mutable_cow.sql");
+ applyRuntimeStatements(tableEnv, fixture.runtimeSql);
+ tableEnv.executeSql(fixture.tableDdl);
+ String plan = tableEnv.explainSql(
+ fixture.insertSql,
+ ExplainDetail.CHANGELOG_MODE,
+ ExplainDetail.JSON_EXECUTION_PLAN);
+
+ assertPinnedArtifacts();
+ assertTrue(plan.contains("orders_mutable_hudi"), plan);
+ assertTrue(plan.contains("stream_write:
default_database.orders_mutable_hudi"), plan);
+ assertTrue(plan.contains("changelogMode=[I,UA,D]"), plan);
+ assertTrue(plan.contains("index_bootstrap"), plan);
+ assertEquals(
+ Set.of(RowKind.INSERT, RowKind.UPDATE_AFTER, RowKind.DELETE),
+ ChangelogModes.UPSERT.getContainedKinds());
+ assertFalse(ChangelogModes.UPSERT.contains(RowKind.UPDATE_BEFORE));
+ }
+
+ @Test
+ void testPinnedMutableOrderingConfiguration() {
+ Configuration conf = mutableConfiguration();
+
+ assertEquals("hoodie.write.record.merge.mode",
FlinkOptions.RECORD_MERGE_MODE.key());
+ assertEquals("event_version", conf.get(FlinkOptions.ORDERING_FIELDS));
+ assertEquals("EVENT_TIME_ORDERING",
conf.get(FlinkOptions.RECORD_MERGE_MODE));
+ assertFalse(conf.get(FlinkOptions.CHANGELOG_ENABLED));
+
+ conf.set(FlinkOptions.ORDERING_FIELDS, FlinkOptions.NO_PRE_COMBINE);
+ assertEquals("no_precombine", FlinkOptions.NO_PRE_COMBINE);
+ assertNull(OptionsResolver.getOrderingFieldsStr(conf));
+ }
+
+ @Test
+ void testPinnedMutableIndexConfiguration() {
+ Configuration conf = mutableConfiguration();
+
+ assertEquals("FLINK_STATE", conf.get(FlinkOptions.INDEX_TYPE));
+ assertTrue(conf.get(FlinkOptions.INDEX_GLOBAL_ENABLED));
+ assertEquals(0.0, conf.get(FlinkOptions.INDEX_STATE_TTL));
+ }
+
+ @Test
+ void testPinnedMutableIndexBootstrapConfiguration() {
+ Configuration conf = mutableConfiguration();
+
+ assertTrue(conf.get(FlinkOptions.INDEX_BOOTSTRAP_ENABLED));
+ }
+
@Test
void testPinnedFactorySkipsMissingRecordKeyCheckInAppendMode() {
TestContext context = newTestContext();
@@ -359,6 +418,20 @@ class TestFlinkArchitectSqlFixtures {
return (RowDataKeyGen) factory.invoke(null, conf, rowType);
}
+ private static Configuration mutableConfiguration() {
+ Configuration conf = new Configuration();
+ conf.set(FlinkOptions.TABLE_TYPE, FlinkOptions.TABLE_TYPE_COPY_ON_WRITE);
+ conf.set(FlinkOptions.OPERATION, "upsert");
+ conf.set(FlinkOptions.ORDERING_FIELDS, "event_version");
+ conf.set(FlinkOptions.RECORD_MERGE_MODE, "EVENT_TIME_ORDERING");
+ conf.set(FlinkOptions.INDEX_TYPE, "FLINK_STATE");
+ conf.set(FlinkOptions.INDEX_GLOBAL_ENABLED, true);
+ conf.set(FlinkOptions.INDEX_STATE_TTL, 0.0);
+ conf.set(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true);
+ conf.set(FlinkOptions.CHANGELOG_ENABLED, false);
+ return conf;
+ }
+
private static TestContext newTestContext() {
StreamExecutionEnvironment environment =
StreamExecutionEnvironment.getExecutionEnvironment();
environment.enableCheckpointing(60_000L);
@@ -404,6 +477,49 @@ class TestFlinkArchitectSqlFixtures {
registerInsertOnlyView(context.tableEnv, "events_source", stream, schema);
}
+ private static void registerMutableSource(TestContext context) {
+ Row insert = Row.ofKind(
+ RowKind.INSERT,
+ "id-1",
+ "Alice",
+ 1L,
+ Date.valueOf("2026-01-01"));
+ Row updateAfter = Row.ofKind(
+ RowKind.UPDATE_AFTER,
+ "id-1",
+ "Alice updated",
+ 2L,
+ Date.valueOf("2026-01-01"));
+ Row delete = Row.ofKind(
+ RowKind.DELETE,
+ "id-1",
+ "Alice updated",
+ 3L,
+ Date.valueOf("2026-01-01"));
+ DataStream<Row> stream = context.environment.fromCollection(
+ List.of(insert, updateAfter, delete),
+ Types.ROW_NAMED(
+ new String[] {"id", "name", "event_version", "partition_date"},
+ Types.STRING,
+ Types.STRING,
+ Types.LONG,
+ Types.SQL_DATE));
+ Schema schema = Schema.newBuilder()
+ .column("id", DataTypes.STRING().notNull())
+ .column("name", DataTypes.STRING())
+ .column("event_version", DataTypes.BIGINT().notNull())
+ .column("partition_date", DataTypes.DATE().notNull())
+ .primaryKey("id")
+ .build();
+ ChangelogMode upsertMode = ChangelogMode.newBuilder()
+ .addContainedKind(RowKind.INSERT)
+ .addContainedKind(RowKind.UPDATE_AFTER)
+ .addContainedKind(RowKind.DELETE)
+ .build();
+ Table source = context.tableEnv.fromChangelogStream(stream, schema,
upsertMode);
+ context.tableEnv.createTemporaryView("orders_mutable_source", source);
+ }
+
private static void registerInsertOnlyView(
StreamTableEnvironment tableEnv,
String viewName,
diff --git a/hudi-agent-gateway/skills/hudi-architect/README.md
b/hudi-agent-gateway/skills/hudi-architect/README.md
index cb9be41ae622..e4796e0818bc 100644
--- a/hudi-agent-gateway/skills/hudi-architect/README.md
+++ b/hudi-agent-gateway/skills/hudi-architect/README.md
@@ -35,10 +35,11 @@ Target Hudi version: **1.2.0**.
- **Spark and HoodieStreamer** retain the existing full design flow,
configuration bundle, and
runnable output.
-- **Flink** combines the routing and safety foundation with one bounded
executable Flink SQL sink
- path for Hudi 1.2.0 and Flink 1.20 (1.20.1 fixtures). New-table,
single-writer, append-only COW
- designs without an external catalog can reach `CONFIG_VALIDATED` after
schema, identity, source
- changelog, append-mode, and checkpoint validation. Other Flink designs
remain fail-closed.
+- **Flink** combines the routing and safety foundation with bounded
append-only and first mutable
+ COW SQL sink paths for Hudi 1.2.0 and Flink 1.20 (1.20.1 fixtures). Mutable
eligibility requires
+ stable identity, event-time ordering, normalized UPSERT changelog, full-row
deletes when present, immutable
+ partitions, and the fixed global FLINK_STATE/bootstrap/no-TTL contract.
Other Flink designs
+ remain fail-closed.
## How to invoke
@@ -120,6 +121,9 @@ python3
hudi-agent-gateway/skills/hudi-architect/validate_flink_design.py \
The validator emits SQL only after all safety and load-bearing checks pass.
The exact SQL is also
planned in CI against the released Hudi 1.2.0 Flink bundle and Flink 1.20.1.
+The mutable schema-2 fixture is
+`hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr3/mutable_cow.json`.
+
Credential-bearing evidence can be sanitized without executing or parsing it
as configuration:
```bash
@@ -140,9 +144,10 @@ This is **Milestone 1 of a longer arc** — meant to be
shareable and playable,
## What's out of scope in Milestone 1
-- Flink upsert/delete, MOR, existing-table composition, external catalogs,
multiple writers,
+- Flink MOR, existing-table takeover, external catalogs, multiple writers,
alternative indexes,
+ custom mergers, retract normalization, partition movement, key-only deletes,
full CDC history,
DataStream API, `HoodieFlinkStreamer`, source connector generation, and
submit/deploy commands.
- Those paths do not reuse the bounded append-only SQL output as if it were
generally applicable.
+ Those paths do not reuse the bounded SQL output as if it were generally
applicable.
- Multi-writer contention tuning — the mode, lock provider, and config bundle
are derived, but retry/timeout tuning and early conflict detection stay at
defaults (they depend on observed behavior, not design-time facts).
- Benchmarking / scale-characterization — different flow shape, future
revision.
diff --git a/hudi-agent-gateway/skills/hudi-architect/RUNBOOK.md
b/hudi-agent-gateway/skills/hudi-architect/RUNBOOK.md
index 44f15739f6f7..5ab8c8d4d05b 100644
--- a/hudi-agent-gateway/skills/hudi-architect/RUNBOOK.md
+++ b/hudi-agent-gateway/skills/hudi-architect/RUNBOOK.md
@@ -25,7 +25,7 @@ A practical guide to running the Hudi Architect agent: what
it does, how to set
(ADR) with tradeoff tables and measurable revisit conditions, a ready-to-use
`hoodie.*` config
bundle, and a runnable submit command — all pinned to **Hudi 1.2.0**. The
Flink route is pinned to
**Hudi 1.2.0 / Flink 1.20** and remains fail-closed by default. It can emit
validated Flink SQL for
-one bounded new-table, single-writer, no-external-catalog, append-only COW
sink path.
+bounded append-only and first mutable new-table, single-writer,
no-external-catalog COW paths.
---
@@ -141,8 +141,10 @@ For a Flink request, the session always includes an
ADR-shaped safety assessment
Hudi 1.2.0 source revision plus Flink 1.20.1 fixture version. Any unresolved
gate returns
`INCOMPLETE`, `BLOCKED`, or `REVIEW_REQUIRED` without SQL. A design in the
bounded executable
surface reaches `CONFIG_VALIDATED` only after the physical target and source
schemas, identity,
-`INSERT_ONLY` source changelog, COW insert append mode
(`write.insert.cluster=false`), and a
-checkpoint interval of at least 1000 ms pass deterministic validation. Its
artifacts are runtime `SET`
+source changelog, fixed write mode, and a checkpoint interval of at least 1000
ms pass deterministic
+validation. Mutable input additionally requires a stable non-null key,
event-time ordering,
+normalized UPSERT changelog, full-row deletes when present, immutable
partitions, and the fixed global
+FLINK_STATE/bootstrap/no-TTL contract. Its artifacts are runtime `SET`
statements, Hudi `CREATE TABLE`, and an explicit-column `INSERT INTO`;
deployment remains manual.
Then: land a first commit in a staging path, run your real read patterns
against it, and check the ADR's operational playbook section for what to
monitor from day one (commit duration, pending compactions, active timeline
size, small-file ratio).
@@ -151,10 +153,11 @@ Then: land a first commit in a staging path, run your
real read patterns against
Be aware of what the agent will *decline* to decide — it defers honestly
rather than guessing, but plan for these yourself:
-- **Flink executable coverage** — only a new-table, single-writer,
no-external-catalog,
- append-only COW streaming SQL sink is implemented. Mutable/upsert/delete,
MOR, existing tables,
- catalogs, multiple writers, DataStream API, `HoodieFlinkStreamer`, source
connector generation,
- and submit/deploy commands remain out of scope.
+- **Flink executable coverage** — bounded append-only and first mutable
new-table, single-writer,
+ no-external-catalog COW streaming SQL sinks are implemented. MOR,
existing-table takeover,
+ catalogs, multiple writers, alternative indexes, custom mergers, retract
normalization,
+ partition movement, key-only deletes, full CDC history, DataStream API,
`HoodieFlinkStreamer`,
+ source connector generation, and submit/deploy commands remain out of scope.
- **Multi-writer contention tuning** — on the Spark route, the agent asks
whether anything else writes the table (every tier but the lightest), derives
the concurrency mode, picks a lock provider, and emits a complete, runnable
block. What it does *not* do is tune for observed contention: lock
retry/timeout values, conflict-retry counts, and early conflict detection stay
at their defaults, because the right values depend on measured behavior rather
than design-time facts. That is Operati [...]
- **Catalog / metastore sync beyond the common paths** — on the Spark route,
the agent asks which engines query the table and derives sync config for Hive
Metastore, AWS Glue, BigQuery, and DataHub, including the partition-extractor
and MOR `_ro`/`_rt` consequences. The current Flink path detects an
external-catalog requirement but emits no catalog configuration. Not covered:
Polaris beyond pointing at the Spark catalog config,
Snowflake/Redshift-specific setup, and per-catalog auth (Ker [...]
diff --git a/hudi-agent-gateway/skills/hudi-architect/SKILL.md
b/hudi-agent-gateway/skills/hudi-architect/SKILL.md
index 0c51f03f4b37..bae7f9a110df 100644
--- a/hudi-agent-gateway/skills/hudi-architect/SKILL.md
+++ b/hudi-agent-gateway/skills/hudi-architect/SKILL.md
@@ -75,11 +75,14 @@ that manifest with options discovered from the enclosing
checkout. If the user e
another Hudi or Flink version, do not silently reuse the baseline: add a
`REVIEW_REQUIRED` finding
unless a separate verified capability reference exists.
-The Flink implementation includes the PR1 safety foundation and the first PR2
executable path. It
-may generate Flink SQL only for a new-table, confirmed-single-writer,
append-only COW streaming
-sink whose source contract is `INSERT_ONLY`. Every safety gate must pass, and
-`validate_flink_design.py` must return `CONFIG_VALIDATED`. Mutable COW, MOR,
existing-table,
-catalog, and multi-writer requests still fail closed for their later PRs.
+The Flink implementation includes the PR1 safety foundation, the PR2
append-only path, and the
+first bounded PR3 mutable COW path. Both require a new table, confirmed single
writer, no external
+catalog, streaming checkpointing, and `CONFIG_VALIDATED` from
`validate_flink_design.py`. The
+mutable path additionally requires stable non-null identity, one event-time
ordering field,
+normalized UPSERT changelog, full-row deletes when present, immutable
partitions, and the fixed global
+FLINK_STATE/bootstrap/no-TTL settings. MOR, existing-table takeover, catalog,
multi-writer,
+alternative indexes, custom mergers, retract normalization, and full CDC
history still fail
+closed.
## Flow structure
@@ -88,7 +91,7 @@ The conversation has three parts:
1. **Tier gate** — a single scoping question to figure out which downstream
questions fire.
2. **Rounds 1-3** — workload questions, gated conditionally by tier.
3. **Output** — engine-specific output: the established Spark artifacts, a
non-executable Flink
- assessment, or the bounded validator-rendered PR2 SQL artifacts.
+ assessment, or bounded validator-rendered PR2/PR3 SQL artifacts.
Load `references/question-flow.md` for the full round-by-round question list
with conditional gating.
@@ -141,7 +144,7 @@ Internal labels for these four tiers: `EXPLORATION`,
`PROTOTYPING`, `PRODUCTIONI
**What fires per tier:**
- **EXPLORATION** — Round 1 abbreviated, concept-explanation focused. May not
produce a full ADR — often a "here's what your workload would look like as a
Hudi table" narrative. Replace hard questions with explanations ("Hudi supports
Spark and Flink — Spark is most common; I'll assume Spark unless you say
otherwise").
-- **PROTOTYPING** — Round 1, then a **disclosed-defaults consent block** for
table size / partitioning / retention, then **hard-ask the non-defaultable
facts**: record key and ordering field when mutable, and whether anything else
writes the table. On an implemented executable route, the goal is a genuinely
runnable first table, not a sketch, and a prototyping ADR must not ship
placeholder values. The Flink PR2 path additionally requires a concrete target,
source contract, and a checkpoi [...]
+- **PROTOTYPING** — Round 1, then a **disclosed-defaults consent block** for
table size / partitioning / retention, then **hard-ask the non-defaultable
facts**: record key and ordering field when mutable, and whether anything else
writes the table. On an implemented executable route, the goal is a genuinely
runnable first table, not a sketch, and a prototyping ADR must not ship
placeholder values. The Flink executable paths additionally require a concrete
target, source contract, and a c [...]
- **PRODUCTIONIZING_INITIAL** — Rounds 1 + 2. Full
mutation/identity/partitioning questions. Production-safe defaults.
- **PRODUCTION_AT_SCALE** — All rounds. Full rubric. Guardrails strict. All
revisit conditions surfaced.
@@ -211,7 +214,7 @@ The Flink status vocabulary is:
- `BLOCKED` — the requested path is known to be unsupported or outside the
currently implemented
Flink capability.
- `REVIEW_REQUIRED` — a compatibility or operational risk needs human
confirmation.
-- `CONFIG_VALIDATED` — every safety gate and load-bearing PR2 design value
passed pinned static
+- `CONFIG_VALIDATED` — every safety gate and load-bearing executable design
value passed pinned static
validation, and canonical SQL was emitted. It does not prove the live
environment.
**Revisit conditions must be measurable.** Not "revisit if write amp becomes
an issue." Yes: "if p95 commit duration exceeds the ingestion interval on a COW
table above 1TB, evaluate switching to MOR — note this requires a table
rewrite, so decide before the table grows further."
diff --git
a/hudi-agent-gateway/skills/hudi-architect/references/flink-1.20-hudi-1.2.0-capabilities.md
b/hudi-agent-gateway/skills/hudi-architect/references/flink-1.20-hudi-1.2.0-capabilities.md
index b53f5dc78fb6..7aead7775916 100644
---
a/hudi-agent-gateway/skills/hudi-architect/references/flink-1.20-hudi-1.2.0-capabilities.md
+++
b/hudi-agent-gateway/skills/hudi-architect/references/flink-1.20-hudi-1.2.0-capabilities.md
@@ -82,7 +82,15 @@ the current checkout contains it.
| Append-only record-key posture detection | Supported |
| Auto-generated-key durability warning | Supported |
| Replay-idempotence classification | Supported |
-| Mutable COW, upsert, and deletes | `BLOCKED`; deferred to PR3 |
+| First bounded mutable COW upsert/delete path | Supported after schema-2
validation |
+| Stable non-null mutable record key | Required; auto-key is `BLOCKED` |
+| Mutable ordering | One non-null `BIGINT` or `TIMESTAMP(p<=6)` event-time
field |
+| Mutable source changelog | Normalized UPSERT (`I`, `UA`, `D`, no `UB`) |
+| Mutable delete payload | Full projected row required when deletes are
emitted |
+| Partition changes for an existing key | `BLOCKED`; immutable partition
values required |
+| Mutable index | Global `FLINK_STATE`, TTL 0, bootstrap enabled |
+| Alternative indexes, expiring state, custom mergers, or retract
normalization | `BLOCKED`; deferred |
+| Full changelog history / Hudi CDC query | Not claimed; output is
latest-state behavior |
| MOR and compaction ownership | Deferred to PR4 |
| New-table, single-writer, append-only COW SQL | Supported after static
validation |
| Stable-key DDL | `PRIMARY KEY (...) NOT ENFORCED` |
@@ -160,13 +168,33 @@ path deliberately applies a stricter 1000 ms safety floor
and blocks lower value
silently increasing them. The pinned runtime test preserves the 9/10 ms
dependency boundary, and
the Python validator tests preserve the 999/1000 ms Architect boundary.
+## Implemented PR3 mutable COW contract
+
+The schema-2 path is deliberately narrower than the full Hudi upsert surface.
It accepts only a
+new table, one writer, no external catalog, streaming checkpoints, stable
non-null identity, one
+event-time ordering field, normalized UPSERT input, immutable partitions, and
full-row deletes when
+present. All three resolved replay answers are eligible; only an unresolved
replay posture blocks
+validation. The exact ordering-field value `no_precombine` is rejected because
Hudi 1.2.0 resolves
+that sentinel to no ordering field even if the physical schema contains a
same-named column.
+The renderer fixes `COPY_ON_WRITE`, `upsert`, `EVENT_TIME_ORDERING`, global
`FLINK_STATE`, state
+TTL `0`, index bootstrap, and `changelog.enabled=false`; it accepts no
pass-through override.
+
+The pinned Flink planner fixture consumes the exact SQL golden with an
`I/UA/D` source and proves
+that the generated graph includes changelog normalization, the Hudi stream
writer, and
+`index_bootstrap`. Direct pinned-option fixtures preserve the ordering, index,
TTL, bootstrap, and
+changelog values. Python tests cover the successful golden and fail-closed
deviations.
+
+Bootstrap closes the cold-start gap for a table created and operated under
this contract: state
+loss must not make existing keys look new. It does not expand the path into
arbitrary
+existing-table takeover; that still needs the separate PR5 evidence and
compatibility flow.
+
## Status vocabulary
- `INCOMPLETE` — a required workload fact, schema, or lifecycle fact is
missing.
- `BLOCKED` — the requested path is known to be outside the currently
implemented Flink scope.
- `REVIEW_REQUIRED` — compatibility, writer topology, catalog behavior, or
another operational
risk requires human confirmation.
-- `CONFIG_VALIDATED` — every PR1 gate passed and all load-bearing PR2 design
values passed the
+- `CONFIG_VALIDATED` — every applicable gate and all load-bearing PR2 or PR3
design values passed the
pinned static validator. Canonical SQL was emitted without unresolved
placeholders.
No status claims that storage permissions, JAR deployment, source
availability, catalog
diff --git
a/hudi-agent-gateway/skills/hudi-architect/references/flink-1.20-hudi-1.2.0-capabilities.toml
b/hudi-agent-gateway/skills/hudi-architect/references/flink-1.20-hudi-1.2.0-capabilities.toml
index 86f60969719a..d193a36d97b6 100644
---
a/hudi-agent-gateway/skills/hudi-architect/references/flink-1.20-hudi-1.2.0-capabilities.toml
+++
b/hudi-agent-gateway/skills/hudi-architect/references/flink-1.20-hudi-1.2.0-capabilities.toml
@@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-schema_version = 2
+schema_version = 3
baseline_id = "hudi-1.2.0-flink-1.20"
validation_surface = "initial-flink-sql-sink"
@@ -43,7 +43,26 @@ require_checkpointing = true
allow_record_key_modes = ["stable_key", "auto_key"]
allow_replay_behaviors = ["cannot_occur", "duplicates_acceptable"]
-# PR2 intentionally validates a bounded scalar physical-schema surface.
Complex,
+[mutable_executable_contract]
+contract_schema = 2
+table_lifecycle = "new"
+writer_model = "single_writer"
+external_catalog = false
+mutability = "mutable"
+source_changelog_mode = "UPSERT"
+execution_mode = "STREAMING"
+require_checkpointing = true
+record_key_mode = "stable_key"
+allow_replay_behaviors = ["cannot_occur", "duplicates_acceptable",
"must_collapse"]
+ordering_mode = "event_time"
+ordering_field_types = ["BIGINT", "TIMESTAMP"]
+reserved_ordering_field_values = ["no_precombine"]
+allow_emits_deletes = [false, true]
+delete_payload_when_deletes = "FULL_ROW"
+delete_payload_without_deletes = "NOT_APPLICABLE"
+partition_fields_mutable = false
+
+# The executable paths intentionally validate a bounded scalar physical-schema
surface. Complex,
# computed, metadata, and watermark columns remain unverified rather than
being guessed.
[physical_types]
simple = [
@@ -123,7 +142,7 @@ purpose = "Writer-side prepending of Hudi metadata fields
to the Flink row type"
id = "options_resolver"
path =
"hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java"
sha256 = "1a63ef88bbdf84cbcebb92f402557c21b6f84bdd0112726792ce22da635ad766"
-purpose = "Effective append-mode predicate"
+purpose = "Effective append-mode predicate and no-precombine ordering sentinel
resolution"
[[source_anchors]]
id = "table_factory"
@@ -149,8 +168,56 @@ path =
"hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/Tes
sha256 = "ef0ae3752cc8ed63f9b6ae83a4aa34d61b30bc163282a658a2d52135154ff15a"
purpose = "Release behavior exercised by the existing factory fixtures"
-# This is an allowlist for the first executable sink path, not a claim that it
+[[source_anchors]]
+id = "table_sink"
+path =
"hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSink.java"
+sha256 = "59c36ec6c97524386c4ce0fb7c70960816a6cfc9dee89e82de86f401f28e1087"
+purpose = "Sink changelog-mode negotiation for upsert writes"
+
+[[source_anchors]]
+id = "changelog_modes"
+path =
"hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ChangelogModes.java"
+sha256 = "ca48d1fbc3cd7e1f6bd7009f8c1cf93d130284cba40a47b245e9002f1733797a"
+purpose = "Exact INSERT, UPDATE_AFTER, and DELETE upsert changelog contract"
+
+[[source_anchors]]
+id = "index_backend_factory"
+path =
"hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexBackendFactory.java"
+sha256 = "57cc58e85475ae32c35da57e9d5df7e8ac040edfd26cccb8859407a25233b722"
+purpose = "FLINK_STATE backend and zero-TTL state behavior"
+
+[[source_anchors]]
+id = "pipelines"
+path =
"hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java"
+sha256 = "9278096f0894a0ad34be09206ee06d317fda4185101833018a314e87ade5e262"
+purpose = "Index bootstrap insertion into the Flink write pipeline"
+
+[[source_anchors]]
+id = "bootstrap_operator"
+path =
"hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java"
+sha256 = "67ea9f1dede0cfe81252613bf158d5fa64dec8e3040f56d50aa6795d7b501e1d"
+purpose = "Existing key and file-location recovery into Flink state"
+
+[[source_anchors]]
+id = "bucket_assign_function"
+path =
"hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java"
+sha256 = "89c36a6c64fb4524505b0ee4c1ab7de68b4a2b9343df659bbc873ecc3b259cad"
+purpose = "Global-index record routing and partition-change behavior"
+
+[[source_anchors]]
+id = "streamer_util"
+path =
"hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java"
+sha256 = "c771e137ebcd45c94a941857a246dc741e1dde2c0e0158900fd48fe8884f83e2"
+purpose = "Write-side record merge mode inference and table-property
persistence"
+
+# This is an allowlist for the bounded executable sink paths, not a claim that
it
# enumerates every option available in Hudi 1.2.0.
+[[verified_options]]
+key = "changelog.enabled"
+value_type = "boolean"
+source_anchors = ["flink_options", "table_sink", "changelog_modes"]
+source_symbol = "CHANGELOG_ENABLED"
+
[[verified_options]]
key = "hoodie.datasource.write.partitionpath.field"
value_type = "string"
@@ -163,10 +230,40 @@ value_type = "string"
source_anchors = ["flink_options", "key_generator_options"]
source_symbol = "RECORD_KEY_FIELD"
+[[verified_options]]
+key = "hoodie.write.record.merge.mode"
+value_type = "string"
+source_anchors = ["flink_options", "streamer_util"]
+source_symbol = "RECORD_MERGE_MODE"
+
+[[verified_options]]
+key = "index.bootstrap.enabled"
+value_type = "boolean"
+source_anchors = ["flink_options", "pipelines", "bootstrap_operator"]
+source_symbol = "INDEX_BOOTSTRAP_ENABLED"
+
+[[verified_options]]
+key = "index.global.enabled"
+value_type = "boolean"
+source_anchors = ["flink_options", "bucket_assign_function"]
+source_symbol = "INDEX_GLOBAL_ENABLED"
+
+[[verified_options]]
+key = "index.state.ttl"
+value_type = "double"
+source_anchors = ["flink_options", "index_backend_factory"]
+source_symbol = "INDEX_STATE_TTL"
+
+[[verified_options]]
+key = "index.type"
+value_type = "string"
+source_anchors = ["flink_options", "index_backend_factory"]
+source_symbol = "INDEX_TYPE"
+
[[verified_options]]
key = "ordering.fields"
value_type = "string"
-source_anchors = ["flink_options"]
+source_anchors = ["flink_options", "options_resolver"]
source_symbol = "ORDERING_FIELDS"
[[verified_options]]
@@ -208,6 +305,46 @@ key = "write.operation"
value = "insert"
applies_to = "pr2-append-only-cow"
+[[required_effective_settings]]
+key = "changelog.enabled"
+value = false
+applies_to = "pr3-mutable-cow"
+
+[[required_effective_settings]]
+key = "hoodie.write.record.merge.mode"
+value = "EVENT_TIME_ORDERING"
+applies_to = "pr3-mutable-cow"
+
+[[required_effective_settings]]
+key = "index.bootstrap.enabled"
+value = true
+applies_to = "pr3-mutable-cow"
+
+[[required_effective_settings]]
+key = "index.global.enabled"
+value = true
+applies_to = "pr3-mutable-cow"
+
+[[required_effective_settings]]
+key = "index.state.ttl"
+value = 0.0
+applies_to = "pr3-mutable-cow"
+
+[[required_effective_settings]]
+key = "index.type"
+value = "FLINK_STATE"
+applies_to = "pr3-mutable-cow"
+
+[[required_effective_settings]]
+key = "table.type"
+value = "COPY_ON_WRITE"
+applies_to = "pr3-mutable-cow"
+
+[[required_effective_settings]]
+key = "write.operation"
+value = "upsert"
+applies_to = "pr3-mutable-cow"
+
[[implemented_acceptance_checks]]
id = "FLINK_BINARY_ROUTING_FIELD_UNSUPPORTED"
requirement = "Reject binary record-key and partition fields whose Hudi 1.2.0
routing strings depend on Java object identity."
@@ -255,3 +392,27 @@ id = "FLINK_CHECKPOINT_INTERVAL_UNSUPPORTED"
requirement = "Reject checkpoint intervals below the 1000 ms Architect safety
floor while preserving the pinned Flink 10 ms runtime boundary as evidence."
python_test = "test_pr2_enforces_checkpoint_interval_safety_floor"
java_test = "testPinnedRuntimeCheckpointIntervalBoundary"
+
+[[implemented_acceptance_checks]]
+id = "FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED"
+requirement = "Require a normalized UPSERT source and verify the Hudi sink
consumes INSERT, UPDATE_AFTER, and DELETE without UPDATE_BEFORE."
+python_test = "test_pr3_rejects_unsupported_source_changelog"
+java_test = "testMutableCowSinkAndUpsertPlan"
+
+[[implemented_acceptance_checks]]
+id = "FLINK_MUTABLE_ORDERING_FIELD_INVALID"
+requirement = "Require one non-null BIGINT or TIMESTAMP(p<=6) event-time
ordering field and reject Hudi's no_precombine sentinel."
+python_test = "test_pr3_rejects_invalid_ordering_fields"
+java_test = "testPinnedMutableOrderingConfiguration"
+
+[[implemented_acceptance_checks]]
+id = "FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED"
+requirement = "Pin global FLINK_STATE with no state expiry for replay-safe
mutable routing."
+python_test = "test_pr3_rejects_unsafe_index_configuration"
+java_test = "testPinnedMutableIndexConfiguration"
+
+[[implemented_acceptance_checks]]
+id = "FLINK_MUTABLE_INDEX_BOOTSTRAP_REQUIRED"
+requirement = "Require index bootstrap so a cold restart does not treat
existing keys as new."
+python_test = "test_pr3_requires_index_bootstrap"
+java_test = "testPinnedMutableIndexBootstrapConfiguration"
diff --git
a/hudi-agent-gateway/skills/hudi-architect/references/flink-config-templates.md
b/hudi-agent-gateway/skills/hudi-architect/references/flink-config-templates.md
index 2796fa902e44..2759417f0934 100644
---
a/hudi-agent-gateway/skills/hudi-architect/references/flink-config-templates.md
+++
b/hudi-agent-gateway/skills/hudi-architect/references/flink-config-templates.md
@@ -15,7 +15,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
-# Flink output template — first executable PR2 sink
+# Flink output template — bounded executable sinks
Use this file only after the Flink route is selected. The PR1 safety gates
still control entry to
the executable path. Never fall back to shared Spark configuration templates.
@@ -30,8 +30,8 @@ Verified baseline: Apache Hudi 1.2.0 / Apache Flink 1.20
Baseline ID: hudi-1.2.0-flink-1.20
Hudi source revision: f05c83f2b97732de7a558ff9b26959e1139c05f5
Fixture version: Apache Flink 1.20.1
-Capability manifest schema: 2
-Design contract schema: 1
+Capability manifest schema: 3
+Design contract schema: <1 append-only | 2 mutable COW>
Confirmed facts:
- <facts established by explicit answers>
@@ -65,26 +65,28 @@ Verified baseline: Apache Hudi 1.2.0 / Apache Flink 1.20
Baseline ID: hudi-1.2.0-flink-1.20
Hudi source revision: f05c83f2b97732de7a558ff9b26959e1139c05f5
Fixture version: Apache Flink 1.20.1
-Capability manifest schema: 2
-Design contract schema: 1
+Capability manifest schema: 3
+Design contract schema: <1 append-only | 2 mutable COW>
Confirmed facts:
- New table; confirmed single writer; no current external-catalog requirement.
-- Append-only input; replay behavior: <cannot occur | duplicates accepted>.
-- Identity: <stable key fields | explicitly accepted auto-generated keys>.
+- Workload: <append-only or mutable, with confirmed replay behavior>.
+- Identity: <stable key fields | explicitly accepted append-only
auto-generated keys>.
+- Mutable only: one event-time ordering field; immutable partition values;
full-row delete posture.
Source contract:
- Existing table: <source identifier>
- Expected physical schema: <Avro-compatible field names, bounded types,
nullability>
-- Changelog: INSERT_ONLY
+- Changelog: <INSERT_ONLY for schema 1 | normalized UPSERT (I/UA/D, no UB) for
schema 2>
- The source connector and live source availability were not validated.
Target contract:
- Table: <target identifier>
- Path: <credential-free concrete path>
- Table type: COPY_ON_WRITE
-- Operation: insert
-- Insert clustering: false
+- Operation: <insert for schema 1 | upsert for schema 2>
+- Path-specific fixed settings: <insert clustering false | EVENT_TIME_ORDERING
plus global
+ FLINK_STATE, TTL 0, bootstrap true, and changelog false>
Runtime contract:
- Execution: streaming
@@ -114,8 +116,7 @@ SET 'execution.checkpointing.interval' = '<milliseconds,
minimum 1000> ms';
Flink 1.20.1 has a lower runtime boundary of 10 ms, but the bounded Architect
path intentionally
requires at least 1000 ms and never raises a supplied value implicitly.
-The generated `CREATE TABLE` contains a Hudi `WITH` block with exactly the
bounded load-bearing
-settings:
+For schema 1, the generated `CREATE TABLE` contains exactly these bounded
load-bearing settings:
```sql
WITH (
@@ -136,22 +137,48 @@ even a verified option when the bounded renderer has no
canonical place for it.
The angle-bracket values in this reference explain the shape only. They are
forbidden in an actual
`CONFIG_VALIDATED` artifact; the validator requires concrete values.
+For schema 2 mutable COW, the canonical block is:
+
+```sql
+WITH (
+ 'connector' = 'hudi',
+ 'path' = '<concrete credential-free target path>',
+ 'table.type' = 'COPY_ON_WRITE',
+ 'write.operation' = 'upsert',
+ 'ordering.fields' = '<one non-null BIGINT or TIMESTAMP(p<=6) field>',
+ 'hoodie.write.record.merge.mode' = 'EVENT_TIME_ORDERING',
+ 'index.type' = 'FLINK_STATE',
+ 'index.global.enabled' = 'true',
+ 'index.state.ttl' = '0',
+ 'index.bootstrap.enabled' = 'true',
+ 'changelog.enabled' = 'false'
+)
+```
+
+The mutable source must declare the same primary key as the target and
normalized UPSERT
+changelog (`I`, `UA`, `D`, no `UB`). Deletes, when emitted, must be full-row;
partition values are immutable.
+This is a latest-state sink contract; it is not full changelog-history or
CDC-query support.
+
## Deployment checks
Always retain these outside the static success claim:
- Deploy `org.apache.hudi:hudi-flink1.20-bundle:1.2.0` with the target Flink
1.20 environment.
- Verify storage permissions and the concrete target path.
-- Verify that the declared source table exists and preserves the expected
insert-only contract.
+- Verify that the declared source table exists and preserves its validated
INSERT_ONLY or
+ normalized UPSERT contract.
- Configure durable checkpoint storage and a restart strategy appropriate to
the environment.
- Confirm the single-writer inventory again immediately before deployment.
- After recovery, verify that completed checkpoints correspond to completed
Hudi instants.
-## Still forbidden in PR2
+## Still forbidden in the bounded paths
- A complete Kafka, CDC, JDBC, or other source connector job.
- A Flink deployment or submit command.
- A Spark fallback.
-- OCC, NBCC, lock-provider, index, catalog, MOR, compaction, or clustering
configuration.
+- OCC, NBCC, lock-provider, alternative index, catalog, MOR, compaction, or
clustering
+ configuration.
+- Existing-table takeover, multi-writer operation, expiring state index,
key-only deletes,
+ retract-stream normalization, partition movement, custom mergers, or full
CDC history.
- A claim that static validation checked live storage, the deployed JAR, the
live source, active
writers, checkpoint completion, or end-to-end exactly-once behavior.
diff --git
a/hudi-agent-gateway/skills/hudi-architect/references/flink-decision-overrides.md
b/hudi-agent-gateway/skills/hudi-architect/references/flink-decision-overrides.md
index 334f99c97253..92dd6baae6cb 100644
---
a/hudi-agent-gateway/skills/hudi-architect/references/flink-decision-overrides.md
+++
b/hudi-agent-gateway/skills/hudi-architect/references/flink-decision-overrides.md
@@ -15,7 +15,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
-# Flink decision overrides — first executable PR2 path
+# Flink decision overrides — bounded executable SQL paths
These rules constrain the shared Hudi Architect decisions after the Flink
route is selected. They
do not replace the shared table-design rules and do not form a standalone
planner API.
@@ -45,8 +45,9 @@ do not replace the shared table-design rules and do not form
a standalone planne
safety floor.
- Do not equate a stable record key with replay idempotence.
- Do not reuse this baseline for another Hudi or Flink version.
-- Emit executable output only when `validate_flink_design.py` returns
`CONFIG_VALIDATED` for the
- complete version-1 design contract. Never hand-write around a validator
finding.
+- Emit executable output only when `validate_flink_design.py` returns
`CONFIG_VALIDATED` for a
+ complete version-1 append-only or version-2 mutable contract. Never
hand-write around a
+ validator finding.
## Gate outcomes
@@ -62,12 +63,12 @@ do not replace the shared table-design rules and do not
form a standalone planne
| External catalog required | `FLINK_EXTERNAL_CATALOG_REVIEW` |
`REVIEW_REQUIRED` | No |
| Catalog requirement unknown | `FLINK_CATALOG_REQUIREMENT_UNRESOLVED` |
`REVIEW_REQUIRED` | No |
| Physical schema missing, insufficient, or available only through an
unreadable pointer | `FLINK_PHYSICAL_SCHEMA_REQUIRED` | `INCOMPLETE` | No |
-| Mutable workload | `FLINK_MUTABLE_COW_DEFERRED` | `BLOCKED` | No |
+| Mutable workload supplied through the version-1 append contract |
`FLINK_MUTABLE_COW_DEFERRED` | `BLOCKED` | No |
| Mutability unknown | `FLINK_MUTABILITY_REQUIRED` | `INCOMPLETE` | No |
| Record-key posture unknown | `FLINK_RECORD_KEY_POSTURE_REQUIRED` |
`INCOMPLETE` | No |
| No stable key and auto-key acceptance is pending or unanswered |
`FLINK_AUTO_KEY_ACCEPTANCE_REQUIRED` | `INCOMPLETE` | No |
| No stable key and the auto-key posture is explicitly declined |
`FLINK_AUTO_KEY_DECLINED` | `BLOCKED` | No |
-| Replay copies must collapse | `FLINK_REPLAY_IDEMPOTENCE_DEFERRED` |
`BLOCKED` | No |
+| Append-only replay copies must collapse |
`FLINK_REPLAY_IDEMPOTENCE_DEFERRED` | `BLOCKED` | No |
| Replay behavior unknown | `FLINK_REPLAY_BEHAVIOR_UNRESOLVED` |
`REVIEW_REQUIRED` | No |
| Stable or explicit record-key option references a missing field |
`FLINK_RECORD_KEY_FIELD_MISSING` | `BLOCKED` | No |
| Stable record-key field is nullable | `FLINK_RECORD_KEY_NULLABLE` |
`BLOCKED` | No |
@@ -83,13 +84,22 @@ do not replace the shared table-design rules and do not
form a standalone planne
| Source changelog is not `INSERT_ONLY` |
`FLINK_SOURCE_CHANGELOG_NOT_APPEND_ONLY` | `BLOCKED` | No |
| Effective `write.insert.cluster` is not `false` |
`FLINK_APPEND_MODE_CLUSTERING_ENABLED` | `BLOCKED` | No |
| Table type, operation, or execution mode is outside PR2 |
`FLINK_PR2_WRITE_PATH_UNSUPPORTED` | `BLOCKED` | No |
+| Mutable path uses auto-generated keys | `FLINK_MUTABLE_AUTO_KEY_UNSUPPORTED`
| `BLOCKED` | No |
+| Mutable source is not normalized UPSERT (`I`, `UA`, `D`, no `UB`) |
`FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED` | `BLOCKED` | No |
+| Mutable ordering field is absent, nullable, outside BIGINT/TIMESTAMP(p≤6),
or is `no_precombine` | `FLINK_MUTABLE_ORDERING_FIELD_INVALID` | `BLOCKED` | No
|
+| Mutable ordering decision is absent or not event-time |
`FLINK_MUTABLE_ORDERING_FIELD_REQUIRED` | `INCOMPLETE` | No |
+| A mutable delete does not carry the full projected row |
`FLINK_MUTABLE_DELETE_PAYLOAD_UNSUPPORTED` | `BLOCKED` | No |
+| Partition values can change for an existing key |
`FLINK_MUTABLE_PARTITION_EVOLUTION_UNSUPPORTED` | `BLOCKED` | No |
+| Mutable index is not global `FLINK_STATE` with TTL 0 |
`FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED` | `BLOCKED` | No |
+| FLINK_STATE index bootstrap is not enabled |
`FLINK_MUTABLE_INDEX_BOOTSTRAP_REQUIRED` | `BLOCKED` | No |
+| Table type, operation, merge mode, changelog setting, or execution mode is
outside PR3 | `FLINK_PR3_WRITE_PATH_UNSUPPORTED` | `BLOCKED` | No |
| Checkpointing is explicitly disabled | `FLINK_CHECKPOINTING_REQUIRED` |
`BLOCKED` | No |
| Checkpoint interval is absent or non-positive |
`FLINK_CHECKPOINT_INTERVAL_REQUIRED` | `INCOMPLETE` | No |
| Positive checkpoint interval is below the 1000 ms Architect safety floor |
`FLINK_CHECKPOINT_INTERVAL_UNSUPPORTED` | `BLOCKED` | No |
| Target table, target path, source table, or another load-bearing value is a
placeholder | `FLINK_LOAD_BEARING_VALUE_REQUIRED` | `INCOMPLETE` | No |
| Design-contract structure is malformed or contains an ignored field |
`FLINK_DESIGN_CONTRACT_INVALID` | `INCOMPLETE` | No |
| A connector option is outside the pinned allowlist |
`FLINK_OPTION_NOT_VERIFIED` | `REVIEW_REQUIRED` | No |
-| Complete PR2 contract passes deterministic validation | — |
`CONFIG_VALIDATED` | Yes |
+| Complete PR2 or PR3 contract passes deterministic validation | — |
`CONFIG_VALIDATED` | Yes |
## Final-status precedence
@@ -103,7 +113,7 @@ After collection, choose one final status with this
precedence:
1. A known unsupported or not-yet-implemented path makes the result `BLOCKED`.
2. Otherwise, an unverified compatibility or operational risk makes it
`REVIEW_REQUIRED`.
3. Otherwise, a missing required fact makes it `INCOMPLETE`.
-4. Only if no finding exists, every PR1 safety gate passed, and the PR2 design
validator emitted
+4. Only if no finding exists, every applicable safety gate passed, and the
design validator emitted
canonical SQL, return `CONFIG_VALIDATED` with executable eligibility `true`.
Status precedence changes only the single summary status; it never removes a
lower-precedence
@@ -112,9 +122,14 @@ only bounded static validation against the pinned release
contract.
## Record-key and replay classification
-| Mutability | Stable business key | Replay requirement | PR2 result |
+| Mutability | Stable business key | Replay requirement | Result |
|---|---|---|---|
-| Mutable | Required | Any | `BLOCKED` — mutable COW is deferred |
+| Mutable | Yes | Replays impossible | Continue into the bounded PR3 mutable
contract |
+| Mutable | Yes | Duplicates acceptable | Continue; record duplicate tolerance
|
+| Mutable | Yes | Copies must collapse | Continue into the bounded PR3 mutable
contract |
+| Mutable | Yes | Replay behavior unknown | `REVIEW_REQUIRED` |
+| Mutable | No | Any | `BLOCKED` — auto-key cannot express mutable identity |
+| Mutable | Unknown | Any | `INCOMPLETE` |
| Append-only | Yes | Replays impossible | Continue safety gates; stable key
preferred |
| Append-only | Yes | Duplicates acceptable | Continue; record duplicate
tolerance |
| Append-only | Yes | Copies must collapse | `BLOCKED` — upsert-capable design
required |
@@ -156,8 +171,8 @@ and executable eligibility, not exact natural-language
wording.
| `F05_CATALOG` | External consumer requires catalog visibility |
`REVIEW_REQUIRED` | No |
| `F06_SCHEMA` | Physical schema is missing | `INCOMPLETE` | No |
| `F06_SCHEMA_POINTER` | Only a schema location is supplied; field names and
types are unavailable | `INCOMPLETE` | No |
-| `F07_MUTABLE` | Updates or deletes occur | `BLOCKED` | No |
-| `F08_REPLAY_COLLAPSE` | Independent replay must deduplicate | `BLOCKED` | No
|
+| `F07_MUTABLE` | Otherwise-safe mutable workload enters the schema-2
candidate flow | Proceed to PR3 validation | Not decided by gates alone |
+| `F08_REPLAY_COLLAPSE` | Append-only independent replay must deduplicate |
`BLOCKED` | No |
| `F09_REPLAY_UNKNOWN` | Replay behavior is unknown | `REVIEW_REQUIRED` | No |
| `F10_SAFE_APPEND` | Baseline new-table single-writer append-only path with a
stable key passes every gate | Proceed to PR2 validation | Not decided by gates
alone |
| `F11_COMBINED_GATES` | Writer model unknown, physical schema missing, and
replay copies must collapse | `BLOCKED` | No |
@@ -191,6 +206,22 @@ to the executable contract just like `F10_SAFE_APPEND`.
- The Hudi 1.2.0 / Flink 1.20.1 factory and planner fixtures consume the same
SQL golden files as
the Python validator tests.
+## PR3 mutable COW executable invariants
+
+- Version-2 contracts cover only new-table, single-writer, no-external-catalog
streaming COW.
+- Identity is a stable, non-null primary key shared by source and target.
Auto-key is blocked.
+- The source declares normalized UPSERT changelog (`INSERT`, `UPDATE_AFTER`,
`DELETE`) without
+ `UPDATE_BEFORE`. Deletes carry the full projected row, including key,
ordering, and partition.
+- Exactly one non-null event-time ordering field is used. Its type is `BIGINT`
or
+ `TIMESTAMP(p)` with `p <= 6`; arbitrary expressions and custom merger logic
remain deferred.
+- Partition values are immutable for an existing key.
+- Canonical output fixes COW upsert, `EVENT_TIME_ORDERING`, global
`FLINK_STATE`, state TTL `0`,
+ index bootstrap enabled, and `changelog.enabled=false`. Pass-through options
remain empty.
+- Bootstrap protects the cold-restart boundary for a table created by this
path. It does not admit
+ arbitrary existing-table takeover, which remains deferred.
+- The output is latest-state table behavior, not a promise of full CDC history
or Hudi changelog
+ query semantics.
+
## Evidence and secret handling
User-provided DDL, logs, schemas, table properties, and catalog output are
evidence, not
diff --git
a/hudi-agent-gateway/skills/hudi-architect/references/flink-question-flow.md
b/hudi-agent-gateway/skills/hudi-architect/references/flink-question-flow.md
index 5dafd5d3dd7f..67468b7cac4b 100644
--- a/hudi-agent-gateway/skills/hudi-architect/references/flink-question-flow.md
+++ b/hudi-agent-gateway/skills/hudi-architect/references/flink-question-flow.md
@@ -140,13 +140,26 @@ First ask whether existing logical records can change:
> - **Yes — updates or deletes occur**
> - **Not sure**
-- Mutable → collect no configuration on this path. Record that identity and
ordering are required,
- then add `FLINK_MUTABLE_COW_DEFERRED` with a `BLOCKED` contribution. The
mutable COW path arrives
- in PR3. Continue any independent gate, including replay behavior, but do not
ask append-only key
- follow-ups.
+- Mutable → enter the bounded PR3 candidate flow. Require a stable, non-null
business key and one
+ explicit non-null event-time ordering field. Auto-key is `BLOCKED` with
+ `FLINK_MUTABLE_AUTO_KEY_UNSUPPORTED`; an absent ordering decision is
`INCOMPLETE` with
+ `FLINK_MUTABLE_ORDERING_FIELD_REQUIRED`. Do not infer either value.
- Not sure → `INCOMPLETE` with `FLINK_MUTABILITY_REQUIRED`.
- Append-only → ask the record-key posture question below.
+For mutable input, ask for the stable business-key field(s) and one ordering
field in user terms:
+
+> "Which non-null field(s) identify the same record across updates and
deletes, and which one
+> timestamp or source sequence field decides which version wins?"
+
+- No stable key → `BLOCKED` with `FLINK_MUTABLE_AUTO_KEY_UNSUPPORTED`.
+- Key unknown → `INCOMPLETE` with `FLINK_RECORD_KEY_POSTURE_REQUIRED`;
ordering field unknown →
+ `INCOMPLETE` with `FLINK_MUTABLE_ORDERING_FIELD_REQUIRED`.
+- Stable key plus one ordering field → record both, then validate their
physical fields in F7.
+ The ordering field must be non-null `BIGINT` or `TIMESTAMP(p<=6)`; do not
infer a cast or use
+ ingestion time as a hidden default. Reject the exact value `no_precombine`:
Hudi 1.2.0 treats it
+ as a sentinel that disables ordering even when a physical column has that
name.
+
For append-only input:
> "Does the data contain stable field(s) that identify the same business
> record across retries
@@ -194,8 +207,8 @@ For append-only input:
- Cannot occur → continue and record the confirmed assumption boundary.
- Duplicates acceptable → continue and record duplicate tolerance in the ADR.
-- Copies must collapse → `BLOCKED` with `FLINK_REPLAY_IDEMPOTENCE_DEFERRED`;
this requires a
- stable key and an upsert-capable path.
+- Copies must collapse → continue only for an otherwise eligible mutable
schema-2 path. For the
+ append-only schema-1 path, use `FLINK_REPLAY_IDEMPOTENCE_DEFERRED` with
`BLOCKED`.
- Not sure → `REVIEW_REQUIRED` with `FLINK_REPLAY_BEHAVIOR_UNRESOLVED`.
## F7 — Physical table and source contract
@@ -207,8 +220,10 @@ Enter this stage only when F0-F6 have no
status-contributing finding. Collect, w
- Partition fields, or an explicit unpartitioned decision.
- Stable record-key fields when F5 found a stable business key.
- The existing source-table identifier and its expected physical columns.
+- For mutable input: source primary-key fields, exactly one ordering field,
whether deletes occur,
+ whether deletes contain the complete projected row, and whether partition
values can change.
-PR2 validates a bounded scalar Flink SQL type surface recorded in the
capability manifest.
+The executable paths validate a bounded scalar Flink SQL type surface recorded
in the capability manifest.
Nested, computed, metadata, and watermark columns remain `REVIEW_REQUIRED` with
`FLINK_SCHEMA_TYPE_UNVERIFIED` until covered by a pinned fixture. A field
referenced by the
record key or partition list must exist. Stable-key columns must be `NOT
NULL`. Every physical
@@ -251,9 +266,11 @@ explicit projection; never use `SELECT *`, infer casts, or
invent a source DDL.
> - **Updates or deletes can occur**
> - **Not sure**
-- Inserts only → record `INSERT_ONLY` and continue.
-- Updates or deletes → add `FLINK_SOURCE_CHANGELOG_NOT_APPEND_ONLY` with a
`BLOCKED`
- contribution. Mutable change semantics arrive in PR3.
+- Inserts only → record `INSERT_ONLY` and continue on the append path.
+- Updates or deletes → for a mutable candidate, require normalized UPSERT
changelog containing
+ `INSERT`, `UPDATE_AFTER`, and optional `DELETE`, with no `UPDATE_BEFORE`.
Otherwise add
+ `FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED`. For an append contract, retain
+ `FLINK_SOURCE_CHANGELOG_NOT_APPEND_ONLY`.
- Not sure → add `FLINK_SOURCE_CONTRACT_REQUIRED` with an `INCOMPLETE`
contribution.
This check is independent of sink-factory construction. Hudi 1.2.0 can
construct a sink without
@@ -283,13 +300,14 @@ deployment responsibilities. If known rate and
active-partition evidence indicat
data per checkpoint, surface `FLINK_CHECKPOINT_SMALL_FILE_RISK`; do not enable
clustering or tune
file sizing in PR2.
-## PR2 validation and completion
+## PR2/PR3 validation and completion
-After all applicable questions, build a version-1 JSON design contract
containing the confirmed
-safety facts, physical schemas, identity mode, source contract, fixed write
settings, and runtime
-contract. Do not insert placeholders for the target table, path, source table,
or checkpoint
-interval. Record an explicitly empty pass-through connector-option map; PR2
never accepts an
-option that the canonical renderer would ignore.
+After all applicable questions, build a version-1 JSON contract for
append-only or a version-2
+JSON contract for mutable COW. Both contain confirmed safety facts, physical
schemas, identity,
+source, write, and runtime contracts. Version 2 additionally records
event-time ordering,
+normalized UPSERT, full-row delete posture, immutable partition fields, and
the fixed global
+FLINK_STATE/bootstrap/no-TTL contract. Do not insert placeholders. Record an
explicitly empty
+pass-through connector-option map; neither renderer accepts an ignored option.
Run:
diff --git
a/hudi-agent-gateway/skills/hudi-architect/references/flink-warnings.md
b/hudi-agent-gateway/skills/hudi-architect/references/flink-warnings.md
index 0a5d8d48e1a0..ef66e6f7bc89 100644
--- a/hudi-agent-gateway/skills/hudi-architect/references/flink-warnings.md
+++ b/hudi-agent-gateway/skills/hudi-architect/references/flink-warnings.md
@@ -120,12 +120,13 @@ ordering, and partition references can eventually be
checked.
## FLINK_MUTABLE_COW_DEFERRED
-**Trigger:** Existing logical records can be updated or deleted.
+**Trigger:** Existing logical records can be updated or deleted, but the
request is still using the
+version-1 append contract.
-**Message:** Mutable COW requires identity, ordering, and an upsert-capable
path that is deferred
-to PR3.
+**Message:** Mutable COW requires the explicit version-2 identity, ordering,
changelog, delete,
+partition, and index safety contract.
-**Action:** `BLOCKED`; collect independent facts but generate no executable
configuration.
+**Action:** `BLOCKED`; qualify the request for schema 2 or generate no
executable configuration.
## FLINK_MUTABILITY_REQUIRED
@@ -188,20 +189,21 @@ status.
## FLINK_REPLAY_IDEMPOTENCE_DEFERRED
-**Trigger:** Replayed or backfilled copies can occur and must collapse into
one table record.
+**Trigger:** On the append contract, replayed or backfilled copies can occur
and must collapse into
+one table record.
**Message:** This requires stable record identity and an upsert-capable
design. The append-only
insert path cannot be presented as deduplicating.
-**Action:** `BLOCKED` until the mutable COW path is implemented.
+**Action:** `BLOCKED`; use the mutable schema-2 contract only if all of its
gates are satisfied.
## FLINK_REPLAY_BEHAVIOR_UNRESOLVED
**Trigger:** The user cannot determine whether retries, replays, or backfills
can repeat logical
records or whether repeated copies must collapse.
-**Message:** Replay safety is unresolved, so the append-only insert path
cannot be presented as
-idempotent.
+**Message:** Replay safety is unresolved, so an executable append-only or
mutable path cannot be
+validated.
**Action:** `REVIEW_REQUIRED`; retain the uncertainty and withhold executable
output.
@@ -306,7 +308,7 @@ rename it.
**Trigger:** An explicitly projected source field is missing or differs in
type or nullability from
the target field.
-**Message:** PR2 does not infer casts, aliases, or schema reconciliation.
+**Message:** The bounded executable paths do not infer casts, aliases, or
schema reconciliation.
**Action:** `BLOCKED`; require an explicit compatible source contract.
@@ -315,7 +317,7 @@ the target field.
**Trigger:** The declared source changelog contains updates or deletes.
**Message:** Sink-factory construction does not prove the complete
source-to-sink statement is
-append-only. Mutable change semantics are deferred to PR3.
+append-only. An eligible mutable request must use the separate schema-2
contract.
**Action:** `BLOCKED`; do not generate executable append SQL.
@@ -336,6 +338,83 @@ Checking only `write.operation=insert` is insufficient.
**Action:** `BLOCKED`; route mutable COW to PR3 and MOR to PR4.
+## FLINK_MUTABLE_AUTO_KEY_UNSUPPORTED
+
+**Trigger:** A mutable contract has no stable business key.
+
+**Message:** Upsert and delete semantics require durable identity across
events and restarts.
+
+**Action:** `BLOCKED`; require a stable, non-null source and target primary
key.
+
+## FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED
+
+**Trigger:** The mutable source is not normalized UPSERT (`I`, `UA`, `D`,
without `UB`).
+
+**Message:** The first mutable sink path does not normalize retract streams or
infer changelog.
+
+**Action:** `BLOCKED`; normalize upstream or defer the path.
+
+## FLINK_MUTABLE_ORDERING_FIELD_REQUIRED
+
+**Trigger:** Event-time ordering or its single ordering field is missing.
+
+**Message:** The validator cannot select conflict-resolution semantics or
invent a field.
+
+**Action:** `INCOMPLETE`; obtain one explicit ordering field.
+
+## FLINK_MUTABLE_ORDERING_FIELD_INVALID
+
+**Trigger:** The ordering field is absent, nullable, not
BIGINT/TIMESTAMP(p<=6), or is the exact
+Hudi `no_precombine` sentinel.
+
+**Message:** The bounded event-time merger surface does not cover nullable or
other orderings, and
+`no_precombine` disables ordering instead of selecting a same-named physical
field.
+
+**Action:** `BLOCKED`; correct the source contract without casting silently.
+
+## FLINK_MUTABLE_DELETE_PAYLOAD_UNSUPPORTED
+
+**Trigger:** Deletes omit projected key, ordering, partition, or row values.
+
+**Message:** The first path accepts only full-row deletes; key-only delete
normalization is not
+implemented.
+
+**Action:** `BLOCKED`; normalize upstream or defer.
+
+## FLINK_MUTABLE_PARTITION_EVOLUTION_UNSUPPORTED
+
+**Trigger:** An existing key can change its partition value.
+
+**Message:** Cross-partition movement is deliberately outside this first
mutable path.
+
+**Action:** `BLOCKED`; use immutable partition fields or an unpartitioned
design.
+
+## FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED
+
+**Trigger:** Index type, scope, or TTL differs from global FLINK_STATE with
TTL 0.
+
+**Message:** Alternative indexes and expiring state need separate correctness
evidence.
+
+**Action:** `BLOCKED`; do not weaken the canonical index contract.
+
+## FLINK_MUTABLE_INDEX_BOOTSTRAP_REQUIRED
+
+**Trigger:** Index bootstrap is not explicitly enabled.
+
+**Message:** A cold start with empty state could otherwise miss keys already
present in the table.
+
+**Action:** `BLOCKED`; enable bootstrap. This does not authorize arbitrary
existing-table takeover.
+
+## FLINK_PR3_WRITE_PATH_UNSUPPORTED
+
+**Trigger:** Mutable table type, operation, merge mode, changelog setting, or
runtime leaves the
+bounded COW upsert path.
+
+**Message:** PR3 implements one conservative latest-state mutable route, not
the complete Hudi
+mutable configuration surface.
+
+**Action:** `BLOCKED`; retain the request as a later capability rather than
guessing settings.
+
## FLINK_CHECKPOINTING_REQUIRED
**Trigger:** Checkpointing is explicitly disabled for the streaming sink.
diff --git
a/hudi-agent-gateway/skills/hudi-architect/validate_flink_capabilities.py
b/hudi-agent-gateway/skills/hudi-architect/validate_flink_capabilities.py
index 3c8498190769..db9cf70cbc32 100644
--- a/hudi-agent-gateway/skills/hudi-architect/validate_flink_capabilities.py
+++ b/hudi-agent-gateway/skills/hudi-architect/validate_flink_capabilities.py
@@ -40,7 +40,7 @@ DEFAULT_MANIFEST = (
SKILL_DIR / "references" / "flink-1.20-hudi-1.2.0-capabilities.toml"
)
-EXPECTED_SCHEMA_VERSION = 2
+EXPECTED_SCHEMA_VERSION = 3
EXPECTED_BASELINE_ID = "hudi-1.2.0-flink-1.20"
EXPECTED_HUDI_VERSION = "1.2.0"
EXPECTED_HUDI_SOURCE_REVISION = "f05c83f2b97732de7a558ff9b26959e1139c05f5"
@@ -62,6 +62,46 @@ EXPECTED_REQUIRED_SETTINGS = [
"value": "insert",
"applies_to": "pr2-append-only-cow",
},
+ {
+ "key": "changelog.enabled",
+ "value": False,
+ "applies_to": "pr3-mutable-cow",
+ },
+ {
+ "key": "hoodie.write.record.merge.mode",
+ "value": "EVENT_TIME_ORDERING",
+ "applies_to": "pr3-mutable-cow",
+ },
+ {
+ "key": "index.bootstrap.enabled",
+ "value": True,
+ "applies_to": "pr3-mutable-cow",
+ },
+ {
+ "key": "index.global.enabled",
+ "value": True,
+ "applies_to": "pr3-mutable-cow",
+ },
+ {
+ "key": "index.state.ttl",
+ "value": 0.0,
+ "applies_to": "pr3-mutable-cow",
+ },
+ {
+ "key": "index.type",
+ "value": "FLINK_STATE",
+ "applies_to": "pr3-mutable-cow",
+ },
+ {
+ "key": "table.type",
+ "value": "COPY_ON_WRITE",
+ "applies_to": "pr3-mutable-cow",
+ },
+ {
+ "key": "write.operation",
+ "value": "upsert",
+ "applies_to": "pr3-mutable-cow",
+ },
]
EXPECTED_PHYSICAL_TYPES = {
"simple": [
@@ -140,6 +180,22 @@ EXPECTED_ACCEPTANCE_EVIDENCE = {
"test_pr2_enforces_checkpoint_interval_safety_floor",
"testPinnedRuntimeCheckpointIntervalBoundary",
),
+ "FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED": (
+ "test_pr3_rejects_unsupported_source_changelog",
+ "testMutableCowSinkAndUpsertPlan",
+ ),
+ "FLINK_MUTABLE_ORDERING_FIELD_INVALID": (
+ "test_pr3_rejects_invalid_ordering_fields",
+ "testPinnedMutableOrderingConfiguration",
+ ),
+ "FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED": (
+ "test_pr3_rejects_unsafe_index_configuration",
+ "testPinnedMutableIndexConfiguration",
+ ),
+ "FLINK_MUTABLE_INDEX_BOOTSTRAP_REQUIRED": (
+ "test_pr3_requires_index_bootstrap",
+ "testPinnedMutableIndexBootstrapConfiguration",
+ ),
}
SHA1_PATTERN = re.compile(r"[0-9a-f]{40}")
SHA256_PATTERN = re.compile(r"[0-9a-f]{64}")
@@ -272,13 +328,15 @@ def validate_manifest(manifest: dict[str, Any]) ->
list[str]:
)
if "value" not in setting:
errors.append(f"required_effective_settings[{index}].value is
required")
- if setting.get("applies_to") != "pr2-append-only-cow":
+ if setting.get("applies_to") not in {
+ "pr2-append-only-cow",
+ "pr3-mutable-cow",
+ }:
errors.append(
- f"required_effective_settings[{index}].applies_to must be "
- "'pr2-append-only-cow'"
+ f"required_effective_settings[{index}].applies_to is
unsupported"
)
if settings != EXPECTED_REQUIRED_SETTINGS:
- errors.append("required_effective_settings does not match the bounded
PR2 path")
+ errors.append("required_effective_settings does not match the bounded
paths")
fixture_artifacts = manifest.get("fixture_artifacts")
if not isinstance(fixture_artifacts, dict):
@@ -311,11 +369,40 @@ def validate_manifest(manifest: dict[str, Any]) ->
list[str]:
if executable_contract != expected_contract:
errors.append("executable_contract does not match the bounded PR2
path")
+ mutable_executable_contract = manifest.get("mutable_executable_contract")
+ expected_mutable_contract = {
+ "contract_schema": 2,
+ "table_lifecycle": "new",
+ "writer_model": "single_writer",
+ "external_catalog": False,
+ "mutability": "mutable",
+ "source_changelog_mode": "UPSERT",
+ "execution_mode": "STREAMING",
+ "require_checkpointing": True,
+ "record_key_mode": "stable_key",
+ "allow_replay_behaviors": [
+ "cannot_occur",
+ "duplicates_acceptable",
+ "must_collapse",
+ ],
+ "ordering_mode": "event_time",
+ "ordering_field_types": ["BIGINT", "TIMESTAMP"],
+ "reserved_ordering_field_values": ["no_precombine"],
+ "allow_emits_deletes": [False, True],
+ "delete_payload_when_deletes": "FULL_ROW",
+ "delete_payload_without_deletes": "NOT_APPLICABLE",
+ "partition_fields_mutable": False,
+ }
+ if mutable_executable_contract != expected_mutable_contract:
+ errors.append(
+ "mutable_executable_contract does not match the bounded PR3 path"
+ )
+
physical_types = manifest.get("physical_types")
if not isinstance(physical_types, dict):
errors.append("physical_types must be a table")
elif physical_types != EXPECTED_PHYSICAL_TYPES:
- errors.append("physical_types does not match the bounded PR2 scalar
surface")
+ errors.append("physical_types does not match the bounded scalar
surface")
else:
for type_group in ("simple", "parameterized"):
values = physical_types.get(type_group)
@@ -372,7 +459,7 @@ def validate_manifest(manifest: dict[str, Any]) ->
list[str]:
}
if implemented_evidence != EXPECTED_ACCEPTANCE_EVIDENCE:
errors.append(
- "implemented_acceptance_checks does not match the required PR2
evidence"
+ "implemented_acceptance_checks does not match the required
evidence"
)
return errors
@@ -384,13 +471,23 @@ def supported_option_keys(manifest: dict[str, Any]) ->
set[str]:
return {option["key"] for option in manifest["verified_options"]}
-def validation_evidence(manifest: dict[str, Any]) -> dict[str, Any]:
+def validation_evidence(
+ manifest: dict[str, Any], contract_schema: int | None = None
+) -> dict[str, Any]:
"""Build the stable baseline evidence included in every Flink
assessment."""
return {
"baseline_id": manifest["baseline_id"],
"capability_manifest_schema": manifest["schema_version"],
- "design_contract_schema":
manifest["executable_contract"]["contract_schema"],
+ "design_contract_schema": (
+ manifest["executable_contract"]["contract_schema"]
+ if contract_schema is None
+ else contract_schema
+ ),
+ "supported_design_contract_schemas": [
+ manifest["executable_contract"]["contract_schema"],
+ manifest["mutable_executable_contract"]["contract_schema"],
+ ],
"flink_fixture_version": manifest["flink"]["fixture_version"],
"hudi_source_revision": manifest["hudi"]["source_revision"],
}
diff --git a/hudi-agent-gateway/skills/hudi-architect/validate_flink_design.py
b/hudi-agent-gateway/skills/hudi-architect/validate_flink_design.py
index fe1e9a09c4e2..5a564fc08c2b 100644
--- a/hudi-agent-gateway/skills/hudi-architect/validate_flink_design.py
+++ b/hudi-agent-gateway/skills/hudi-architect/validate_flink_design.py
@@ -15,12 +15,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Validate and render the bounded Hudi Architect Flink PR2 design contract.
+"""Validate and render bounded Hudi Architect Flink SQL design contracts.
The caller supplies every architecture decision. This program does not select
-table type, identity, partitioning, replay behavior, or checkpoint cadence. It
-only rejects incomplete or unsupported combinations and serializes a validated
-contract into deterministic Flink SQL.
+table type, identity, ordering, partitioning, replay behavior, or checkpoint
+cadence. It only rejects incomplete or unsupported combinations and serializes
+a validated contract into deterministic Flink SQL.
"""
from __future__ import annotations
@@ -41,7 +41,12 @@ from validate_flink_capabilities import (
validation_evidence,
)
-CONTRACT_SCHEMA_VERSION = 1
+APPEND_CONTRACT_SCHEMA_VERSION = 1
+MUTABLE_CONTRACT_SCHEMA_VERSION = 2
+SUPPORTED_CONTRACT_SCHEMAS = {
+ APPEND_CONTRACT_SCHEMA_VERSION,
+ MUTABLE_CONTRACT_SCHEMA_VERSION,
+}
MAX_TYPE_LENGTH = 2_147_483_647
STATUS_PRIORITY = {"INCOMPLETE": 0, "REVIEW_REQUIRED": 1, "BLOCKED": 2}
AZURE_FILESYSTEM_SCHEMES = frozenset({"abfs", "abfss"})
@@ -78,7 +83,15 @@ FINDING_STATUS = {
"FLINK_LOAD_BEARING_VALUE_REQUIRED": "INCOMPLETE",
"FLINK_MULTI_WRITER_REVIEW": "REVIEW_REQUIRED",
"FLINK_MUTABILITY_REQUIRED": "INCOMPLETE",
+ "FLINK_MUTABLE_AUTO_KEY_UNSUPPORTED": "BLOCKED",
"FLINK_MUTABLE_COW_DEFERRED": "BLOCKED",
+ "FLINK_MUTABLE_DELETE_PAYLOAD_UNSUPPORTED": "BLOCKED",
+ "FLINK_MUTABLE_INDEX_BOOTSTRAP_REQUIRED": "BLOCKED",
+ "FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED": "BLOCKED",
+ "FLINK_MUTABLE_ORDERING_FIELD_INVALID": "BLOCKED",
+ "FLINK_MUTABLE_ORDERING_FIELD_REQUIRED": "INCOMPLETE",
+ "FLINK_MUTABLE_PARTITION_EVOLUTION_UNSUPPORTED": "BLOCKED",
+ "FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED": "BLOCKED",
"FLINK_OPTION_NOT_VERIFIED": "REVIEW_REQUIRED",
"FLINK_PARTITION_FIELD_MISSING": "BLOCKED",
"FLINK_PHYSICAL_SCHEMA_REQUIRED": "INCOMPLETE",
@@ -97,6 +110,7 @@ FINDING_STATUS = {
"FLINK_VERSION_UNVERIFIED": "REVIEW_REQUIRED",
"FLINK_WRITER_MODEL_UNRESOLVED": "REVIEW_REQUIRED",
"FLINK_PR2_WRITE_PATH_UNSUPPORTED": "BLOCKED",
+ "FLINK_PR3_WRITE_PATH_UNSUPPORTED": "BLOCKED",
"FLINK_SCHEMA_TYPE_UNVERIFIED": "REVIEW_REQUIRED",
}
@@ -181,7 +195,7 @@ def _normalize_type(
]:
assessment.add(
"FLINK_SCHEMA_TYPE_UNVERIFIED",
- f"Flink SQL type {value!r} is outside the bounded PR2 scalar
surface",
+ f"Flink SQL type {value!r} is outside the bounded scalar surface",
)
return None
@@ -355,6 +369,7 @@ def _render_sql(
source = contract["source"]
runtime = contract["runtime"]
identity = contract["identity"]
+ mutable = contract["contract_schema"] == MUTABLE_CONTRACT_SCHEMA_VERSION
definitions = [
f" {_quote_identifier(column['name'])} {column['type']}"
@@ -373,15 +388,33 @@ def _render_sql(
if partition_fields:
partition_sql = ", ".join(_quote_identifier(field) for field in
partition_fields)
table_ddl += f"\nPARTITIONED BY ({partition_sql})"
- table_ddl += (
- "\nWITH (\n"
- " 'connector' = 'hudi',\n"
- f" 'path' = {_sql_string(table['path'])},\n"
- " 'table.type' = 'COPY_ON_WRITE',\n"
- " 'write.operation' = 'insert',\n"
- " 'write.insert.cluster' = 'false'\n"
- ");"
- )
+ if mutable:
+ ordering_field = contract["ordering"]["fields"][0]
+ table_ddl += (
+ "\nWITH (\n"
+ " 'connector' = 'hudi',\n"
+ f" 'path' = {_sql_string(table['path'])},\n"
+ " 'table.type' = 'COPY_ON_WRITE',\n"
+ " 'write.operation' = 'upsert',\n"
+ f" 'ordering.fields' = {_sql_string(ordering_field)},\n"
+ " 'hoodie.write.record.merge.mode' = 'EVENT_TIME_ORDERING',\n"
+ " 'index.type' = 'FLINK_STATE',\n"
+ " 'index.global.enabled' = 'true',\n"
+ " 'index.state.ttl' = '0',\n"
+ " 'index.bootstrap.enabled' = 'true',\n"
+ " 'changelog.enabled' = 'false'\n"
+ ");"
+ )
+ else:
+ table_ddl += (
+ "\nWITH (\n"
+ " 'connector' = 'hudi',\n"
+ f" 'path' = {_sql_string(table['path'])},\n"
+ " 'table.type' = 'COPY_ON_WRITE',\n"
+ " 'write.operation' = 'insert',\n"
+ " 'write.insert.cluster' = 'false'\n"
+ ");"
+ )
field_sql = ",\n ".join(_quote_identifier(column["name"]) for column in
table_columns)
insert_sql = (
@@ -405,7 +438,7 @@ def _render_sql(
def assess_design(
contract: dict[str, Any], manifest: dict[str, Any]
) -> dict[str, Any]:
- """Validate one explicit PR2 contract and return deterministic evidence."""
+ """Validate one explicit append-only or mutable COW contract."""
assessment = Assessment()
manifest_errors = validate_manifest(manifest)
@@ -420,7 +453,7 @@ def assess_design(
"findings": assessment.findings,
"advisory_codes": assessment.advisories,
"validation_evidence": {
- "design_contract_schema": CONTRACT_SCHEMA_VERSION,
+ "design_contract_schema": contract.get("contract_schema"),
},
}
@@ -432,6 +465,7 @@ def assess_design(
"flink_version",
"hudi_version",
"identity",
+ "ordering",
"runtime",
"safety",
"source",
@@ -441,10 +475,24 @@ def assess_design(
"contract",
assessment,
)
- if contract.get("contract_schema") != CONTRACT_SCHEMA_VERSION:
+ contract_schema = contract.get("contract_schema")
+ if (
+ not isinstance(contract_schema, int)
+ or isinstance(contract_schema, bool)
+ or contract_schema not in SUPPORTED_CONTRACT_SCHEMAS
+ ):
assessment.add(
"FLINK_DESIGN_CONTRACT_INVALID",
- f"contract_schema must be {CONTRACT_SCHEMA_VERSION}",
+ "contract_schema must be 1 (append-only) or 2 (mutable COW)",
+ )
+ mutable_contract = contract_schema == MUTABLE_CONTRACT_SCHEMA_VERSION
+ contract_profile = manifest[
+ "mutable_executable_contract" if mutable_contract else
"executable_contract"
+ ]
+ if not mutable_contract and "ordering" in contract:
+ assessment.add(
+ "FLINK_DESIGN_CONTRACT_INVALID",
+ "ordering is supported only by contract_schema 2",
)
if contract.get("baseline_id") != manifest.get("baseline_id"):
assessment.add("FLINK_VERSION_UNVERIFIED", "The capability baseline
does not match")
@@ -475,14 +523,20 @@ def assess_design(
if lifecycle is None:
assessment.add("FLINK_TABLE_LIFECYCLE_REQUIRED", "Table lifecycle is
required")
elif lifecycle == "existing":
- assessment.add("FLINK_EXISTING_TABLE_DEFERRED", "PR2 supports only a
new table")
+ assessment.add(
+ "FLINK_EXISTING_TABLE_DEFERRED",
+ "The executable contracts support only a new Architect-managed
table",
+ )
elif lifecycle != "new":
assessment.add("FLINK_TABLE_LIFECYCLE_REQUIRED", "Table lifecycle is
unresolved")
writer_model = safety.get("writer_model")
if writer_model is None:
assessment.add("FLINK_WRITER_MODEL_UNRESOLVED", "Writer topology is
required")
elif writer_model == "multi_writer":
- assessment.add("FLINK_MULTI_WRITER_REVIEW", "PR2 supports only a
confirmed single writer")
+ assessment.add(
+ "FLINK_MULTI_WRITER_REVIEW",
+ "The executable contracts support only a confirmed single writer",
+ )
elif writer_model != "single_writer":
assessment.add("FLINK_WRITER_MODEL_UNRESOLVED", "Writer topology is
unresolved")
external_catalog = safety.get("external_catalog")
@@ -491,20 +545,32 @@ def assess_design(
elif external_catalog is not False:
assessment.add("FLINK_CATALOG_REQUIREMENT_UNRESOLVED", "Catalog
requirement is unresolved")
mutability = safety.get("mutability")
- if mutability == "mutable":
- assessment.add("FLINK_MUTABLE_COW_DEFERRED", "Mutable COW is deferred")
+ if mutable_contract:
+ if mutability != "mutable":
+ assessment.add(
+ "FLINK_MUTABILITY_REQUIRED",
+ "contract_schema 2 requires mutable input",
+ )
+ elif mutability == "mutable":
+ assessment.add(
+ "FLINK_MUTABLE_COW_DEFERRED",
+ "Mutable COW requires the explicit contract_schema 2 safety
contract",
+ )
elif mutability != "append_only":
assessment.add("FLINK_MUTABILITY_REQUIRED", "Append-only input must be
confirmed")
replay_behavior = safety.get("replay_behavior")
if not _non_empty_string(replay_behavior):
assessment.add("FLINK_REPLAY_BEHAVIOR_UNRESOLVED", "Replay behavior is
unresolved")
- elif replay_behavior == "must_collapse":
+ elif replay_behavior == "must_collapse" and not mutable_contract:
assessment.add("FLINK_REPLAY_IDEMPOTENCE_DEFERRED", "Replay
deduplication needs upsert")
- elif replay_behavior not in {"cannot_occur", "duplicates_acceptable"}:
+ elif replay_behavior not in contract_profile["allow_replay_behaviors"]:
assessment.add("FLINK_REPLAY_BEHAVIOR_UNRESOLVED", "Replay behavior is
unresolved")
table = _dict(contract.get("table"), "table", assessment)
- _check_keys(table, {"columns", "name", "partition_fields", "path"},
"table", assessment)
+ table_keys = {"columns", "name", "partition_fields", "path"}
+ if mutable_contract:
+ table_keys.add("partition_fields_mutable")
+ _check_keys(table, table_keys, "table", assessment)
table_name = table.get("name")
table_path = table.get("path")
if not _check_concrete_value(table_name) or any(
@@ -529,6 +595,17 @@ def assess_design(
partition_fields = _string_list(
table.get("partition_fields"), "table.partition_fields", assessment
)
+ if mutable_contract:
+ if table.get("partition_fields_mutable") is True:
+ assessment.add(
+ "FLINK_MUTABLE_PARTITION_EVOLUTION_UNSUPPORTED",
+ "The first mutable path does not move a key between
partitions",
+ )
+ elif table.get("partition_fields_mutable") is not False:
+ assessment.add(
+ "FLINK_DESIGN_CONTRACT_INVALID",
+ "table.partition_fields_mutable must be explicitly false",
+ )
for field in partition_fields:
if field not in table_by_name:
assessment.add(
@@ -600,7 +677,8 @@ def assess_design(
f"Record-key option field {field!r} is absent from the
physical schema",
)
if identity_mode == "stable_key":
- assessment.advise("FLINK_STABLE_KEY_NOT_IDEMPOTENT")
+ if not mutable_contract:
+ assessment.advise("FLINK_STABLE_KEY_NOT_IDEMPOTENT")
if "auto_key_accepted" in identity:
assessment.add(
"FLINK_DESIGN_CONTRACT_INVALID",
@@ -639,7 +717,12 @@ def assess_design(
"The auto-key path cannot contain primary-key or record-key
fields",
)
accepted = identity.get("auto_key_accepted")
- if accepted is True:
+ if mutable_contract:
+ assessment.add(
+ "FLINK_MUTABLE_AUTO_KEY_UNSUPPORTED",
+ "Mutable upsert/delete requires a stable non-null record key",
+ )
+ elif accepted is True:
assessment.advise("FLINK_AUTO_KEY_DURABILITY")
elif accepted is False:
assessment.add("FLINK_AUTO_KEY_DECLINED", "Auto-generated keys
were declined")
@@ -651,8 +734,57 @@ def assess_design(
else:
assessment.add("FLINK_DESIGN_CONTRACT_INVALID", "identity.mode is
required")
+ ordering_fields: list[str] = []
+ if mutable_contract:
+ ordering = _dict(contract.get("ordering"), "ordering", assessment)
+ _check_keys(ordering, {"fields", "mode"}, "ordering", assessment)
+ ordering_fields = _string_list(
+ ordering.get("fields"), "ordering.fields", assessment
+ )
+ if ordering.get("mode") != "event_time":
+ assessment.add(
+ "FLINK_MUTABLE_ORDERING_FIELD_REQUIRED",
+ "The first mutable path requires event_time ordering",
+ )
+ if len(ordering_fields) != 1:
+ assessment.add(
+ "FLINK_MUTABLE_ORDERING_FIELD_REQUIRED",
+ "Exactly one ordering field is required",
+ )
+ reserved_ordering_values = set(
+ contract_profile["reserved_ordering_field_values"]
+ )
+ for field in ordering_fields:
+ if field in reserved_ordering_values:
+ assessment.add(
+ "FLINK_MUTABLE_ORDERING_FIELD_INVALID",
+ f"Ordering field {field!r} is reserved by Hudi and
disables ordering",
+ )
+ continue
+ column = table_by_name.get(field)
+ if column is None:
+ assessment.add(
+ "FLINK_MUTABLE_ORDERING_FIELD_INVALID",
+ f"Ordering field {field!r} is absent from the physical
schema",
+ )
+ elif column["nullable"]:
+ assessment.add(
+ "FLINK_MUTABLE_ORDERING_FIELD_INVALID",
+ f"Ordering field {field!r} must be NOT NULL",
+ )
+ elif column["type"] != "BIGINT" and re.fullmatch(
+ r"TIMESTAMP\([0-6]\)", column["type"]
+ ) is None:
+ assessment.add(
+ "FLINK_MUTABLE_ORDERING_FIELD_INVALID",
+ f"Ordering field {field!r} must use BIGINT or
TIMESTAMP(p<=6)",
+ )
+
source = _dict(contract.get("source"), "source", assessment)
- _check_keys(source, {"changelog_mode", "columns", "table"}, "source",
assessment)
+ source_keys = {"changelog_mode", "columns", "table"}
+ if mutable_contract:
+ source_keys.update({"delete_payload", "emits_deletes",
"primary_key_fields"})
+ _check_keys(source, source_keys, "source", assessment)
source_name = source.get("table")
if not _check_concrete_value(source_name) or any(
not part for part in str(source_name).split(".")
@@ -673,36 +805,74 @@ def assess_design(
"FLINK_SOURCE_SCHEMA_MISMATCH",
f"Source field {name!r} must match the target name, type,
and nullability",
)
+ if mutable_contract:
+ source_primary_key_fields = _string_list(
+ source.get("primary_key_fields"), "source.primary_key_fields",
assessment
+ )
+ if not source_primary_key_fields:
+ assessment.add(
+ "FLINK_SOURCE_CONTRACT_REQUIRED",
+ "The mutable source must declare its primary-key fields",
+ )
+ elif record_key_fields and source_primary_key_fields !=
record_key_fields:
+ assessment.add(
+ "FLINK_PRIMARY_KEY_RECORD_KEY_CONFLICT",
+ "Source primary-key fields must exactly match target
record-key fields",
+ )
+ for field in source_primary_key_fields:
+ source_column = source_by_name.get(field)
+ if source_column is None or source_column["nullable"]:
+ assessment.add(
+ "FLINK_PRIMARY_KEY_RECORD_KEY_CONFLICT",
+ f"Source primary-key field {field!r} must exist and be NOT
NULL",
+ )
+ emits_deletes = source.get("emits_deletes")
+ if not isinstance(emits_deletes, bool):
+ assessment.add(
+ "FLINK_DESIGN_CONTRACT_INVALID",
+ "source.emits_deletes must be explicitly true or false",
+ )
+ delete_payload = source.get("delete_payload")
+ if emits_deletes is True and delete_payload != "FULL_ROW":
+ assessment.add(
+ "FLINK_MUTABLE_DELETE_PAYLOAD_UNSUPPORTED",
+ "Deletes must carry key, ordering, partition, and projected
row values",
+ )
+ elif emits_deletes is False and delete_payload != "NOT_APPLICABLE":
+ assessment.add(
+ "FLINK_DESIGN_CONTRACT_INVALID",
+ "source.delete_payload must be NOT_APPLICABLE when deletes are
absent",
+ )
+
changelog_mode = source.get("changelog_mode")
if not _non_empty_string(changelog_mode) or changelog_mode == "UNKNOWN":
assessment.add(
"FLINK_SOURCE_CONTRACT_REQUIRED",
"The source changelog mode must be explicitly confirmed",
)
- elif changelog_mode != "INSERT_ONLY":
+ elif mutable_contract and changelog_mode != "UPSERT":
+ assessment.add(
+ "FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED",
+ "The mutable source must declare normalized UPSERT changelog
(I/UA/D, no UB)",
+ )
+ elif not mutable_contract and changelog_mode != "INSERT_ONLY":
assessment.add(
"FLINK_SOURCE_CHANGELOG_NOT_APPEND_ONLY",
"The PR2 source contract must be INSERT_ONLY",
)
write = _dict(contract.get("write"), "write", assessment)
+ write_keys = {"connector_options", "operation", "table_type"}
+ if mutable_contract:
+ write_keys.update({"changelog_enabled", "index", "record_merge_mode"})
+ else:
+ write_keys.add("insert_cluster")
_check_keys(
write,
- {"connector_options", "insert_cluster", "operation", "table_type"},
+ write_keys,
"write",
assessment,
)
- insert_cluster = write.get("insert_cluster")
- if insert_cluster is True:
- assessment.add(
- "FLINK_APPEND_MODE_CLUSTERING_ENABLED",
- "COW insert is append mode only with write.insert.cluster=false",
- )
- elif insert_cluster is not False:
- assessment.add(
- "FLINK_DESIGN_CONTRACT_INVALID",
- "write.insert_cluster must be explicitly true or false",
- )
table_type = write.get("table_type")
operation = write.get("operation")
if table_type is None or operation is None:
@@ -710,11 +880,70 @@ def assess_design(
"FLINK_DESIGN_CONTRACT_INVALID",
"write.table_type and write.operation must be explicit",
)
- elif table_type != "COPY_ON_WRITE" or operation != "insert":
+ elif mutable_contract and (
+ table_type != "COPY_ON_WRITE" or operation != "upsert"
+ ):
+ assessment.add(
+ "FLINK_PR3_WRITE_PATH_UNSUPPORTED",
+ "The first mutable path supports only COPY_ON_WRITE upsert",
+ )
+ elif not mutable_contract and (
+ table_type != "COPY_ON_WRITE" or operation != "insert"
+ ):
assessment.add(
"FLINK_PR2_WRITE_PATH_UNSUPPORTED",
"PR2 supports only COPY_ON_WRITE with write.operation=insert",
)
+ if mutable_contract:
+ if write.get("record_merge_mode") != "EVENT_TIME_ORDERING":
+ assessment.add(
+ "FLINK_PR3_WRITE_PATH_UNSUPPORTED",
+ "write.record_merge_mode must be EVENT_TIME_ORDERING",
+ )
+ if write.get("changelog_enabled") is not False:
+ assessment.add(
+ "FLINK_PR3_WRITE_PATH_UNSUPPORTED",
+ "write.changelog_enabled must be explicitly false",
+ )
+ index = _dict(write.get("index"), "write.index", assessment)
+ _check_keys(
+ index,
+ {"bootstrap_enabled", "global", "state_ttl_days", "type"},
+ "write.index",
+ assessment,
+ )
+ if index.get("type") != "FLINK_STATE" or index.get("global") is not
True:
+ assessment.add(
+ "FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED",
+ "The first mutable path requires global FLINK_STATE index",
+ )
+ state_ttl = index.get("state_ttl_days")
+ if (
+ not isinstance(state_ttl, (int, float))
+ or isinstance(state_ttl, bool)
+ or state_ttl != 0
+ ):
+ assessment.add(
+ "FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED",
+ "write.index.state_ttl_days must be 0 (no state expiry)",
+ )
+ if index.get("bootstrap_enabled") is not True:
+ assessment.add(
+ "FLINK_MUTABLE_INDEX_BOOTSTRAP_REQUIRED",
+ "FLINK_STATE must bootstrap existing keys after state loss or
cold restart",
+ )
+ else:
+ insert_cluster = write.get("insert_cluster")
+ if insert_cluster is True:
+ assessment.add(
+ "FLINK_APPEND_MODE_CLUSTERING_ENABLED",
+ "COW insert is append mode only with
write.insert.cluster=false",
+ )
+ elif insert_cluster is not False:
+ assessment.add(
+ "FLINK_DESIGN_CONTRACT_INVALID",
+ "write.insert_cluster must be explicitly true or false",
+ )
connector_options = write.get("connector_options")
if not isinstance(connector_options, dict):
assessment.add("FLINK_DESIGN_CONTRACT_INVALID",
"write.connector_options must be an object")
@@ -726,7 +955,7 @@ def assess_design(
f"Options outside the pinned allowlist: {unknown_options}",
)
raw_insert_cluster = connector_options.get("write.insert.cluster")
- if "write.insert.cluster" in connector_options and not (
+ if not mutable_contract and "write.insert.cluster" in
connector_options and not (
raw_insert_cluster is False
or (
isinstance(raw_insert_cluster, str)
@@ -739,21 +968,27 @@ def assess_design(
)
raw_table_type = connector_options.get("table.type")
raw_operation = connector_options.get("write.operation")
+ expected_operation = "upsert" if mutable_contract else "insert"
if (
"table.type" in connector_options
and raw_table_type != "COPY_ON_WRITE"
) or (
- "write.operation" in connector_options and raw_operation !=
"insert"
+ "write.operation" in connector_options
+ and raw_operation != expected_operation
):
assessment.add(
- "FLINK_PR2_WRITE_PATH_UNSUPPORTED",
- "A connector-option override leaves the bounded COW insert
path",
+ (
+ "FLINK_PR3_WRITE_PATH_UNSUPPORTED"
+ if mutable_contract
+ else "FLINK_PR2_WRITE_PATH_UNSUPPORTED"
+ ),
+ "A connector-option override leaves the bounded COW path",
)
verified_overrides = sorted(set(connector_options) -
set(unknown_options))
if verified_overrides:
assessment.add(
"FLINK_DESIGN_CONTRACT_INVALID",
- "PR2 connector settings must use canonical structured fields; "
+ "Connector settings must use canonical structured fields; "
f"pass-through overrides are not rendered:
{verified_overrides}",
)
@@ -775,7 +1010,14 @@ def assess_design(
"FLINK_DESIGN_CONTRACT_INVALID", "runtime.execution_mode must be
explicit"
)
elif execution_mode != "STREAMING":
- assessment.add("FLINK_PR2_WRITE_PATH_UNSUPPORTED", "PR2 supports
streaming execution")
+ assessment.add(
+ (
+ "FLINK_PR3_WRITE_PATH_UNSUPPORTED"
+ if mutable_contract
+ else "FLINK_PR2_WRITE_PATH_UNSUPPORTED"
+ ),
+ "The executable path supports streaming execution",
+ )
checkpointing_enabled = runtime.get("checkpointing_enabled")
if checkpointing_enabled is False:
assessment.add("FLINK_CHECKPOINTING_REQUIRED", "Streaming commits
require checkpointing")
@@ -794,7 +1036,7 @@ def assess_design(
"FLINK_CHECKPOINT_INTERVAL_UNSUPPORTED",
"The checkpoint interval must be at least "
f"{manifest['runtime_constraints']['checkpoint_interval_min_ms']}
ms for the "
- "bounded PR2 path",
+ "bounded executable path",
)
target_freshness = runtime.get("target_commit_freshness_ms")
if target_freshness is not None and (
@@ -815,7 +1057,7 @@ def assess_design(
"finding_codes": [finding["code"] for finding in assessment.findings],
"findings": assessment.findings,
"advisory_codes": assessment.advisories,
- "validation_evidence": validation_evidence(manifest),
+ "validation_evidence": validation_evidence(manifest, contract_schema),
}
if executable:
result["artifacts"] = _render_sql(
diff --git
a/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr1_scenarios.toml
b/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr1_scenarios.toml
index 8d3579a38648..d017169fc92a 100644
--- a/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr1_scenarios.toml
+++ b/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr1_scenarios.toml
@@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-schema_version = 3
+schema_version = 4
status_precedence = ["INCOMPLETE", "REVIEW_REQUIRED", "BLOCKED"]
[auto_key_acceptance]
@@ -105,12 +105,13 @@ executable_eligible = false
[[scenarios]]
case_id = "F07_MUTABLE"
-finding_codes = ["FLINK_MUTABLE_COW_DEFERRED"]
-expected_final_status = "BLOCKED"
-executable_eligible = false
+all_other_gates_pass = true
+passes_safety_gates = true
+next_contract_schema = 2
[[scenarios]]
case_id = "F08_REPLAY_COLLAPSE"
+inputs = { mutability = "append_only" }
finding_codes = ["FLINK_REPLAY_IDEMPOTENCE_DEFERRED"]
expected_final_status = "BLOCKED"
executable_eligible = false
diff --git
a/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr3/mutable_cow.json
b/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr3/mutable_cow.json
new file mode 100644
index 000000000000..0fcbbc84a812
--- /dev/null
+++
b/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr3/mutable_cow.json
@@ -0,0 +1,66 @@
+{
+ "contract_schema": 2,
+ "baseline_id": "hudi-1.2.0-flink-1.20",
+ "hudi_version": "1.2.0",
+ "flink_version": "1.20.1",
+ "safety": {
+ "table_lifecycle": "new",
+ "writer_model": "single_writer",
+ "external_catalog": false,
+ "mutability": "mutable",
+ "replay_behavior": "must_collapse"
+ },
+ "table": {
+ "name": "orders_mutable_hudi",
+ "path": "file:///tmp/hudi-architect-mutable-orders",
+ "columns": [
+ {"name": "id", "type": "STRING", "nullable": false},
+ {"name": "name", "type": "STRING", "nullable": true},
+ {"name": "event_version", "type": "BIGINT", "nullable": false},
+ {"name": "partition_date", "type": "DATE", "nullable": false}
+ ],
+ "partition_fields": ["partition_date"],
+ "partition_fields_mutable": false
+ },
+ "identity": {
+ "mode": "stable_key",
+ "record_key_fields": ["id"],
+ "record_key_option_fields": []
+ },
+ "ordering": {
+ "mode": "event_time",
+ "fields": ["event_version"]
+ },
+ "source": {
+ "table": "orders_mutable_source",
+ "columns": [
+ {"name": "id", "type": "STRING", "nullable": false},
+ {"name": "name", "type": "STRING", "nullable": true},
+ {"name": "event_version", "type": "BIGINT", "nullable": false},
+ {"name": "partition_date", "type": "DATE", "nullable": false}
+ ],
+ "primary_key_fields": ["id"],
+ "changelog_mode": "UPSERT",
+ "emits_deletes": true,
+ "delete_payload": "FULL_ROW"
+ },
+ "write": {
+ "table_type": "COPY_ON_WRITE",
+ "operation": "upsert",
+ "record_merge_mode": "EVENT_TIME_ORDERING",
+ "index": {
+ "type": "FLINK_STATE",
+ "global": true,
+ "state_ttl_days": 0,
+ "bootstrap_enabled": true
+ },
+ "changelog_enabled": false,
+ "connector_options": {}
+ },
+ "runtime": {
+ "execution_mode": "STREAMING",
+ "checkpointing_enabled": true,
+ "checkpoint_interval_ms": 60000,
+ "target_commit_freshness_ms": 120000
+ }
+}
diff --git
a/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr3/mutable_cow.sql
b/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr3/mutable_cow.sql
new file mode 100644
index 000000000000..4a90e0e42624
--- /dev/null
+++ b/hudi-agent-gateway/tests/fixtures/hudi_architect/flink_pr3/mutable_cow.sql
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+SET 'execution.runtime-mode' = 'streaming';
+SET 'execution.checkpointing.interval' = '60000 ms';
+
+CREATE TABLE `orders_mutable_hudi` (
+ `id` STRING NOT NULL,
+ `name` STRING,
+ `event_version` BIGINT NOT NULL,
+ `partition_date` DATE NOT NULL,
+ PRIMARY KEY (`id`) NOT ENFORCED
+)
+PARTITIONED BY (`partition_date`)
+WITH (
+ 'connector' = 'hudi',
+ 'path' = 'file:///tmp/hudi-architect-mutable-orders',
+ 'table.type' = 'COPY_ON_WRITE',
+ 'write.operation' = 'upsert',
+ 'ordering.fields' = 'event_version',
+ 'hoodie.write.record.merge.mode' = 'EVENT_TIME_ORDERING',
+ 'index.type' = 'FLINK_STATE',
+ 'index.global.enabled' = 'true',
+ 'index.state.ttl' = '0',
+ 'index.bootstrap.enabled' = 'true',
+ 'changelog.enabled' = 'false'
+);
+
+INSERT INTO `orders_mutable_hudi` (
+ `id`,
+ `name`,
+ `event_version`,
+ `partition_date`
+)
+SELECT
+ `id`,
+ `name`,
+ `event_version`,
+ `partition_date`
+FROM `orders_mutable_source`;
diff --git a/hudi-agent-gateway/tests/test_hudi_architect_skill.py
b/hudi-agent-gateway/tests/test_hudi_architect_skill.py
index a327bd4c00da..b662da9e4a4d 100644
--- a/hudi-agent-gateway/tests/test_hudi_architect_skill.py
+++ b/hudi-agent-gateway/tests/test_hudi_architect_skill.py
@@ -40,6 +40,7 @@ FLINK_VALIDATOR = SKILL_DIR / "validate_flink_capabilities.py"
FLINK_DESIGN_VALIDATOR = SKILL_DIR / "validate_flink_design.py"
PINNED_HUDI_REVISION = "f05c83f2b97732de7a558ff9b26959e1139c05f5"
PR2_FIXTURE_DIR = GATEWAY_DIR / "tests" / "fixtures" / "hudi_architect" /
"flink_pr2"
+PR3_FIXTURE_DIR = GATEWAY_DIR / "tests" / "fixtures" / "hudi_architect" /
"flink_pr3"
ASF_LICENSE_MARKER = "Licensed to the Apache Software Foundation (ASF) under
one"
PR2_JAVA_FIXTURE = (
GATEWAY_DIR
@@ -152,7 +153,7 @@ def
test_engine_router_is_lazy_and_keeps_spark_on_shared_flow() -> None:
assert "FLINK_" not in _read(REFERENCES_DIR / "config-templates.md")
-def test_flink_baseline_is_fixed_and_pr2_has_bounded_executable_contract() ->
None:
+def test_flink_baseline_has_bounded_append_and_mutable_contracts() -> None:
capability = _read(REFERENCES_DIR /
"flink-1.20-hudi-1.2.0-capabilities.md")
manifest = _load_toml(CAPABILITY_MANIFEST)
decisions = _read(REFERENCES_DIR / "flink-decision-overrides.md")
@@ -169,7 +170,7 @@ def
test_flink_baseline_is_fixed_and_pr2_has_bounded_executable_contract() -> No
"fixture_version": "1.20.1",
}
assert PINNED_HUDI_REVISION in capability
- assert manifest["schema_version"] == 2
+ assert manifest["schema_version"] == 3
assert manifest["fixture_artifacts"] == {
"hudi_bundle": "org.apache.hudi:hudi-flink1.20-bundle:1.2.0",
"flink_version": "1.20.1",
@@ -214,10 +215,11 @@ def
test_flink_capability_validation_emits_pinned_evidence() -> None:
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"baseline_id": "hudi-1.2.0-flink-1.20",
- "capability_manifest_schema": 2,
+ "capability_manifest_schema": 3,
"design_contract_schema": 1,
"flink_fixture_version": "1.20.1",
"hudi_source_revision": PINNED_HUDI_REVISION,
+ "supported_design_contract_schemas": [1, 2],
}
@@ -301,7 +303,7 @@ def
test_flink_capability_validation_rejects_executable_contract_drift(
assert expected_message in result.stderr
-def test_flink_capability_manifest_records_pr2_acceptance_contract() -> None:
+def test_flink_capability_manifest_records_executable_acceptance_contracts()
-> None:
manifest = _load_toml(CAPABILITY_MANIFEST)
python_tests = {
node.name
@@ -310,7 +312,11 @@ def
test_flink_capability_manifest_records_pr2_acceptance_contract() -> None:
}
java_fixture = _read(PR2_JAVA_FIXTURE)
effective_settings = {
- setting["key"]: setting["value"]
+ setting["applies_to"]: {
+ candidate["key"]: candidate["value"]
+ for candidate in manifest["required_effective_settings"]
+ if candidate["applies_to"] == setting["applies_to"]
+ }
for setting in manifest["required_effective_settings"]
}
implemented_checks = {
@@ -318,10 +324,30 @@ def
test_flink_capability_manifest_records_pr2_acceptance_contract() -> None:
for check in manifest["implemented_acceptance_checks"]
}
+ assert manifest["mutable_executable_contract"]["allow_replay_behaviors"]
== [
+ "cannot_occur",
+ "duplicates_acceptable",
+ "must_collapse",
+ ]
+ assert manifest["mutable_executable_contract"][
+ "reserved_ordering_field_values"
+ ] == ["no_precombine"]
assert effective_settings == {
- "table.type": "COPY_ON_WRITE",
- "write.insert.cluster": False,
- "write.operation": "insert",
+ "pr2-append-only-cow": {
+ "table.type": "COPY_ON_WRITE",
+ "write.insert.cluster": False,
+ "write.operation": "insert",
+ },
+ "pr3-mutable-cow": {
+ "changelog.enabled": False,
+ "hoodie.write.record.merge.mode": "EVENT_TIME_ORDERING",
+ "index.bootstrap.enabled": True,
+ "index.global.enabled": True,
+ "index.state.ttl": 0.0,
+ "index.type": "FLINK_STATE",
+ "table.type": "COPY_ON_WRITE",
+ "write.operation": "upsert",
+ },
}
assert implemented_checks == {
"FLINK_APPEND_MODE_CLUSTERING_ENABLED": (
@@ -356,6 +382,22 @@ def
test_flink_capability_manifest_records_pr2_acceptance_contract() -> None:
"test_pr2_enforces_checkpoint_interval_safety_floor",
"testPinnedRuntimeCheckpointIntervalBoundary",
),
+ "FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED": (
+ "test_pr3_rejects_unsupported_source_changelog",
+ "testMutableCowSinkAndUpsertPlan",
+ ),
+ "FLINK_MUTABLE_ORDERING_FIELD_INVALID": (
+ "test_pr3_rejects_invalid_ordering_fields",
+ "testPinnedMutableOrderingConfiguration",
+ ),
+ "FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED": (
+ "test_pr3_rejects_unsafe_index_configuration",
+ "testPinnedMutableIndexConfiguration",
+ ),
+ "FLINK_MUTABLE_INDEX_BOOTSTRAP_REQUIRED": (
+ "test_pr3_requires_index_bootstrap",
+ "testPinnedMutableIndexBootstrapConfiguration",
+ ),
}
for check in manifest["implemented_acceptance_checks"]:
assert check["python_test"] in python_tests
@@ -376,9 +418,9 @@ def
test_pinned_source_hashes_match_when_release_object_is_available() -> None:
assert result.returncode == 0, result.stderr
-def test_pr1_safety_gates_keep_deterministic_status_before_pr2_validation() ->
None:
+def
test_pr1_safety_gates_keep_deterministic_status_before_executable_validation()
-> None:
contract = _load_toml(GATE_FIXTURE)
- assert contract["schema_version"] == 3
+ assert contract["schema_version"] == 4
assert contract["status_precedence"] == ["INCOMPLETE", "REVIEW_REQUIRED",
"BLOCKED"]
precedence = {
status: priority for priority, status in
enumerate(contract["status_precedence"])
@@ -401,6 +443,14 @@ def
test_pr1_safety_gates_keep_deterministic_status_before_pr2_validation() -> N
assert resolved_status == scenario["expected_final_status"],
scenario["case_id"]
assert scenario["executable_eligible"] is False
+ mutable = next(
+ scenario
+ for scenario in contract["scenarios"]
+ if scenario["case_id"] == "F07_MUTABLE"
+ )
+ assert mutable["passes_safety_gates"] is True
+ assert mutable["next_contract_schema"] == 2
+
def test_combined_gate_fixture_preserves_all_reasons_and_blocked_wins() ->
None:
contract = _load_toml(GATE_FIXTURE)
@@ -459,12 +509,210 @@ def
test_pr2_valid_designs_render_exact_executable_sql(fixture_name: str) -> Non
).rstrip("\n")
assert assessment["validation_evidence"] == {
"baseline_id": "hudi-1.2.0-flink-1.20",
- "capability_manifest_schema": 2,
+ "capability_manifest_schema": 3,
"design_contract_schema": 1,
"flink_fixture_version": "1.20.1",
"hudi_source_revision": PINNED_HUDI_REVISION,
+ "supported_design_contract_schemas": [1, 2],
+ }
+
+
+def test_pr3_valid_mutable_design_renders_exact_executable_sql() -> None:
+ result = _run_flink_design_validator(PR3_FIXTURE_DIR / "mutable_cow.json")
+
+ assert result.returncode == 0, result.stderr
+ assessment = json.loads(result.stdout)
+ assert assessment["status"] == "CONFIG_VALIDATED"
+ assert assessment["executable_eligible"] is True
+ assert assessment["finding_codes"] == []
+ assert assessment["advisory_codes"] == []
+ assert assessment["artifacts"]["combined_sql"] == _read_sql_golden(
+ PR3_FIXTURE_DIR / "mutable_cow.sql"
+ ).rstrip("\n")
+ assert assessment["validation_evidence"]["design_contract_schema"] == 2
+ assert assessment["validation_evidence"]["capability_manifest_schema"] == 3
+
+
[email protected](
+ "replay_behavior",
+ ["cannot_occur", "duplicates_acceptable", "must_collapse"],
+)
+def test_pr3_accepts_resolved_replay_behaviors(
+ tmp_path: Path, replay_behavior: str
+) -> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ design["safety"]["replay_behavior"] = replay_behavior
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 0, result.stderr
+ assessment = json.loads(result.stdout)
+ assert assessment["status"] == "CONFIG_VALIDATED"
+ assert assessment["finding_codes"] == []
+ assert assessment["artifacts"]["combined_sql"] == _read_sql_golden(
+ PR3_FIXTURE_DIR / "mutable_cow.sql"
+ ).rstrip("\n")
+
+
[email protected]("replay_behavior", ["unknown", [], {}])
+def test_pr3_rejects_unresolved_replay_behaviors(
+ tmp_path: Path, replay_behavior: object
+) -> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ design["safety"]["replay_behavior"] = replay_behavior
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 1
+ assessment = json.loads(result.stdout)
+ assert assessment["finding_codes"] == ["FLINK_REPLAY_BEHAVIOR_UNRESOLVED"]
+ assert assessment["status"] == "REVIEW_REQUIRED"
+ assert "artifacts" not in assessment
+
+
+def test_pr3_accepts_update_only_source_without_delete_payload(tmp_path: Path)
-> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ design["source"]["emits_deletes"] = False
+ design["source"]["delete_payload"] = "NOT_APPLICABLE"
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 0, result.stderr
+ assessment = json.loads(result.stdout)
+ assert assessment["status"] == "CONFIG_VALIDATED"
+ assert assessment["finding_codes"] == []
+
+
[email protected]("changelog_mode", ["INSERT_ONLY", "RETRACT", "ALL"])
+def test_pr3_rejects_unsupported_source_changelog(
+ tmp_path: Path, changelog_mode: str
+) -> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ design["source"]["changelog_mode"] = changelog_mode
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 1
+ assessment = json.loads(result.stdout)
+ assert assessment["finding_codes"] == [
+ "FLINK_MUTABLE_SOURCE_CHANGELOG_UNSUPPORTED"
+ ]
+ assert assessment["status"] == "BLOCKED"
+ assert "artifacts" not in assessment
+
+
[email protected](
+ ("field_name", "field_type", "nullable"),
+ [
+ ("event_version", "STRING", False),
+ ("event_version", "TIMESTAMP_LTZ(3)", False),
+ ("event_version", "BIGINT", True),
+ ("no_precombine", "BIGINT", False),
+ ],
+)
+def test_pr3_rejects_invalid_ordering_fields(
+ tmp_path: Path, field_name: str, field_type: str, nullable: bool
+) -> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ for section in ("table", "source"):
+ column = next(
+ column
+ for column in design[section]["columns"]
+ if column["name"] == "event_version"
+ )
+ column["name"] = field_name
+ column["type"] = field_type
+ column["nullable"] = nullable
+ design["ordering"]["fields"] = [field_name]
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 1
+ assessment = json.loads(result.stdout)
+ assert "FLINK_MUTABLE_ORDERING_FIELD_INVALID" in
assessment["finding_codes"]
+ assert assessment["status"] == "BLOCKED"
+ assert "artifacts" not in assessment
+
+
[email protected](
+ ("field", "value"),
+ [("type", "BUCKET"), ("global", False), ("state_ttl_days", 1)],
+)
+def test_pr3_rejects_unsafe_index_configuration(
+ tmp_path: Path, field: str, value: object
+) -> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ design["write"]["index"][field] = value
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 1
+ assessment = json.loads(result.stdout)
+ assert assessment["finding_codes"] == [
+ "FLINK_MUTABLE_INDEX_CONFIGURATION_UNSUPPORTED"
+ ]
+ assert assessment["status"] == "BLOCKED"
+
+
+def test_pr3_requires_index_bootstrap(tmp_path: Path) -> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ design["write"]["index"]["bootstrap_enabled"] = False
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 1
+ assessment = json.loads(result.stdout)
+ assert assessment["finding_codes"] ==
["FLINK_MUTABLE_INDEX_BOOTSTRAP_REQUIRED"]
+ assert assessment["status"] == "BLOCKED"
+
+
+def test_pr3_rejects_table_property_alias_for_write_merge_mode(tmp_path: Path)
-> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ design["write"]["connector_options"] = {
+ "hoodie.record.merge.mode": "EVENT_TIME_ORDERING"
}
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 1
+ assessment = json.loads(result.stdout)
+ assert assessment["finding_codes"] == ["FLINK_OPTION_NOT_VERIFIED"]
+ assert assessment["status"] == "REVIEW_REQUIRED"
+ assert "artifacts" not in assessment
+
+
[email protected](
+ ("mutation", "expected_code"),
+ [
+ (("identity", "mode", "auto_key"),
"FLINK_MUTABLE_AUTO_KEY_UNSUPPORTED"),
+ (
+ ("table", "partition_fields_mutable", True),
+ "FLINK_MUTABLE_PARTITION_EVOLUTION_UNSUPPORTED",
+ ),
+ (
+ ("source", "delete_payload", "KEY_ONLY"),
+ "FLINK_MUTABLE_DELETE_PAYLOAD_UNSUPPORTED",
+ ),
+ (("write", "operation", "insert"), "FLINK_PR3_WRITE_PATH_UNSUPPORTED"),
+ ],
+)
+def test_pr3_fails_closed_outside_the_bounded_mutable_path(
+ tmp_path: Path, mutation: tuple[str, str, object], expected_code: str
+) -> None:
+ design = json.loads(_read(PR3_FIXTURE_DIR / "mutable_cow.json"))
+ section, field, value = mutation
+ design[section][field] = value
+ if section == "identity" and field == "mode":
+ design["identity"]["record_key_fields"] = []
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 1
+ assessment = json.loads(result.stdout)
+ assert expected_code in assessment["finding_codes"]
+ assert assessment["status"] == "BLOCKED"
+ assert "artifacts" not in assessment
+
def test_pr2_fails_closed_when_capability_manifest_is_malformed(tmp_path:
Path) -> None:
manifest_path = tmp_path / "capabilities.toml"
@@ -486,6 +734,22 @@ def
test_pr2_fails_closed_when_capability_manifest_is_malformed(tmp_path: Path)
assert "artifacts" not in assessment
[email protected]("contract_schema", [True, 1.0, 3])
+def test_flink_design_rejects_non_exact_contract_schema(
+ tmp_path: Path, contract_schema: object
+) -> None:
+ design = json.loads(_read(PR2_FIXTURE_DIR / "stable_key.json"))
+ design["contract_schema"] = contract_schema
+
+ result = _run_flink_design_validator(_write_design(tmp_path, design))
+
+ assert result.returncode == 1
+ assessment = json.loads(result.stdout)
+ assert "FLINK_DESIGN_CONTRACT_INVALID" in assessment["finding_codes"]
+ assert assessment["executable_eligible"] is False
+ assert "artifacts" not in assessment
+
+
def test_pr2_rejects_record_key_missing_from_append_schema(tmp_path: Path) ->
None:
design = json.loads(_read(PR2_FIXTURE_DIR / "stable_key.json"))
design["write"]["connector_options"] = {
@@ -1052,7 +1316,7 @@ def test_safety_gate_order_is_stable() -> None:
"## F7 — Physical table and source contract",
"## F8 — Source changelog contract",
"## F9 — Streaming checkpoint contract",
- "## PR2 validation and completion",
+ "## PR2/PR3 validation and completion",
]
offsets = [flow.index(heading) for heading in headings]
assert offsets == sorted(offsets)