Skip to content

Method claims: deterministic, idempotent, sandboxed #677

Closed

Description

@David-Noble-at-work

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:

  1. action collided with op.Action. Every provider method is an action — op.Action,
    op.FallibleAction, and op.CompensableAction are all actions.
  2. 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 in Detect()), so it is a declared input. ui.print() narrates, changing nothing a plan reads
    back. Both are hermetic; the tier vocabulary called them query and action.
  3. 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.FileMode in a signature passes;
    os.O_CREATE in an argument passes; os.Getenv stored in a FuncMap does not. No per-symbol list is needed.
  • We never walk stdlib internals. fmt imports os for Stdout, but fmt.Sprintf in provider code is a
    reference to fmt, which is not denylisted. So fmt needs 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

  1. changed the title [-]Implement method classification: pure, query, action[/-] [+]Method claims: deterministic, idempotent, sandboxed[/+] on Aug 25, 2026
  2. David-Noble-at-work commented on Aug 25, 2026

    @David-Noble-at-work
    ContributorAuthor

    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 the Dir doc, "scoping" in its plan title — and none of them is sandboxed.

    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. OpenConfined named 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.

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:ExecutionGraphThe execution graph epicPriority:P0Now -- ahead of feature workSeverity:CriticalData loss, corruption, or a security failurefeatureDeliverable unit within an epic

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions