Skip to content

draft(gcp): DiskAsyncReplication, and the plan-time reference gap that blocks it - #166

Draft
naxty wants to merge 2 commits into
mainfrom
naxty/fixAsyncDisk
Draft

draft(gcp): DiskAsyncReplication, and the plan-time reference gap that blocks it#166
naxty wants to merge 2 commits into
mainfrom
naxty/fixAsyncDisk

Conversation

@naxty

@naxty naxty commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Draft, not for merge. It parks the work for a resource type that cannot pass conformance in any shape the plugin can take, and states what the agent needs to do so it can. #172 withdraws the type from main meanwhile; merging this re-adds it.

The resource

GCP::Compute::DiskAsyncReplication is not a GCP API resource — no GET returns one. It is the relationship between two ordinary disks in different regions, started and stopped with the startAsyncReplication / stopAsyncReplication verbs on the primary. The plugin invents the resource, its two properties and its native ID:

projects/{p}/zones/{primaryZone}/disks/{primary}/asyncReplication/{secondaryZone}/{secondary}

Its two properties are the disks the pair joins. Both are immutable — which disks a pair joins is fixed at creation, and is exactly what the native ID is made of — and both are declared as references, because that is how a forma names another resource:

new diskAsyncReplication.DiskAsyncReplication {
  primaryDisk = primary.res.selfLink
  secondaryDisk = secondary.res.selfLink
}

The defect

The extract step exports the live resource to a forma and re-applies it. That must be a zero-operation apply:

[Extract] Re-applying the extracted forma should be a zero-operation apply, but
the agent plans changes: delete plugin-sdk-test-disk-async-replication (GCP::Compute::DiskAsyncReplication);
create plugin-sdk-test-disk-async-replication (GCP::Compute::DiskAsyncReplication)

An extracted forma writes each field as a reference that is unresolved at plan time:

DBG No resolved value set for ref uri=formae://3IX0aOrpzWkOGsGkCF6oQFls9LQ# targetPath=primaryDisk
DBG No resolved value set for ref uri=formae://3IX0aNkzy1pDMUCNcSrDyQKhxoH# targetPath=secondaryDisk

So the planner compares a $ref with no $value against the URL in state, on a createOnly path, and correctly concludes an immutable field changed. The replacement is the planner behaving properly on a comparison it should never have been asked to make.

Why it surfaced on 2026-08-27

when what
Aug 27, 07:10 PDT plugin 6d13459 hints both fields writeOnly alongside createOnly. formae dropped such a field from the desired side before diffing, unconditionally, so nothing was compared.
Aug 27, 15:10 PDT formae f0b5e61e narrows that strip: dropped only when the stored document holds no value at that path, so a genuine change to such a field is no longer silently ignored. Sound change — 386 fields across published schemas carry both hints.
the plugin's Read reported both fields back as disk URLs (the native ID has them). That put a value at both paths, so the fields stayed in the comparison — and the plain string also displaced the stored reference envelope whose $applied record is how the planner recognises an unresolved reference as unchanged.
Aug 28, 05:37 nightly red; CI green on the identical plugin commit c14ab919. CI runs released formae 0.89.0, the nightly builds formae main.

The bind, measured

run plugin shape CRUD discovery
33145336853 writeOnly, Read reports the pair ❌ extract
33154860464 createOnly only, Read reports the pair ❌ extract
33153366449 writeOnly, Read reports {} ✅ 8/8 ❌ discover
33155568420 writeOnly, Read reports nothing ✅ 8/8 ❌ discover

All four against formae main. Two findings:

  1. createOnly alone is not enough. Run 33154860464 drops the writeOnly hint entirely and still fails the extract re-apply, so plan-time reference resolution does not reach this case even after the Aug 27 resolver work (085e8181 "plan-time references resolve against effective desired state", 274e782b "follow reference chains to the value the chain root will hold").
  2. Suppression costs discovery. With the fields kept out of the diff, a discovered pair never reaches the inventory:
Validation of required fields failed error="resource projects/…/asyncReplication/… of type
GCP::Compute::DiskAsyncReplication is missing required fields: [primaryDisk secondaryDisk]"

Reporting nothing at all rather than {} does not dodge that either — the resource still arrives at the persister with an empty property set (internal/metastructure/resource_persister/resource_persister.go:181).

A run with phases=both is one make invocation with two targets, so a CRUD failure stops the discovery phase before it starts — which is why the first two rows have no discovery result.

What the agent needs to do

1. Resolve a plan-time reference to the value the target already holds. The desired side is a $ref with no $value; state holds the URL that same reference resolved to when the resource was created. Resolve it and no plugin hint is needed at all: plain createOnly, Read reports the pair, discovery keeps its properties, and extract → re-apply is a zero-op by ordinary comparison. This is the fix that makes the type shippable.

2. Stop requiring a writeOnly field on a resource that came from a Read. A writeOnly field can never be returned by a Read, so requiring one makes every resource carrying it undiscoverable — today that reads as a two-minute discovery timeout with the reason only in a debug line. This is smaller than (1), is not GCP-specific (386 fields across the published plugin schemas carry writeOnly), and would unblock the suppression route as a stopgap.

Either one lands and this branch becomes mergeable; (1) makes it a two-line hint removal instead.

What is on this branch

  • 2b13349Read stops echoing the write-only pair, honouring the hint rather than contradicting it. CRUD 8/8 against formae main.
  • c15bc04Read reports absent properties rather than {}, an attempt at the discovery half that the run above shows is not sufficient.
  • the formae_branch input for debug-conformance, which lands separately via feat(gcp)!: withdraw DiskAsyncReplication until the agent can resolve a reference at plan time #172. Without it a debug run installs the released formae and cannot reproduce a nightly-only failure at all.

The honest framing for whatever eventually lands in the plugin: it suppresses a diff the planner cannot compute, not a field the API declines to return. GCP does report the pairing — the secondary disk carries asyncPrimaryDisk.disk and resourceStatus.asyncPrimaryDisk.state, and Read parses both to decide the pair is live. primaryDisk and secondaryDisk are plugin-invented names whose values were synthesised from the native ID. We are choosing not to report them.

naxty added 2 commits August 28, 2026 09:56
…nly pair

The nightly's disk-async-replication case fails the extract re-apply while CI on
the same commit passes: CI runs the released formae, the nightly builds formae
main, and formae main narrowed the strip these two fields relied on. A field
hinted writeOnly + createOnly used to be dropped from the desired state
unconditionally; it is now dropped only when the stored document holds no value
at that path, so a genuine change to one is no longer silently ignored.

DiskAsyncReplication's Read reported primaryDisk and secondaryDisk - both
declared writeOnly - back as disk URLs, because the native ID already carries
them. That put a value at both paths, so the new condition kept the fields in
the comparison, and the extracted forma writes them as unresolved references to
the two disks. Unresolved reference against a stored URL, on a createOnly path,
plans a replacement of the pair already in place:

  delete plugin-sdk-test-disk-async-replication; create plugin-sdk-test-...

Write-only promises the agent that a Read never returns the field, and the agent
keeps the last-applied value in state on that promise. Honour it: Read reports
no properties and establishes only what a read of a relationship has to, that
the pair is live. Same treatment vpnTunnel's sharedSecret already gets.

A discovered pair now carries no properties, so extracting one no longer names
its disks - the native ID still does. That is the cost writeOnly always carries,
and the discovery case checks the pair is found, unmanaged and correctly typed,
none of which changes.

Also adds a formae_branch input to the debug-conformance workflow. Without it a
debug run installs the released formae and cannot reproduce a nightly-only
failure at all; with it the run builds formae from the given branch exactly as
the nightly does.
Read reported an empty object, and the agent validates required fields before
it persists a resource: primaryDisk and secondaryDisk are both required, so
every discovered pair was rejected and the discovery case timed out waiting for
it to appear. Absent properties skip that validation - what the pair is, is
still in its native ID.

Measured, not reasoned: run 33153366449 has crud 8/8 green against formae main
and discovery failing on "missing required fields: [primaryDisk secondaryDisk]".
A probe branch that drops the write-only hint entirely (run 33154860464) still
fails the extract re-apply, so the suppression is still needed and this is the
narrow way to keep discovery.
@naxty
naxty marked this pull request as draft August 28, 2026 08:53
@naxty naxty changed the title fix(gcp): stop an async replication pair reading back its own write-only pair draft(gcp): DiskAsyncReplication, and the plan-time reference gap that blocks it Aug 28, 2026
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.

1 participant