Repository navigation
Sealed provider resources: every announced resource type is an interface #625
Description
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
[-]Sealed provider resources: every <provider>.Resource is an interface[/-][+]Sealed provider resources: every announced resource type is an interface[/+]David-Noble-at-work commentedon Aug 25, 2026
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:
git.Resourcedecodes a document form it can never be written to — its unmarshalers readref/head, and nothing has ever written themfunction.call's variadic args arrives unresolved, as anInvocationfunctionis not sealed yet (#662, unstarted)devlore-test runfails on 24 of 104 fixtures the Go harness passestest_walk_tree_planned.star, untouched by this feature, and watching it fail identicallydevlore-test runwrites per-fixture artifacts into the working directory — 73 untracked files in one sweepThey are labelled by subject rather than by discovery: #661
Epic:Serialization, #663Epic:ExecutionGraph, #664 and #665Epic: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:
receiverNamespecial-cases the type namedResource; a sealed implementation is namedresourceand yields the bareresourcefor every provider, so the second one announced panicked at init.servicealone was fine — it tookgitbeside 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.
pkg's resource is sealed, and the five package manifests take the sch…