Repository navigation
Method claims: deterministic, idempotent, sandboxed #677
Description
Epic: #530
Plan: docs/plans/method-claims.md
Design: 3.6-method-classification.md — superseded by this
issue's scheme; rewrite is step 26
The provider-level access directive was deleted from every file in the repository on 2026-08-25 — 80 references
across 38 files. Nothing replaced it. Namespace placement now falls to a hardcoded default for all nineteen
providers, which empties the plan.* namespace.
Measured 2026-08-25 with make test: 34 packages failing, 69 passing, 201 individual tests failing. Thirteen
of the thirty-four are */gen packages — the role regression exactly. This issue is what makes the working tree
committable.
The scheme: a claim namespace, not a classification enum
3.6 framed the replacement as three exclusive tiers — pure / query / action. Design review on 2026-08-25
rejected that framing on three grounds, each established against the tree:
actioncollided withop.Action. Every provider method is an action —op.Action,
op.FallibleAction, andop.CompensableActionare all actions.- The tiers were a proxy for hermeticity, and the proxy leaked both ways.
platform.os()reads a value
supplied to the runtime environment at construction (pkg/op/provider/platform/provider.go:71; the syscalls
happen once inDetect()), so it is a declared input.ui.print()narrates, changing nothing a plan reads
back. Both are hermetic; the tier vocabulary called themqueryandaction. - Mutation is not always compensable. Roughly eight methods mutate without returning a receipt —
elevator.elevate,git.checkout,git.pull,pkg.update,plan.run,plan.save_definition,
powershell.exec,shell.exec. Inference from a receipt return is sufficient but not necessary.
// +devlore:claim=deterministic, idempotent, sandboxed
type MethodClaims uint
const (
ClaimDeterministic MethodClaims = 0x0001
ClaimIdempotent MethodClaims = 0x0002
ClaimSandboxed MethodClaims = 0x0004
)| Claim | Asserts | Verified by |
|---|---|---|
deterministic |
output is a function of declared inputs alone | capability denylist + claim propagation; gates the build |
idempotent |
re-application converges to the same state | nothing statically — a double-application test obligation, riding #635 |
sandboxed |
all I/O routes through an fsroot.Dir |
any os reference not through fsroot.Dir; gates the build |
Comma-separated on one line, following the +devlore:defaults precedent (generate.star:178 already does
split(",") with per-element validation). Author order free; codegen emits sorted.
The check — denylist, not allowlist
No allowlist. An allowlist of safe packages is unbounded and rots on every new import. Measured across the
twenty providers: 34 distinct stdlib packages imported, so an allowlist would need ~26 entries and grow.
The denylist needs eleven, and is not ours to maintain — it enumerates the ways a Go program reaches outside
its own process, which the OS interface fixes:
crypto/rand, math/rand, net, net/http, os, os/exec, os/signal, os/user, runtime, syscall,
time
Everything else passes automatically and needs no entry, now or later: archive/tar, archive/zip, bytes,
compress/bzip2, compress/gzip, context, crypto/sha256, encoding, encoding/binary, encoding/hex,
encoding/json, errors, fmt, io, io/fs, maps, net/url, path, path/filepath, reflect, regexp,
slices, sort, strconv, strings, sync, text/template.
Two rules keep it list-free beyond that:
- Calls and function values are flagged; types and constants are not.
os.FileModein a signature passes;
os.O_CREATEin an argument passes;os.Getenvstored in aFuncMapdoes not. No per-symbol list is needed. - We never walk stdlib internals.
fmtimportsosforStdout, butfmt.Sprintfin provider code is a
reference tofmt, which is not denylisted. Sofmtneeds no exception.
Reach through our own helpers is claim propagation, not lookup: a claiming method requires every provider
method and local helper it calls to pass the same check. Induction, not enumeration.
fsroot.Dir is the one declared exception to os — asserted by hand in pkg/fsroot, never analyzed
through. That converts the hardest part of the analysis into a policy line.
Why it gates the build
The rejected design was an SSA call graph (x/tools/go/ssa + RTA or VTA). fsroot.Dir and platform.Platform
are interfaces, so RTA over-approximates every call through them to every implementation in the program — false
positives arising from analysis imprecision, which a developer cannot fix by editing their own code.
Under claim propagation plus a capability denylist, every false positive has a source edit that resolves it:
claim the helper, route the I/O through fsroot.Dir, or drop the claim. That difference is what makes a build
error the right response rather than a warning.
A gap in the existing tooling
goast.calls (cmd/star/provider/goast/provider.go:75) inspects *ast.CallExpr only. It would walk straight
past:
var renderFuncs = template.FuncMap{
"Env": os.Getenv,
}os.Getenv there is a function value, not a call — the one confirmed hermeticity violation in the catalog
(#683). goast.references is a hard requirement (step 14), not a refinement.
Steps
28 steps in six groups — see the plan. A the type in pkg/op (1–4), B the generator (5–8), C
registry and dispatch (9–11), D verifying the claims (12–19), E regenerate and prove (20–24), F the
record (25–28).
Step 8 is the crux: placement moves from provider-level to per-method, and a provider's role set becomes the
union over its methods. The retired directive's both value was never a provider property — it was the union,
spelled by hand.
Step 25 is a defect found en route: op.Action's doc comment (pkg/op/action.go:30) says "no side effects"
while holding platform's five and ui's five; op.FallibleAction (:41) says "has side effects" while
holding regex's eight and json/yaml's seven. The tiers measure fallibility and are documented as
measuring effects.
Decisions — all ruled
| # | Decision | Status |
|---|---|---|
| 1 | Syntax and vocabulary | Ruled — +devlore:claim= namespace; Bazel's vocabulary |
| 2 | Gate the build or warn | Ruled — gate. A false claim is a build error |
| 3 | Allowlist or denylist | Ruled — denylist. 11 capability packages against ~26 for an allowlist |
Ready to implement.
Related: #563 (per-runtime module selection — the hermeticity half), #571, #680, #683 (template.Env), #685
(pkg/fsroot vocabulary).
Activity
[-]Implement method classification: pure, query, action[/-][+]Method claims: deterministic, idempotent, sandboxed[/+]David-Noble-at-work commentedon Aug 25, 2026
The vocabulary ruled here has a follow-through in
pkg/fsroot, filed as #685 (Epic:QualityCoverage): the package uses three words for one concept — "confined" in its package doc and type names, "scoped" in theDirdoc, "scoping" in its plan title — and none of them issandboxed.Also ruled there: the constructor pair becomes
OpenExisting/OpenScratch. Both constructors are sandboxed, so the pair names the two lifetimes — the only axis they differ on.OpenConfinednamed the property and implied a distinction that does not exist.#685 is a rename with no semantic change, which is why it sits under QualityCoverage rather than here. It carries a hard sequencing constraint: it touches
pkg/op/and so must land before #451's serial window opens, or after it closes.docs: remove access-directive references; add the method-claims plans
chore: sandbox vocabulary, lint-gate parity, and the access-directive…
feat(op): per-method claims, surface eligibility, and the hermetic ru…
feat(op): providers claim what they guarantee, and a type-checked pas…
feat(op): claim propagation follows a method into the helpers it calls
fix(op): a graph is conceived once, and its identity never mentions when
docs(op): the record catches up with what shipped
fix(goast): two declarations named LoadSourceFile, one of which had t…
Providers claim what they guarantee, and a type-checked pass holds th…
ui takes the root namespace and replaces print and fail (#708)
docs(plans): ProviderFlags -- a Surfaces bit field and a Placement va…
feat(op)!: ProviderFlags packs a Surfaces bit field and a Placement v…