Skip to content

Sealed provider resources: every announced resource type is an interface #625

Open

Description

@David-Noble-at-work

Epic: #444 — The resource model. Plan: 625-sealed-provider-resources.md.

Every announced resource type is a sealed interface, implemented by an unexported struct. One rule, no exceptions: a provider resource can be produced only by the provider that declares it. file announces four such types (its kind axis); the other eight announce one each — one rule applied four times in one place and once in eight others.

This is a correctness change

The threat is not a struct literal somebody might one day write. No &<provider>.Resource{…} exists outside its own package anywhere in the tree — checked across all eight on 2026-08-24. The live threat is a path the framework already takes.

tryHydrateStruct (pkg/op/convert.go:441) is generic map→struct hydration inside op.Convert. Its guard admits any conversion whose source is a string-keyed map and whose target has concrete kind reflect.Struct. A resource slot declaring *service.Resource satisfies that today. So an author who supplies a dict where a resource is expected gets reflect.New(concrete).Elem() — a freshly minted service.Resource with its exported fields filled from the map.

The embedded op.ResourceBase has only unexported fields, so it stays zero and URI() returns "". The value is not merely unclaimed, it is identity-less — and it reaches pkg/op/provider/service/provider.go:50, which reads name.Name directly (20 such reads, none re-canonicalizing) and calls sm.Disable(name.Name) against the host. A host mutation performed for a resource the ledger never issued.

Every guarantee the resource model makes is bypassed on that path: claims are true when made (nothing was verified); identity is the catalog key (the URI is empty); a string is a key never a constructor (#586 refuses run-time construction, then a map does the same thing one layer down); the graph carries complete intent (an unclaimed resource is intent the document never recorded).

Sealing removes the path rather than policing it. The guard requires reflect.Struct. Once the slot type is an interface, tryHydrateStruct declines and Convert falls through to the registered constructor, which interns through the catalog. Nothing is left to remember to check.

file is exposed here too, and more quietly: its variants carry zero exported fields, so hydration fills nothing yet still forges and returns an identity-less *Regular.

Consistency is the dividend

Worth naming because it compounds: one rule with no exceptions is easier to teach, easier to learn, and therefore easier to onboard into and cheaper to maintain for as long as this codebase lives. A rule with one exception is two rules — and the exception is what every newcomer trips over.

What phase-1 investigation changed (2026-08-24)

The original framing rested on an enabling fact that is false: pkg/op/provider/<p>/gen is a distinct package from the provider (same package name, different import path), so a gen file can never name an unexported type. Ruling 4 gives the URI fragment to the interface rather than the struct — and because the interface takes the name the struct gave up, typeIDOf is byte-identical and the gen files need no changes at all. The alternative required an exported ResourceType() reflect.Type per provider, and reflect.New on that seam forges a resource, reopening the door this feature exists to close.

Ruling 5: the work splits in two parts, so the goal is actually reached — part 1 seals the eight single-resource providers, part 2 converts file's four variants. Stopping after part 1 would leave file a permanent exception in a new direction.

Ruling 6: file's variant interfaces are discriminated by kind() <Interface>. Regular and Directory have identical exported method sets, so as bare interfaces they would be structurally interchangeable and every kind-honesty guarantee from #616 would evaporate without a single test failing.

Tasks

Part Phase Task Scope
1 1 #641 framework repairs and service — the template
1 2 #642 git, appnet
1 3 #643 json, yaml, mem, function — the Unpacker four
1 4 #644 pkg — the widest footprint
2 5 #645 file's four variants, discriminated by kind()
— 6 #646 the rule becomes structurally enforceable
— 7 #647 closure — the design record states the contract

Supersedes #626–#630, closed as superseded.

Providers in scope: eight — appnet, function, git, json, mem, pkg, service, yaml — plus file's four variants. (The original said nine besides file; enumerating AnnounceResource call sites gives eight.)

Activity

  1. changed the title [-]Sealed provider resources: every <provider>.Resource is an interface[/-] [+]Sealed provider resources: every announced resource type is an interface[/+] on Aug 24, 2026
  2. David-Noble-at-work commented on Aug 25, 2026

    @David-Noble-at-work
    ContributorAuthor

    Found during this feature — not caused by it

    Sealing forces every assumption about a resource through the compiler, which turns up things that were already true but unexamined. Four defects surfaced so far. None was introduced by this feature, and each was checked rather than assumed:

    Issue What Why it is not ours
    #661 git.Resource decodes a document form it can never be written to — its unmarshalers read ref/head, and nothing has ever written them The decoders predate this work; #642 only read them closely
    #663 A promise passed to function.call's variadic args arrives unresolved, as an Invocation function is not sealed yet (#662, unstarted)
    #664 devlore-test run fails on 24 of 104 fixtures the Go harness passes Proved by running test_walk_tree_planned.star, untouched by this feature, and watching it fail identically
    #665 devlore-test run writes per-fixture artifacts into the working directory — 73 untracked files in one sweep Longstanding runner behavior

    They are labelled by subject rather than by discovery: #661 Epic:Serialization, #663 Epic:ExecutionGraph, #664 and #665 Epic:QualityCoverage. Each sits with the code that owns the fix, so none is scheduled behind this feature — #664 in particular is costing time now, to anyone running a fixture by hand.

    This table is the view from the other side: what the feature yielded, without claiming authorship of the defects.

    Also found, and fixed here, because they were ours

    Two defects this feature genuinely introduced, both caught before merge:

    • The generated announcement lost its method metadata when a provider was sealed — the generator inspected the exported type, and sealing moved the methods onto the unexported struct. Silent: a smaller Starlark surface, no build error. Fixed by The generator must derive resource method metadata from the implementation, not the interface #658, which became phase 2 precisely so the remaining phases would be safe by construction rather than by review.
    • The registry name collided. receiverName special-cases the type named Resource; a sealed implementation is named resource and yields the bare resource for every provider, so the second one announced panicked at init. service alone was fine — it took git beside it to surface. Fixed in Seal git and appnet #642.

    The distinction matters for reading this feature's history later: the first table is what sealing revealed, the second is what sealing broke and repaired.

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

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions