Skip to content

Auto test every field mutation - #6452

Draft
denik wants to merge 141 commits into
mainfrom
denik/extensive-test
Draft

Auto test every field mutation#6452
denik wants to merge 141 commits into
mainfrom
denik/extensive-test

Conversation

@denik

@denik denik commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Adds bundle/direct/autotest: a per-field catalog of how the direct engine handles every field a user
can set. For each field it walks every ordered pair of a small value set — absent included, so adding
and removing a field are just the pairs with absent on one side — and records three things per move:
did the plan propose a change, did the apply succeed, did the next plan converge.

The outcome is a verdict word in a committed golden. A bad outcome is recorded, not failed, so the
report is a map of the engine's behaviour rather than a wall of failures; what fails the test is a
verdict changing. All 34 resource types the engine supports, ~3900 observations.

Both a local run against libs/testserver and a real-workspace run are held to the same golden, so a
divergence means the fake server does not match the API. That is where most of this PR's findings come
from — 14 fake-server fidelity fixes, each carrying the API's own error text:

  • a genie space created with no title comes back named, not empty
  • a SQL warehouse create requires a name, a cluster_size, and max_num_clusters in 1..40, and the
    backend defaults enable_photon and spot_instance_policy
  • a pipeline with a storage location cannot have a catalog added — verified directly: a pipeline
    created with neither gets storage, and the later update is then refused
  • a serving endpoint's create does not apply email_notifications; an update does
  • a cluster's autotermination_minutes has a 10-minute minimum
  • target_qps on a vector search endpoint is omitempty, so the API drops a clear
  • a registered model needs all three name parts; an Azure KeyVault scope needs its metadata

Three of those exposed acceptance fixtures asserting behaviour the API does not have. All were
Cloud = false, so nothing had ever checked them: bind/pipelines/update bound a storage-defaulted
pipeline to a config asking for a catalog; create-scope sent the bundle schema's field name to a raw
API call, so the CLI dropped the metadata and the whole script aborted once the fake required it; and
the serving-endpoint fix sharpened a test whose own comment describes a TF bug the fake had been hiding
half of.

Engine findings the goldens record: 27 postgres_projects rows name a field in update_mask and then
omit it from the body (#6441 and #6443 fixed 13 of these while this branch was open);
pipelines.catalog is absent from recreate_on_changes, so the engine sends an update the API refuses.

Running it

./task test covers the local run. Two new tasks for the real-workspace runs:

  • ./task autotest-cloud — every field; nightly
  • ./task autotest-cloud-pr-sample 2, for PRs: two fields per type, picked from HEAD so
    successive commits cover different ground while one run's picks follow from its SHA

output/ holds the 34 committed goldens and nothing else; logs/ holds the full report per type and
is gitignored wholesale.

State of the cloud verification

In progress. 14 of the 34 types declare local_only with a reason — an external location needs
IAM-backed storage, an instance pool cannot be deleted again, a postgres project holds its name after
deletion, a job run would start a real job per transition. Of the 20 that can be driven, verification
against the current goldens is under way; each one that diverged so far has been a fixture value the
fake accepts and the API refuses, or a fake-server gap, and is fixed in this branch.

Test-only; no changelog fragment.

This pull request and its description were written by Isaac.

@denik denik changed the title Catalog how the direct engine handles every settable field Auto test every field mutation Sep 3, 2026
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 0528be3

Run: 34641852368

Env ❌​FAIL 🔄​flaky 💚​RECOVERED ✅​pass 🙈​skip Time
❌​ aws linux 3 1 586 35 180:36
❌​ aws windows 3 1 588 33 175:27
❌​ azure linux 4 1 570 35 14:04
❌​ azure windows 4 1 572 33 11:58
❌​ gcp linux 3 1 586 35 113:45
❌​ gcp windows 3 1 1 587 33 109:46
6 interesting tests: 4 FAIL, 1 RECOVERED, 1 flaky
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
❌​ TestFields ❌​F ❌​F ❌​F ❌​F ❌​F ❌​F
❌​ TestFields/clusters ❌​F ❌​F ❌​F ❌​F ❌​F ❌​F
❌​ TestFields/model_services ❌​F ❌​F ❌​F ❌​F ❌​F ❌​F
❌​ TestFields/model_serving_endpoints ✅​p ✅​p ❌​F ❌​F ✅​p ✅​p
🔄​ TestSyncIncrementalSyncFileToPythonNotebook ✅​p ✅​p ✅​p ✅​p ✅​p 🔄​f
Top 50 slowest tests (at least 2 minutes):
duration env testname
29:46 aws linux TestFields/clusters/single_user_name
23:21 aws windows TestFields/clusters/single_user_name
19:59 gcp linux TestFields/clusters/single_user_name
18:10 gcp windows TestFields/clusters/single_user_name
17:04 aws windows TestFields/clusters/cluster_log_conf.s3.canned_acl
12:56 gcp windows TestFields/clusters/cluster_log_conf.s3.canned_acl
11:41 aws linux TestFields/clusters/cluster_log_conf.s3.canned_acl
11:32 gcp linux TestFields/clusters/cluster_log_conf.s3.canned_acl
10:56 aws linux TestFields/clusters/single_user_name/x_to_y
7:47 aws linux TestFields/clusters/single_user_name/absent_to_x
5:57 aws windows TestFields/clusters/single_user_name/y_to_absent
5:38 aws windows TestFields/clusters/single_user_name/x_to_y
5:37 aws windows TestFields/clusters/single_user_name/y_to_x
5:00 aws windows TestFields/clusters/cluster_log_conf.s3.canned_acl/absent_to_x
5:00 aws windows TestFields/clusters/cluster_log_conf.s3.canned_acl/y_to_absent
4:43 gcp windows TestAccept
4:39 aws windows TestFields/apps
4:33 gcp linux TestFields/clusters/single_user_name/x_to_absent
4:32 gcp linux TestFields/clusters/single_user_name/y_to_absent
4:29 azure windows TestFields/apps
4:26 gcp linux TestFields/clusters/single_user_name/x_to_y
4:22 gcp linux TestFields/apps
4:19 azure linux TestFields/apps
4:10 gcp linux TestFields/clusters/single_user_name/y_to_x
4:09 gcp windows TestFields/clusters/single_user_name/y_to_absent
4:02 gcp windows TestFields/clusters/single_user_name/y_to_x
4:00 gcp windows TestFields/clusters/single_user_name/x_to_y
4:00 aws linux TestFields/clusters/single_user_name/y_to_absent
3:56 gcp windows TestFields/apps
3:51 azure windows TestAccept
3:51 aws linux TestFields/clusters/single_user_name/x_to_absent
3:50 gcp windows TestFields/clusters/single_user_name/x_to_absent
3:48 aws linux TestFields/apps
3:39 azure windows TestFields/apps/git_source.tag
3:35 aws windows TestFields/apps/git_source.tag
3:31 aws windows TestAccept
3:31 gcp linux TestFields/apps/git_source.tag
3:29 azure linux TestFields/apps/git_source.tag
3:09 aws linux TestFields/clusters/single_user_name/y_to_x
3:07 aws windows TestFields/clusters/single_user_name/x_to_absent
2:58 gcp windows TestFields/apps/git_source.tag
2:45 aws linux TestFields/apps/git_source.tag
2:44 aws linux TestFields/clusters/cluster_log_conf.s3.canned_acl/x_to_absent
2:25 aws linux TestFields/clusters/cluster_log_conf.s3.canned_acl/y_to_absent
2:16 gcp linux TestFields/clusters/single_user_name/absent_to_x
2:15 gcp windows TestFields/clusters/cluster_log_conf.s3.canned_acl/y_to_x
2:14 gcp windows TestFields/clusters/cluster_log_conf.s3.canned_acl/x_to_absent
2:14 aws windows TestFields/clusters/cluster_log_conf.s3.canned_acl/x_to_y
2:13 gcp windows TestFields/clusters/cluster_log_conf.s3.canned_acl/y_to_absent
2:06 gcp windows TestFields/clusters/single_user_name/absent_to_x

@denik
denik force-pushed the denik/extensive-test branch 4 times, most recently from 04d32c3 to d34b72b Compare September 11, 2026 09:26
Recreating a resource re-keys the parent but not its permissions/grants child:
the child's state entry keeps the old id, DoRead echoes it back, and the planner
re-proposes the same update on every deploy. The ACL itself is applied to the
recreated object, so this is stale bookkeeping rather than lost access, and
terraform converges -- hence the per-engine plan files.

Co-authored-by: Isaac
model_with_permissions.yml.tmpl was model.yml.tmpl plus a permissions block, so
the two tested the same resource twice. Invariant configs carry permissions or
grants by default; a permissions-free variant is not a separate case.

continue_293 now excludes the model config outright, since model permissions did
not work until 0.297.0.

Co-authored-by: Isaac
Moves every field of every single-resource invariant config through each ordered
pair of a small value set -- absent included, so add and remove are just the
pairs with absent on one side -- and records whether the plan proposed anything,
the apply succeeded, and the next plan was clean.

Runs in-process against CalculatePlan/Apply rather than shelling out to bundle
deploy, so 5502 observations across 22 resource types take 35s instead of being
dominated by bundle file sync. Permissions and grants are stripped first; a bad
outcome does not fail the test, it lands in out.<resource_type>.fields.txt, and
the committed goldens are what turn an SDK bump into a visible diff.

Co-authored-by: Isaac
Every field of catalog.UpdateSchema is omitempty and DoUpdate forwards only the
config's own ForceSendFields, so removing the comment sends an empty PATCH body
and UC keeps the old value. The deploy reports success and every later plan
re-proposes the same update.

Co-authored-by: Isaac
The earlier approach: "# ACTION:" annotations in the invariant configs drive
apply_update and apply_remote_update, which plan, apply and re-plan one field at
a time through the full CLI.

apply_update is now covered far more thoroughly by bundle/direct/dresources_test.
apply_remote_update is not -- it drifts the remote by restoring a state snapshot,
which the Go catalog does not do yet -- so this is kept for that half. Drop the
commit if the remote case lands there instead.

Co-authored-by: Isaac
A harness rebuilt inside a field-level subtest goes on being used by later
fields, but it captured that subtest's t.Context(), which is cancelled the moment
the subtest returns -- 56 observations came back as "context canceled" from the
SDK rate limiter rather than a real verdict. Thread the resource-type context in
instead.

With those unmasked, model serving telemetry turned out to be rejected 36 times:
the block only validates against a catalog and schema that exist and are
writable. Skip the subtree, which needed skip keys to support a "<prefix>.*"
form.

Co-authored-by: Isaac
…text

cloud: true was the wrong default -- a resource type should be covered
everywhere, and the expensive ones still have to be covered. Replaced with
slow: true, which only drops a type under -short: the PR cloud leg skips them,
the nightly does not. Files that said nothing but cloud: true are gone.

t.Context() is cancelled just before Cleanup functions run, so every harness
destroy was issuing its API calls on a dead context and silently leaking the
resource; a harness rebuilt inside a field subtest hit the same thing once that
subtest returned. context.WithoutCancel keeps the values and drops the deadline.

Reports move to output/, so the package directory reads as seven entries instead
of thirty.

Co-authored-by: Isaac
It drives CalculatePlan and Apply, so it tests the engine rather than any one
resource implementation, and it belongs beside the engine instead of under
dresources. The old dresources_test directory name also misapplied Go's _test
package-name convention to a directory.

Value libraries move to testdata/fields, which the go tool ignores by name.
Goldens stay in output/. Every file in the package is a _test.go, so GoFiles is
empty and none of this reaches the binary.

Co-authored-by: Isaac
The committed output/<type>.txt now lists just the results worth looking at, one
line each, with from and to as their own columns. output/<type>.full.txt keeps
everything plus the summary and the not-covered list, and is gitignored since it
moves whenever a passing row does. 304 findings out of 5442 observations.

A SUPPRESSED whose reason is "empty" is not one of them: both sides were empty,
so nothing the user expressed was lost.

DRIFT becomes POST_DEPLOY_DRIFT, which is what it measures. Transition subtests
are named absent_to_true rather than with an arrow, so a single case re-runs
without shell quoting, and now -v prints the post-deploy plan behind any finding.

Dropped the slow flag: every resource type runs everywhere. Renaming an app is a
real operation, so it is no longer skipped for being slow. It records
DEPLOY_ERROR "timed out: app is deleting", which is the finding. A per-operation
deadline backs that up with a TIMEOUT verdict.

Main fixed clearing a schema comment, so that test becomes clear_field: it now
asserts comment converges and records that custom_max_retention_hours does not.

Co-authored-by: Isaac
They were committed by accident: output/<type>.txt is the file worth diffing.

Co-authored-by: Isaac
They were committed by accident: output/<type>.txt is the file worth diffing.

Co-authored-by: Isaac
Slices and maps are covered two ways. The container becomes a field whose values
are the config's own and that value minus its last entry, so with absent in the
set one field covers adding and removing an entry as well as the whole container
-- all with data the backend already accepted. Separately, a pattern like
tasks[*].description is expanded against the deployed config to the indices that
exist, so fields inside an element are tested like any other. A pattern with
nothing behind it is reported as not covered rather than silently passing.

base: in a value library seeds a block the invariant config does not declare, so
its fields become reachable. A job's git_source needs a provider, a url and
exactly one ref, and cannot be built up one field at a time from nothing; seeded,
its skip list drops from nine entries to the two that are genuinely a different
side of the same oneof.

Value pairs are now walked as a single chain instead of staged one at a time. The
values form a complete digraph, so one Eulerian circuit covers every ordered pair
exactly once and each move starts where the last ended -- one deploy per
transition instead of two, which took jobs from 381s to 233s. Walk order is
shuffled from a seed derived from the field path: fixed per field so goldens stay
byte-stable, different between fields so the suite is not always taking the same
path. TestTransitionsCoverEveryPairInOneChain is what guarantees nothing is left
for a second chain to pick up.

Configs of a resource type now run in parallel, so the report is guarded.
out.full.txt indents the evidence under each finding: the post-deploy plan for
drift, the whole API error for a rejection.

Co-authored-by: Isaac
One config per resource type -- the simplest one -- and whatever structure that
config lacks is seeded through testdata/fields instead. A slice or map is a field
in its own right, whose values are the config's own and that value minus its last
entry, so with absent in the set one field covers adding and removing an entry as
well as the whole container. Patterns like tasks[*].description expand against
the deployed config, so fields inside an element are tested like any other, and
what no config declares is named rather than passed over.

Findings are classified until each verdict names one mechanism: UPDATE_IGNORED
(write accepted, remote unmoved on two reads) separated from STALE_READ (unmoved
on the first read only), COLLATERAL_DRIFT (updating this field left another one
drifting -- 119 rows were one field wearing other fields' names), BASELINE_DRIFT
(drifts with nothing changed), and OK_INERT / INERT_NOT_HONOURED, which check the
ignore_local_changes declarations rather than trusting them.

Order comes from git HEAD so a finding cannot be retried away, and the pairs are
walked as one Eulerian circuit, halving the deploys.

Three bugs in the harness, each of which quietly under-reported coverage: map
wildcards never expanded, since splitPattern emitted the wildcard twice; the
not-covered list was a union across configs, so it named fields other configs
were testing; and neither a map key nor a slice element could be removed, since
dyn's path parser reads brackets as indices and deletePath only handled keys.

libs/testserver grows SettleAsyncImmediately, off by default so acceptance keeps
exercising the waiters. The SDK's poller has a hardcoded one-second backoff with
no exported way to shorten it, so one in-progress read per update cost serving
endpoints more wall time than every other resource type combined: 512s to 5s.

7052 observations over 22 resource types in 76s.

Co-authored-by: Isaac
The generic "x"/"y" defaults are rejected outright on a real workspace for a
field that names another object, and the retention period is validated in days,
so those fields reported nothing but BACKEND_ERROR on cloud.

Co-authored-by: Isaac
The suite now writes values through libs/structs (structaccess over the resource's
own Go type, structpath for paths) and syncs the result into the dynamic tree the
way a mutator does, instead of manipulating dyn.Value directly. Nothing in the
package depends on libs/dyn any more.

That also removes every hand-written substitute for something dyn lacked: a path
parser that could hold a map key, a delete-by-path, a parent-creating walk. And it
makes "absent" the distinction the API actually sees -- the zero value with the
field out of ForceSendFields -- so a list element or map entry now becomes settable
where it previously reported UNSETTABLE: 91 of those are gone and the suite records
252 more observations, in a third of the wall time.

The order seed is a constant now rather than HEAD. Some verdicts depend on the
order -- a field the API cannot clear leaves the remote holding an old value -- and
committing a report changes HEAD, so a HEAD-derived seed could never validate at
the commit that carried the report.

Co-authored-by: Isaac
MLflow refuses to clear a description, and the update sends the field
unconditionally, so a config that drops it fails on a real workspace while the
fake server accepted it.

readplan-permissions changed the description instead of removing it, since
removing it can no longer converge.

Co-authored-by: Isaac
From the AWS comparison: a skip key naming a field inside a slice
("aliases[*].id") never matched the concrete "aliases[0].id" it expands to, so the
field was tested anyway and the backend rejected every value.

Keys are now matched the way the planner matches its own field rules, which also
makes naming a block skip everything beneath it -- so the ".*" suffix is gone.

Co-authored-by: Isaac
From the AWS comparison: an alias belongs to a model version and is managed through
its own API, so UC does not echo aliases on a plain GET -- which is why the direct
engine passes include_aliases=false and declares the field input_only. The fake
server returned them anyway, so the remote appeared to hold what the config asked
for and the field's real behaviour was hidden.

registered_models now matches AWS row for row.

Co-authored-by: Isaac
From the AWS comparison: an experiment name is an absolute workspace path and an
artifact location needs a scheme, so the generic "x" was rejected outright. The UC
trace location needs its catalog and schema together, like a job's git_source, so
it is seeded rather than built up one field at a time.

experiments matches AWS row for row.

Co-authored-by: Isaac
…on values

From the AWS comparison. A retention period is validated in days (0, or 7 to 30), so
the generic 1 and 2 were rejected. The rest need workspace state this suite does not
provision: a Delta Sharing provider and share, a connection, an external location, a
customer-managed key.

options is skipped for a different reason worth noting: UpdateCatalog rejects the
field outright ("UpdateCatalog options can not be provided"), and the engine sends it
on every update whenever the config declares it -- so seeding it made every other
field of the catalog fail its update too.

catalogs matches AWS row for row.

Co-authored-by: Isaac
From the AWS comparison: external_locations cannot deploy against a real workspace at
all -- it needs a storage credential with cloud IAM behind it -- so every field
reported one BASE_ERROR and the golden diverged for a reason that says nothing about
the engine. The invariant suite already excludes the same config from its cloud run.

A type now declares "local_only: <reason>" in its value library and is skipped on
cloud; its local golden stands, since a cloud run neither confirms nor contradicts it.

Co-authored-by: Isaac
Two fixes from the AWS comparison, both about the suite rather than the engine.

An alert or dashboard is created inside ${workspace.resource_path} and the backend
404s on a missing parent, which is why a real deploy runs deploy.ResourcePathMkdir
first. This suite plans and applies directly, so it now runs that step too --
otherwise the whole type reported one BASE_ERROR that said nothing about any field.

A seeded value naming tester@databricks.com only exists on the fake server, so the
base fragment is now expanded with the same $VARS the corpus configs use.

Also: a base error's evidence went through firstError, which truncates to 140
characters -- so the full report, whose whole purpose is to carry the backend's own
words, showed a cut-off message.

Co-authored-by: Isaac
…brary

From the AWS comparison. An enum field was getting the generic "x" and "y", which a
real backend rejects or silently ignores, so the field reported nothing about whether
the engine handles a change to it -- the alerts report was full of those. Every
generated SDK enum declares its own values through a Values() method, so they are read
from there instead, which fixes enum fields across every resource type.

The alerts library also skips what the suite cannot provision: a second warehouse or
workspace folder (and the backend refuses to move an alert between folders at all), and
a registered notification destination. A subscription and a run_as now name the
workspace's own user.

Co-authored-by: Isaac
…texts

Both from the adversarial review.

containerValues recorded a slice or map with its elements shallow-copied, so the
pointers inside an element were shared with the live resource: editing a field under
clusters.init_scripts[0].workspace then reached into the value saved for the
init_scripts container's own transitions, and that container was tested against
corrupted data.

A plan the caller never applies was dropping its pendingApply on the floor, leaking
the operation context and its ten-minute timer -- thousands of them on a cloud run.
Such a caller now goes through readPlan, and the two paths that plan and then return
early cancel explicitly.

Co-authored-by: Isaac
A row read "cannot update resources.postgres_projects.foo: updating id=projects/test-pg-project-[UNIQU
...ovided in request", which is the question rather than the answer: the reason was
"Field 'spec.budget_policy_id' is in update_mask but not provided in request", and the cut fell in the
middle of it.

The engine's own framing was the thing crowding it out, and it says only what the row already says --
the resource is the report's own filename, the operation follows from the transition. Dropping that
prefix and widening the cap to 200 characters leaves every message in the catalog intact: 49 elided rows
become 0, and the longest surviving detail is 213 characters.

Worth the change beyond legibility. With the message whole, one row now reads "Field
'spec.default_endpoint_settings.suspension' is in update_mask but not provided" against a transition of
autoscaling_limit_min_cu -- the engine naming a different field than the one being changed, which the
elision had been hiding.

Also restores the rune-safe truncation from an earlier commit, lost in a merge resolution: the ellipsis
substituted for a URL is multi-byte, so a byte index can split it.

Co-authored-by: Isaac
A field the API will not clear leaves the resource drifted, and the suite replaced the resource for it.
That was the wrong response twice over.

It was wasteful: on apps it cost a fresh app per non-converging field, 31 in one run, on a shared
workspace that allows 100 in total. The sweep kept hitting "reached the maximum limit of 100 apps" for
no reason but this.

And it was wrong. The drift was real, and destroying the resource that carried it meant the next
resource had none -- so the drift reappeared later and was attributed to whichever field was under test
at the time. database_instances shows it plainly: COLLATERAL_DRIFT 46 rows to 0, OK 2 rows to 26, and
capacity now reads UPDATE_IGNORED, which is what the API actually does with it, rather than
POST_DEPLOY_DRIFT.

A resource is replaced when it will no longer deploy at all -- which is what the original comment was
really guarding against, and is rare. Drift on its own goes into the baseline, where remeasure already
knew how to put it, and every later field is measured against the drift that is really there.

Across the catalog: COLLATERAL_DRIFT 46 to 20.

Co-authored-by: Isaac
…n assume it

A failed deploy is not evidence that a resource is unusable -- the API may simply refuse to put one
field back. So ask it something it must be able to answer.

Three outcomes now, and only the last spends a new resource:

  - Base comes back. Any remaining drift is real and goes into the baseline.
  - Base does not, but the state the transitions left does. The API had already accepted that state, so
    a deploy of it succeeds unless the resource itself is broken -- it answered, so the resource is
    alive and updatable and only this one field is stuck. Base advances to what is reachable, so later
    fields start from a state the API accepts instead of re-failing on this field all run.
  - Neither deploys: past repair, replace it.

The probe is a snapshot rather than a reconstruction -- the config as the field's transitions left it is
already the state the remote holds -- so the whole thing is a switch with three arms.

Verdicts improve again, because a discarded resource was taking its drift with it and the next field was
being blamed for it: OK 1486 to 1488, and postgres_projects' BASE_ERROR 14 to 12 with the remaining ones
naming autoscaling_limit_min_cu, the field actually under test, instead of suspension.

Co-authored-by: Isaac
… does

"use_ml_runtime is not allowed with unspecified kind" -- the same constraint already recorded for
is_single_node, on a fixture whose cluster has no kind. Found by the cloud run; the mock server stores
the field either way.

Co-authored-by: Isaac
Review found the three-arm switch was doing more than it needed. The first arm deployed, then called
converged(), which deployed again -- and converged() answers false for both "the deploy failed" and
"the plan is dirty", so a second failure was being recorded as drift. The arms also differed in whether
they refreshed the baseline, so a clean restore left stale entries behind, and driftDetail would have
suppressed a later drift on one of those paths.

It is now one condition and one shared conclusion: try base, fall back to the state the transitions
left, rebuild only if neither deploys -- then re-measure the baseline from whatever landed. Always
replacing the baseline is what fixes the stale entries, and it is also simpler than deciding when not to.

converged() and hasDrift() have no callers left, which is the clearest evidence the shape was wrong:
their whole job was the second deploy this no longer does.

No golden moves.

Co-authored-by: Isaac
A rejected request is a statement about the request, not the resource: a 400 means the backend did not
act, so the remote still holds what it had and the run carries on with it. What the rejection tells us
depends on direction, and removing a value is the informative one -- a field the API will not let go of
is required in practice, whatever the bundle schema says.

Two verdicts, split by whether the schema already knew:
  CANNOT_DELETE_REQUIRED     -- the schema marks it required, so the refusal is the contract working.
  CANNOT_DELETE_NOT_REQUIRED -- it does not, so one of the two is wrong and is worth a look.

Reaching the second needs absent tested on required fields, which the suite used to skip on the grounds
that a config missing one does not validate. That was the wrong authority: validate.Required only warns,
and the edit happens after it runs, so the API was always the one answering. Now it answers for every
field. 8 rows come back CANNOT_DELETE_REQUIRED and 47 CANNOT_DELETE_NOT_REQUIRED -- cluster policies'
name and definition, a model's description, postgres is_protected and no_expiry.

The same classifier reads the other direction too: a field that cannot be cleared cannot be a starting
value either, so those rows say so instead of carrying a bare error. BASE_ERROR drops from 93 to 65.

One honest limit: CANNOT_DELETE_NOT_REQUIRED groups two causes. Most are the API requiring a field the
schema does not mark, which is the finding it is named for; some are the engine naming a field in
update_mask and then omitting it, which is a bug of ours and not a statement about requiredness at all.
The API's own words are in the detail, which is what tells them apart.

Co-authored-by: Isaac
CANNOT_DELETE_NOT_REQUIRED was carrying three unrelated facts, because any 400 on a removal was read as
the API insisting on the value. The messages tell them apart, so the classifier now reads them -- with
the phrase list taken from real responses rather than guessed, and written down next to the service that
produced each one:

  UPDATE_MASK_OMITTED          the engine named the field in update_mask and left it out of the body.
                               Our defect, not a fact about the field, and checked first because it
                               would otherwise read as the API demanding a value it was never sent.
                               Checked before the direction gate too: a malformed mask is malformed
                               whichever way the field was moving, which is what turned up 33 more rows
                               that had been sitting in BACKEND_ERROR.
  CANNOT_DELETE_{NOT_,}REQUIRED  the message says the value is needed -- must be supplied, cannot be
                               empty, Missing required field. Split by whether the schema agrees.
  BACKEND_ERROR                everything else, which now means what it says: an enum value the API
                               does not accept, a name that already exists, a parent that is missing.

Of the 47 rows previously grouped as one: 31 are the update_mask defect, 12 are the API requiring a
field the schema does not mark, and 4 are rows where both agree. The two "Unknown library" rows went
back to being plain backend errors, which is correct -- that message says nothing about requiredness.

error_code does the coarse split it can be trusted for: INVALID_PARAMETER_VALUE and BAD_REQUEST mean the
request was refused. Every message above shares those codes, so the fine split has to come from the
text, and the phrase list is the honest version of that.

Co-authored-by: Isaac
Both from the cursor review, both the same shape -- an operation that failed left something behind for
the next field to trip over.

A rejected edit is now undone. setNode allocates the containers on the way to a leaf before it can find
out the leaf will not take the value, so an UNSETTABLE transition left a half-built block in the config
while the caller moved on believing nothing had happened. setField snapshots and restores, which covers
both of its call sites and any future one. Latent today -- every recorded UNSETTABLE fails before
mutating anything -- so this is a guard, not a fix for an observed wrong row.

A base the run could not reset is now recorded. When the API refuses to put a field back, the run keeps
the state the API took, because throwing the resource away costs an app on a shared workspace and blames
the discarded drift on later fields. The baseline keeps those later verdicts honest, but the config they
deploy still carries the field, so it rides along in their update masks -- and nothing said so. A
BASE_SHIFTED row now names the field, which puts the caveat in the golden where a reader comparing two
types' reports will see it. Three rows: apps' description, pipelines' budget_policy_id, and
postgres_endpoints' autoscaling_limit_min_cu -- the same fields the CANNOT_DELETE and UPDATE_MASK_OMITTED
rows already name, which is the consistency check on it.

Co-authored-by: Isaac
The cloud sweep's first divergence, and exactly the kind this suite exists to find: a real workspace
refuses to clear display_name, query_text, or either half of a schedule -- "Field 'display_name' is
required, expected non-default value (not "")!" -- on update as much as on create, while the fake server
took all four. Sixteen alert rows read OK locally and CANNOT_DELETE_REQUIRED on aws.

The fake server now validates them, so both sides agree on CANNOT_DELETE_REQUIRED. A schedule stays
optional; an alert that has one has to fill both of its fields, there being no such thing as a schedule
without a cron expression or a timezone. Message shape copied from the postgres handler, which already
models this backend's wording.

Also widens the summary's verdict column from 18 to 26, the length of the longest verdict name -- the
counts had stopped lining up as soon as one of the new names appeared.

Co-authored-by: Isaac
From the serial cloud sweep, each found by aws and the fake server disagreeing:

experiments: the backend refuses a create or update with no name -- "An experiment name must be
provided" -- while the fake server's update silently skipped an empty NewName, which is the
UPDATE_IGNORED those rows used to read. Now refused on both sides, and the four rows say
CANNOT_DELETE_REQUIRED. That message also exposed a gap in the classifier's phrase list: it knew "must
be supplied" but not "must be provided", so the rows landed in BACKEND_ERROR. Added, with the service
that produced it noted beside the rest.

clusters: dependency_mode needs DBR 19 and the fixture runs the default spark version, which is DBR 13.
Skipped rather than raising the runtime for one field, which would change what every other field of the
type is measured on. The fake server has no DBR notion, so all six rows read OK locally.

genie_spaces: title is omitempty in the update request, so clearing it never reaches the backend. Every
transition the suite records as starting from absent actually starts from the previous value, which
makes the from-labels fiction and the verdicts order-dependent -- aws and the fake server disagree on
four of six rows without either being wrong. Skipped, because a field that cannot be cleared has no
starting value the suite can establish. I also had the fake server drop title updates to match what aws
showed, then reverted it: aws applied one of the six, so "never applied" is not something I can claim.

Co-authored-by: Isaac
The cloud sweep's remaining eight divergences are all one fake-server gap: it accepted an empty name (or
other identity value) where the API refuses one, so clearing such a field read OK locally and failed on
aws. Each message below is the backend's own, taken from the sweep's logs rather than guessed:

  jobs             environment key charset, git_source.git_url, a nameless job parameter, and a
                   periodic trigger interval below 1. Extends the existing git-provider check into one
                   validateJobSettings covering the settings a job is created or reset with.
  pipelines        a deployment block with no kind
  schemas          empty name or catalog_name, in Unity Catalog's "is not a valid name" wording
  volumes          same, plus volume_type, which CreateVolume requires
  secrets          same wording again, so it reuses the helper schemas introduced
  serving endpts   the 63-character alphanumeric name constraint
  vector search    the 50-character lowercase one (with the backend's doubled space, kept verbatim)
  secret scopes    "Scope name must be non-empty and at most 128 characters!"

The verdicts these produce stay BACKEND_ERROR and BASE_ERROR rather than CANNOT_DELETE_*, and that is
right: the API is complaining that the value it was sent is invalid, not that the field is required.
Reading requiredness into "must contain less than 50 characters" would be inference, not evidence. Where
the backend does say required -- CreateVolume's volume_type, pipelines' deployment.kind -- the rows say
CANNOT_DELETE_REQUIRED on their own.

Two corrections along the way. I first put the secret-name check in libs/testserver/secrets.go, which
serves scope-based secrets; these are Unity Catalog secrets on a different route, so that edit was
reverted and redone in uc_secrets.go. And a blanket string replace briefly dropped two of the four job
checks from the create path by rewriting the SubmitRun call site and JobsCreate identically.

Co-authored-by: Isaac
The testserver now refuses a volume without one, the way CreateVolume does, and this fixture builds a
volume from a bare struct. Same shape as the sql_warehouses fixture fix earlier on this branch: teaching
the fake server a required field breaks every test that was relying on it not being required.

Mine to fix, and mine for not catching sooner: after adding that validation I re-ran the autotest package
and the acceptance suite but not ./bundle/..., so this sat red locally until the rebase made me run
everything.

Co-authored-by: Isaac
…_version repl

#6549 normalized plan_version in every recorded plan, including this test's golden, and its version
replaced the one this branch had regenerated. The golden then expected an email_notifications block the
branch's testserver no longer produces at create. Regenerated, so it carries both: main's
[PLAN_VERSION] repl and this branch's create behaviour.

Co-authored-by: Isaac
The SDK bump (#6448) swapped BranchSpec's ttl (a *duration.Duration the catalog cannot move a generic
value through, so it was never covered) for source_snapshot (a string). The catalog walks the new field
automatically -- six OK_RECREATE transitions, a branch being recreated when it changes -- which is the
coverage a new settable field is supposed to get. No other type's golden moved.

Co-authored-by: Isaac
#6084 refactored the immutable folder upload into a direct-engine resource, adding
internal_immutable_snapshots to SupportedResources. The drivenTypes guard demanded a fixture for it --
which is the guard working, a new supported type must be covered -- but this one is marked
bundle:"internal" on config.Resources: an engine construct with no user-authored config, the same
category as permissions and grants. So it is excluded the same way, by the bundle:"internal" tag rather
than a name-prefix guess, reading it off config.Resources with structwalk the way resources_test.go does.

Co-authored-by: Isaac
…edules

Two things from the latest main:

dstate.Open gained WithDeploymentHistory and OpenDmsArgs (#6094). The harness passes
WithDeploymentHistory(false) and a zero OpenDmsArgs{} at both call sites -- this suite drives a local
state file, not a recorded DMS deployment, the same as bind.go's callers.

The PyDABs codegen generated postgres_snapshot_schedules, and drivenTypes demanded a fixture for it --
the new-resource guard working. Added one, local_only like the rest of the postgres family (the project
holds its name until a soft delete is purged, so a recreate cannot rebuild the chain). The schedule
attaches to the project's implicitly-created "production" root branch; the backend refuses any other
("not allowed to snapshot non-root branch"), so the fixture follows
acceptance/bundle/resources/postgres_snapshot_schedules/basic rather than creating its own branch.

Co-authored-by: Isaac
…lan fix

#6589 made the direct engine match recreate rules bidirectionally, so a change to a nested field inside
a block covered by a recreate rule is now caught. Seven ingestion_definition.* transitions on pipelines
move OK -> OK_RECREATE as a result -- connector_type, the data_staging_options catalog/schema, and
several table_configuration leaves. Both are passing verdicts; this records the improved classification.

Co-authored-by: Isaac
The cloud sweep's first divergence. A real workspace refuses to clear deployment.kind ("Missing required
field: new_settings.deployment.kind") or trigger.periodic.unit; the fake server accepted both, so eight
job rows read OK locally and CANNOT_DELETE_REQUIRED on aws. DABs stamps deployment.kind=BUNDLE on every
job it deploys, which is why the field is effectively always present and clearing it is refused -- so
even the absent<->value rows fail, at the step that tries to reach absent.

validateJobSettings now rejects a deployment block with no kind and a periodic trigger with no unit,
with the backend's own message and the field-path prefix it uses (empty on create, new_settings. on
reset). Both sides now agree on the eight CANNOT_DELETE_REQUIRED rows.

Co-authored-by: Isaac
The cloud sweep's second divergence. A real workspace refuses a tag with an empty key ("Endpoint tag
keys must be defined."), so clearing tags[0].key fails; the fake server stored the keyless tag, and four
rows read OK locally and BASE_ERROR/BACKEND_ERROR on aws. PatchTags now rejects an empty AddTags key with
the backend's message -- the engine wraps it as "failed to update tags: ...". Both sides now agree.

Co-authored-by: Isaac
The cloud sweep's third divergence. endpoint_type is required on create ("endpoint_type is required."),
so clearing it is refused; the fake server stored a typeless endpoint, and four rows read OK_RECREATE
locally and CANNOT_DELETE_REQUIRED on aws. Create now rejects an empty endpoint_type with the backend's
message. Both sides now agree.

Co-authored-by: Isaac
The cloud sweep's fourth divergence, and the narrowest: the message matched but the fake server returned
the 400 with no error_code, so the row read (400 <nil>) locally against (400 INVALID_PARAMETER_VALUE) on
aws. Added the code to the existing response. Both sides now agree.

Co-authored-by: Isaac
The cloud sweep's fifth divergence, and the largest (160 rows). An ingestion pipeline must name exactly
one source -- connection_name, ingestion_gateway_id, or ingest_from_uc_foreign_catalog -- and a real
workspace refuses one with none ("Found unsupported source option ... unsupported source option:
Empty"). The base seeds no ingestion_definition, so every leaf the catalog set built a sourceless block:
the mock server stored it (OK/OK_RECREATE) while aws rejected it (BASE_ERROR/BACKEND_ERROR).

All three source options need UC infrastructure the suite does not provision, so no leaf under
ingestion_definition is deployable on cloud. Skipped the whole subtree with ingestion_definition.*,
subsuming the three source-field skips that were already there for the same underlying reason. Covering
it needs a fixture that provisions a real ingestion source.

Co-authored-by: Isaac
The cloud sweep's sixth divergence. A uc_trace_location, once present, has to name both its catalog and
schema; a real workspace refuses either cleared ("trace_location.uc_trace_location.catalog is
required."). trace_location is set only at create, so the engine recreates to change it -- which is why
these read OK_RECREATE locally, where the fake server accepted the sourceless block, and
CANNOT_DELETE_REQUIRED on aws, where the recreate's create fails. ExperimentCreate now enforces both.

Co-authored-by: Isaac
The cloud sweep's seventh divergence, in two parts, both in AppsCreateUpdate (the POST /update path the
engine uses).

Clearing a masked field was ignored. The handler only set fields present in the update body and never
cleared one absent from it, and it unmarshalled the merged map back into the old struct -- where
json.Unmarshal leaves an absent field untouched. So clearing description or user_api_scopes kept the old
value (UPDATE_IGNORED locally) where aws applies it (OK). The merge now deletes a masked field absent
from the body and unmarshals into a zero value, so a cleared field really clears. The map is built from
the raw body, since re-marshalling the SDK struct would drop an explicitly-empty field.

That then exposed the second part: compute_size is a backend default (MEDIUM), re-stamped by the platform
after any update, but only AppsUpsert applied it, not AppsCreateUpdate. With clears now honoured,
compute_size went empty on update and the engine's backend_default/remote_already_set suppression stopped
firing -- OK locally where aws is SUPPRESSED. AppsCreateUpdate now re-applies the same defaults after the
merge. apps matches aws row for row.

Co-authored-by: Isaac
The latest main generated the model_services resource, and drivenTypes demanded a fixture -- the
new-resource guard working. Added one modelled on acceptance/bundle/invariant/configs/model_service.yml.tmpl:
parent references the pre-existing main.default schema, model_service_id is the other half of the
immutable id, comment is the settable scalar. The parent id-field rows show the standard invalid-name
BASE_ERROR a generic value produces, the same as other name-composed id fields.

Co-authored-by: Isaac
Cloud verification of the new model_services resource surfaced two fidelity gaps the fake server had.

A model service must be created with a config block -- the backend refuses one without ("config is
required") -- so base seeds a routing config to a pay-per-token system foundation model
(models/system.ai.databricks-gpt-5), which needs no workspace infrastructure. And its destinations
cannot be emptied once set: the backend refuses it and points at DeleteModelService instead. The fake
server accepted both, so the config subtree read OK locally where aws rejects it. Create now requires
config, and update refuses a change that leaves no destination, both with the backend's own messages.

The cloud run that surfaced this was also noisy with 499 CANCELLED from a degraded workspace (clusters
could not provision at the time either); the destinations rows that came back cleanly match this golden,
and model_services wants a clean cloud re-verification once the workspace is healthy.

Co-authored-by: Isaac
…sing

Refines the previous commit. Four acceptance tests create a model service with no config block, so
requiring config on create broke them -- reverted, the catalog fixture carries its own config and does
not need the fake server to enforce that. And the destinations check is now conditional: it fires only
when an update would empty destinations a service already had, leaving a service that never had a config
block untouched. The catalog golden is unchanged (its base seeds destinations, so clearing them is still
refused), and the model_services + bind acceptance tests pass.

Co-authored-by: Isaac
Both are my own fidelity fixes whose goldens the rebase left behind, not behaviour changes here:

- models/readplan-permissions: 03448f62c made the testserver reject an empty MLflow description and
  changed the script to modify the description rather than remove it, but the output.txt kept the old
  "and description" title. Regenerated to "change description".
- model_serving_endpoints/update/email-notifications: 9d55fe09d made a serving endpoint's create not
  echo email_notifications (matching aws), so the read right after deploy is null, not the seeded value.
  Regenerated.

Co-authored-by: Isaac
…the generator change

#6618 expands reused nested types per-path in the direct resources generator, which changes the field
paths the catalog enumerates for these two types. Both are local_only, so this is their local truth --
verdict counts shift (database_instances' COLLATERAL_DRIFT findings actually decrease, external_locations
stays finding-free), no new problems, deterministic. Regenerated to match the new structure.

Co-authored-by: Isaac
@denik
denik force-pushed the denik/extensive-test branch from d34b72b to 0528be3 Compare September 11, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants