Skip to content

A value in an any-typed slot loses its concrete type at save; the document must carry it #712

Closed

Description

@David-Noble-at-work

Epic: #443 — Serialization and the single codec
Related: #711 (declared-type numbers), #709 (conversion rules)

A parameter declared any has no target type to read a serialized value against. Unlike #711, this cannot
be fixed by reading more carefully
— the information is destroyed at save, before anything reloads.

The mechanism

json.Marshal(float64(42)) emits 42. Go's encoder writes the shortest representation that round-trips as a
float64, and for an integral value that has no decimal point. So a Starlark float 42.0 landing in a
map[string]any is written to the document as 42, indistinguishable from an integer.

UseNumber on the way back in preserves the literal faithfully. The literal simply no longer says "float".

yaml.v3 marshals float64(42) as 42 as well, so this is not a JSON-specific defect.

It is live

any appears in parameters across nine files, including ones that certainly reach a serialized graph:

plan.Variable(name string, defaultValue any, field string)
plan.Plan(name op.ActionName, args []any, kwargs map[string]any)
template.RenderText(content string, data map[string]any)
function.Call(callable Resource, args []any, kwargs map[string]any)

plan.Variable's defaultValue is the clearest case: a declared default of 1.0 is written as 1 and
reloads as an integer.

Ruling

For a value whose slot is any, the document stores the type. There is no other option: with no
declared type at either end, the document is the only place the information can live.

This does not apply to a value whose parameter has a declared type. There the field says how to read the
number, which is what #711 implements — fs.FileMode wants an integer and the text answers. Only any slots
need the type written down.

What this opens

  • The envelope's shape. {"$type": "float", "value": 42} is the obvious form, and it changes every
    document containing an any slot. A narrower alternative — force a decimal point when marshaling a float
    into an any slot, so 42.0 stays 42.0 — is smaller but only covers numbers, and the same question
    arises for any other type whose identity JSON cannot carry.
  • Which types need it. Numbers are the case in hand. Strings, bools, lists, and dicts survive JSON
    unambiguously. Anything with a Go type that JSON flattens is a candidate.
  • Checksums and graph identity. Changing what a document contains changes the bytes. Whether it changes
    the canonical form is a separate question, and one JSON reload turns every authored integer into a float64, and the codecs disagree #711 is already having to answer.
  • Read compatibility. Greenfield, per the repository's governing principle, so old documents need no
    accommodation — but that should be a stated decision rather than an assumption.

Not blocking #711

#711 narrows to parameters with a declared type. any-typed slots keep today's behavior until this is
decided, so the two do not contend.

Acceptance

  • A float in an any slot reloads as a float
  • An integer in an any slot reloads as an integer
  • plan.Variable(default=1.0) survives save and reload with its type intact
  • The envelope is documented as part of the document format, not left implicit in the encoder
  • JSON and YAML agree

Plan and design documents

Kind Document
Plan docs/plans/fix/712-any-slot-types.md
Design docs/architecture/5-graph-trace-integrity.md
Design docs/architecture/2.1-typed-slots.md
Design docs/architecture/5.2-recovery-serialization.md

Epic: #443 · Feature: #458

Activity

  1. David-Noble-at-work commented on Aug 30, 2026

    @David-Noble-at-work
    ContributorAuthor

    Ruled: a node carries its graph's catalog

    There was no fork here. Nodes are constructed with the graph during planning, so a node carries the catalog
    its graph owns. I presented three options as though the design were open; it is not, and two of them were
    wrong for reasons the design already settles:

    Where it lands

    buildGraph (pkg/op/graph.go:214-216) already walks g.root.descendantNodes() to build unitsByID, and
    it holds resourceCatalog. That loop is where the graph takes ownership of its nodes, so it is where each
    node receives the catalog. No new traversal, no new seam.

    What it fixes

    encodeResource currently reads resource.RuntimeEnvironment().ResourceCatalog -- the resource's own
    environment, which in the executor tests has no catalog at all. It must read the graph's, as
    Graph.resourceCatalog's doc comment already requires: "every later session-owner (a Go-side
    GraphExecutor.Run, a serializer, an inspector) reads from resourceCatalog rather than from the
    long-gone planning environment."

    Three executor tests fail today on $resource ... has no catalog to name it, plus
    TestLoadGraph_AResourceInAnAnySlotReloadsAsAResource. All four are the same defect.

    Remaining work on the read side

    assembleUnits / assembleNode / assembleBindings currently thread env.ResourceCatalog. That is likely
    also wrong: the graph's own catalog is reconstructed by unpackCatalog from the document's resources
    section, and that -- not the ambient environment's -- is what a slot's id must resolve against.

    This is recorded here rather than in docs/plans/any-slot-types.md because the plan lives on the
    any-slot-types branch. It should be folded into the plan's design decisions when that branch is next
    checked out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Area:ArchitectureSystem architecture and designEpic:SerializationSerialization and the single codecPriority:P2Scheduled, not urgentSeverity:MediumBroken with a workaround, or a narrow pathbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions