Skip to content

Providers claim what they guarantee, and a type-checked pass holds them to it - #697

Merged
David-Noble-at-work merged 13 commits into
developfrom
seal-mem-and-function
Aug 27, 2026
Merged

David-Noble-at-work merged 13 commits into
developfrom
seal-mem-and-function

Conversation

@David-Noble-at-work

Copy link
Copy Markdown
Contributor

Closes #677. Also closes #669, #686, #687, #690.

make check exits 0 — 104 packages, no lint issues. Every claim verified against host, darwin, linux, and
windows build tags.

What this is

A provider method now states the guarantees it makes, and two checks hold it to them. The work started as
"remove the +devlore:access= directive" and became "what should have replaced it," because the directive was
vague in a way that could not be repaired: its role assignment cannot be reproduced from any property of the
code. Four providers with zero mutators were both; two with zero mutators were immediate. Nothing
distinguished them but a judgment nobody wrote down.

// +devlore:claim=deterministic, idempotent, sandboxed
Claim Asserts Checked by
deterministic output is a function of declared inputs alone pkg/op/claimcheck, against the call graph
idempotent re-application converges nothing static — a test obligation (#691)
sandboxed all filesystem I/O routes through an fsroot.Dir pkg/op/claimcheck, against the call graph

38 claims across ten providers. file reaches complete coverage of its 25 announced methods.

Mutation is the one property NOT claimed: a method returning a receipt mutates by construction, and a signature
cannot drift without changing. Everything else is asserted, because a derived property flips silently when a
helper three calls down grows a dependency, where a false claim fails the build and names the method.

The commits, in order

ecf1fde6 mem and function sealed; function stops borrowing another provider's identity (#662)
361260a3 the access directive leaves the documentation; the method-claims plans arrive
eeadf3f5 fsroot speaks one word (sandbox); the local lint gate matches CI; the directive leaves the code
7b05b38c op.MethodClaims, +devlore:surface=, and the hermetic runtime filter
b202640f pkg/op/claimcheck — one type-checked load of the module, gated as a conformance test
54ade79f propagation follows a method into the helpers it calls
684e9d1a a graph is conceived once, and its identity never mentions when (#690)
(final) the record: 3.6 rewritten, op.Action's doc comments corrected, the catalog regenerated

The branch name predates all of this. It describes ecf1fde6, the smallest of the eight.

Why the claims earned their keep

Every one of these was invisible until something asserted a property that could be contradicted.

Found Status
file.Glob used filepath.Glob — absolute patterns honored verbatim, .. surviving Join's clean, symlinks followed out of the tree fixed (#687)
walkDir fell back to filepath.WalkDir whenever no root existed, so Find and WalkTree were confined only when a root happened to be there fixed (#687)
applyOwnership called os.Chown while fsroot.Dir has one — the only file mutation leaving the sandbox fixed
Every graph checksum embedded time.Now(), so no two identical plans agreed fixed (#690)
Four methods read machine-local config while planning open (#694)
template.Env reads the ambient environment open (#683)
Four dead nil guards on an interface that can never be nil fixed (#669)
star lint go reported "not tidy" for any uncommitted go.mod edit, gating the commit that would clear it fixed (#686)

Two things a reviewer should know

Interface dispatch is not followed and cannot be. action.Do(...) resolves to an interface method whose
implementation dispatches by reflection, so no static walk reaches what it runs. Propagation catches a helper
that reaches a capability and never one reached through dispatch. A claim remains an assertion the author is
accountable for, not a proof
— plan.Run carries no claim because it was withheld, not because anything
verified it.

Two trust boundaries are declared and never walked through. pkg/fsroot IS the sandbox, so its own body
calls os.OpenRoot; walking it would report the sandbox as escaping itself. pkg/assert reaches
runtime.Callers only while raising — the abort path of a failed assertion.

Notable non-obvious choices

A denylist, never an allowlist. Eleven capability packages, enumerating the ways a Go program reaches outside
its own process — which the OS interface fixes, so the list is not ours to maintain. An allowlist of safe
packages would need ~26 entries today and grow with every import.

Types, not syntax. os.Getenv and os.FileMode are one shape to a parser, and os.FileMode(mode) is a
call despite being a conversion. Ten methods in file reference os.FileMode, os.O_CREATE, os.O_TRUNC,
os.ErrNotExist, or os.ModeSymlink; a syntactic scan flags every one. file.Copy carries four and passes
clean.

A separate pass, not a codegen step. One type-checked load (~6s) rather than one per provider, and it runs
where verification belongs rather than where metadata is generated.

Dependency

golang.org/x/tools becomes direct — go.sum already carried it transitively. go.mod moves to 1.27 and CI
follows, so both sides analyze with the same semantics. The upgrade pulled a newer x/net, which deprecates
h2c; suppressed with a reason and filed as #689, because the documented fix needs an http.Server that
nothing constructs yet.

Follow-ups filed

#683 · #689 · #691 · #694 — and #679, #678, #680 from the catalog audit that started this.

David Noble added 8 commits August 25, 2026 11:45
The provider-level access directive is gone from the documentation. Provider design
docs lose it from their header lines; the directive tables in 3.1-provider-loading,
3.2-projected-provider-api, and 3-operation-namespaces are removed; and the guides
stop instructing developers to write it. Plan documents keep their historical prose
with the literal token replaced.

New plans, one per defect found while verifying the provider catalog:

  elevation-provider-rename.md  #679  6.1 settled the name; the tree never received it
  method-claims.md              #677  the replacement scheme: claims, not tiers
  regex-provider-naming.md      #678  the code renamed to regex; the docs did not
  template-purity-claim.md      #680  documented pure, reads the host environment

method-classification.md becomes method-claims.md: the scheme is a claim namespace
over deterministic, idempotent, and sandboxed, not a three-tier classification.

The Go and generated changes for the elimination stay uncommitted. The tree is red
until #677 lands -- 34 packages failing, 201 tests -- because nothing supplies
namespace placement yet.
… code removal

Several changesets, kept together because the tree cannot go green until #677 lands
and splitting them would leave every intermediate commit red for a different reason.

The access directive, code side (#677 supplies the replacement)
  The +devlore:access directives are gone from the provider sources, generate.star
  hardcodes the documented default, and every *.gen.go is regenerated from it. This
  is what makes the tree red: all 19 providers now announce RoleModule only, so the
  plan.* namespace is empty. 34 packages, 201 tests. The 10 new module.gen_test.go
  files are generator output for providers that previously had no module side.
  goast's production_test.go carried the directive as fixture text rather than as an
  annotation, so it moves to +devlore:root=true.

fsroot speaks one word: sandbox (#685)
  OpenConfined becomes OpenExisting -- both constructors are sandboxed, so the pair
  names the two lifetimes, the only axis on which they differ. confinedDir and
  confinedScratchDir become sandboxedDir and sandboxedScratchDir. The package doc
  said "confined", the Dir doc said "scoped", and the plan title said "scoping";
  all three are now "sandboxed", the term Bazel uses for the mechanism. The
  `// Confinement:` convention becomes `// Unsandboxed:`, which names what those 11
  comments actually mark: a deliberate bypass, with its reason.

  4.5-fsroot-variants.md is retitled to match, and env-minted-root.md gains a
  supersession note -- it is marked complete while describing a Mode type and three
  constructors that appear in zero Go files.

The dead nil guards are gone (#669)
  graphMetadata.origin is typed OriginBase, a concrete value, so Graph.Origin()
  never returns nil and staticcheck was right about all four SA4023 findings. The
  guards are removed and the invariant is documented on Origin() itself, so the next
  reader does not re-add them.

The local gate matches CI (#670, partial; #686)
  go.mod moves to 1.27 and CI follows, so both sides analyse with the same semantics.
  golangci-lint is pinned to v2.13.1 on both sides -- the skew against CI's v2.12.2
  is what hid those four findings from CI. make lint now runs `star lint go`, the
  gate CI actually carries, rather than a bare golangci-lint.

  Routing through star exposed #686: its tidy check ran `go mod tidy` and then
  diffed against HEAD, so it could not tell a change tidy made from one the caller
  made -- every uncommitted go.mod edit failed, and the check gated the very commit
  that would clear it. It now compares against a pre-tidy snapshot and restores the
  originals on mismatch, so it reports without rewriting the tree.

make lint and make vet are green. make test is not, and stays that way until #677.
…ntime filter

Phases A, B, and C of #677. make check exits 0 -- 103 packages passing, zero
failures, down from 34 failing packages and 201 failing tests.

The type
  op.MethodClaims is a bit set over ClaimDeterministic, ClaimIdempotent, and
  ClaimSandboxed. It rides the rails MethodModifiers already used: codegen emits it
  onto MethodMetadata, the announcement path stamps it onto the Method. Claims are
  asserted and checked, never derived -- a derived property flips silently when a
  helper three calls down grows a dependency, where a false claim fails the build.

  Method.Mutates() is the deliberate exception: kind == MethodCompensableFunction,
  covering both *Receipt and *RecoveryStack returns. Sufficient, not necessary --
  shell.exec and git.pull mutate without returning either -- and its doc says so.

The directive
  +devlore:claim=a, b, c follows the +devlore:defaults precedent: comma-separated on
  one line, sorted on the way out so author order never churns a regenerated file.
  Codegen refuses a deterministic claim on a mutating signature and quotes it back:

    method Mkdir: claims deterministic but returns "(*Directory, *Receipt, error)",
    which mutates by construction

  regex.Match carries the first real claim in the tree.

Surface eligibility
  The access directive was vague and its role assignment could not be reproduced from
  any property of the code -- four providers with zero mutators were "both", two with
  zero mutators were "immediate". +devlore:surface= replaces it and maps straight onto
  the existing dispatch zone: graph is RoleAction alone, module is RoleModule alone,
  absent is both.

  RoleModule now means ELIGIBLE FOR a module surface, not "immediate mode". Two
  providers declare, and they are opposites: flow is graph-only (its methods ARE the
  graph combinators), plan is module-only (no scenario for planning the planner). The
  other sixteen are both; mem announces nothing, having no Provider.

  Consequence worth naming: ui and star's lint provider were immediate-only before and
  are now eligible for both, so each gains action_names.gen.go and gen/action.gen_test.go.

The runtime filter
  Hermetic() is a RuntimeOption. admits(method) is `!hermetic || claims deterministic`
  -- per METHOD, never per provider, because a hermetic runtime wants file.join and not
  file.write_text and no bit on the provider can say that.

  The filter is consulted at install time rather than through applyDenials, so it
  reaches both placement paths: a non-root provider is wrapped in a filteredReceiver
  carrying the refused set, a root provider's install loop skips a refused method.
  DenyAttributes is untouched -- caller-driven narrowing stays a separate concern.

  Two zero cases, deliberately different: a provider with no methods is skipped in
  silence (elevation while dormant, mem), while a provider whose every method is
  refused fails loudly at construction rather than as a NameError later.

Retired
  access_title and the descriptor's "pure" field, both written and read by nothing.
  "pure" computed `"error" not in m.returns` -- infallible, not effect-free, the same
  conflation op.Action's doc comment still carries.

Regeneration was verified to have actually run: 103 codegen invocations counted, not
inferred from an exit code. Step 15 (wiring lore hermetic) waits on step 14: only
regex.Match claims anything today, so lore would trip the loud failure on every
provider it selects.
…s holds them to it

Steps 14 through 20 and 22 of #677, plus two sandbox escapes the annotation pass
found. make check exits 0 -- 104 packages. Closes #687.

43 claims across ten providers
  deterministic: regex (8), plan's construction surface (8), ui (6), platform (5),
  json (4), file's path algebra (4), yaml (3), function.call (1).
  sandboxed: file's 21 I/O methods, archive (2), encryption (2).

  file reaches complete coverage -- all 25 announced methods carry a claim.

  Four plan methods are deliberately excluded, and two of them have clean bodies:
  Spec calls fsroot.OpenExisting to probe the root, and Run builds an executor and
  runs the graph. Everything they REACH is non-deterministic -- the claim-propagation
  case arriving early, and proof that a body-only check would pass a false claim.

  function.call claims deterministic because a Starlark callable is evaluated against
  the same filtered surface: in a hermetic runtime it cannot reach anything the
  runtime did not admit. Hermeticity inherited, not inspected.

lore plans hermetically
  starlarkbridge.Hermetic() at builder.go:610. star passes no such option: it is a
  scripting tool, where effects are the point.

The loud-failure rule is reversed
  A fully refused provider used to fail runtime construction. That made selecting
  every module impossible under hermeticity -- and select-all is the POINT of a
  per-method filter, since a caller should not have to curate the list. The provider
  now installs with every attribute denied, so the author hears about it where they
  reached for it:

    shell.exec is not available in this runtime: this runtime is hermetic and the
    method makes no deterministic claim

  That names the method. A construction-time failure named only the provider.

Two selection reports
  Runtime.reportSelection narrates what a module surface admitted, refused, and never
  considered; GraphExecutor.reportGraphReach narrates which providers the graph
  actually dispatches to. Planning reports what an author could reach, execution
  reports what the plan reached, and the difference is now visible.

  Both carry TODO(#507): they are diagnostics by 2.8's own test, and go to the
  narrator only because the DEBUG/TRACE API does not exist yet. 2.8 back-references
  them as a first consumer to design against.

pkg/op/claimcheck holds the claims to account
  A claim is an assertion, so something must be able to contradict it. claimcheck
  loads the provider tree once through x/tools/go/packages and resolves every selector
  against real type information:

    method plan.LoadDefinition: claims deterministic but calls os.ReadFile at
    provider.go:364

  Types are the whole point. os.Getenv and os.FileMode are one shape to a parser, and
  os.FileMode(mode) is a CALL despite being a conversion. Ten claiming methods
  reference os.FileMode, os.O_CREATE, os.O_TRUNC, os.ErrNotExist, or os.ModeSymlink;
  a syntactic scan flags every one, and the type checker correctly sees a type, three
  constants, and a sentinel. file.Copy carries four and passes clean.

  It runs as a conformance test, so make check gates it with no new command and no
  Makefile change -- and one load (6s) rather than one per provider during codegen.
  The checker is a library with a test on top, so a lint command can call it later
  without a rewrite.

Two sandbox escapes, found by annotating and fixed here
  file.Glob used filepath.Glob, which honored an absolute pattern verbatim, let `..`
  survive Join's clean, and followed a symlink out. It now matches over
  fs.Glob(root.FS()), where patterns are slash-form and root-relative by construction.
  TestGlob_RefusesPatternsOutsideTheRoot pins all three shapes.

  walkDir fell back to filepath.WalkDir whenever no root existed, so file.Find and
  file.WalkTree were confined only when a root happened to be there. The fallback is
  gone; it was not load-bearing.

Dependency
  golang.org/x/tools becomes direct (go.sum already carried it transitively). The
  upgrade pulled a newer x/net, which deprecates h2c -- suppressed with a reason at
  pkg/op/server/router.go and filed as #689, because the documented fix needs an
  http.Server that nothing constructs yet.
Step 20 of #677. A claiming method's own body was never the whole story: plan.Run
has a clean body and builds an executor that runs everything.

What it follows
  Direct calls into in-module functions, transitively, reporting the hop chain that
  led to a capability:

    method plan.AssembleDefinition: claims deterministic but reaches time.Now at
    graph.go:152, through op.NewGraph

  That found a real determinism break on its first run (#690), and AssembleDefinition's
  claim is withdrawn here until it is fixed.

Two boundaries, both deliberate
  pkg/fsroot is a declared trust boundary and is never walked through. It IS the
  sandbox, so its own body calls os.OpenRoot; walking it would report the sandbox as
  an escape from itself.

  Methods on values are not acquisitions. f.Stat() on an *os.File obtained through the
  root is already confined -- what would escape is OBTAINING the handle, which is a
  package-level call like os.Open. types.Signature.Recv() distinguishes them.

  Without those two, the first run produced ten violations and every one was noise.

What it cannot do
  Interface dispatch is not followed and cannot be. action.Do(...) resolves to an
  interface method whose implementation dispatches by reflection, so no static walk
  reaches what it runs. Propagation catches a helper that reaches a capability and
  never one reached through dispatch.

  So a claim stays an assertion the author is accountable for, not a proof. plan.Run
  is safe because its claim was withheld, not because anything verified it. Worth
  knowing before reading a green gate as a guarantee.

Shape
  inspectBody became a walker -- walk / record / follow -- because the recursive
  closure it replaced scored 32 on cognitive complexity. Traversal, classification,
  and recursion are now separate and each is small.
Closes #690. make check exits 0 -- 104 packages.

The break
  NewGraph stamped time.Now(), canonicalGraph carried Timestamp, and the checksum is
  taken over exactly that. Two runs of an identical plan produced different checksums
  with no input having changed -- the thing 2.4 says must not happen.

Identity: the timestamp leaves canonical content
  canonicalGraph has always omitted Signature, because a signature is taken OVER the
  content and cannot be IN it. A build time has the identical relationship: it says
  when this graph was made, not what it is. Timestamp joins Signature in being absent.

  The alternative was to call the timestamp an acceptable deviation from hermeticity.
  Rejected: an exception has no natural boundary -- if wall-clock is acceptable, so is
  hostname, so is the planning user -- and "hermetic except X" cannot be checked
  mechanically where "hermetic" can.

Provenance: the clock is read once, where it is visible
  RuntimeEnvironmentSpec.ConceivedAt is stamped by NewRuntimeEnvironmentSpec and
  carried to the environment; GraphSpec.WithTimestamp threads it into every graph a
  session assembles. NewGraph no longer reads the clock at all -- it is a pure
  function of its spec.

  Time of conception, not time of birth. Every graph from one planning session shares
  a timestamp and stays comparable, where a per-graph construction instant made each
  one unique for no reason. Six call sites supply it: plan.AssembleDefinition, lore's
  builder, and writ's deploy, decommission, secret, and upgrade.

  That is what lets plan.AssembleDefinition claim +devlore:claim=deterministic with a
  straight face. It does again, and #677's propagation check holds it.

Verified in both directions
  TestGraphChecksum_IdenticalAcrossConstructionTimes builds the same graph with two
  timestamps two hundred million seconds apart, asserts they differ so identity cannot
  pass by coincidence, then asserts the checksums match. Reverting the canonical-content
  change makes it fail.

  It sits beside TestGraphChecksum_IdenticalAcrossJSONAndYAMLDocuments, which pins the
  other axis. Together: a graph's identity depends on neither when it was built nor how
  it was written down.
Phases E and F of #677, which completes it. make check exits 0 -- 104 packages.

3.6 is replaced, not amended
  Its subject was gone: the title read "Method Classification -- Pure, Query,
  Action" and the tiers were rejected on 2026-08-26. The filename stays so inbound
  links resolve; the document is now "Method Claims", written against the
  implementation rather than ahead of it.

  §8 records why the tiers were rejected, because they are the obvious first idea
  and someone will propose them again. The reason is structural: the tiers were
  exclusive where the properties are independent. file.mkdir is sandboxed, mutating,
  and probably idempotent -- three facts, and a tier holds one. `idempotent` is the
  proof, since under tiers it could not exist at all: it applies to mutators, which
  a tier labels `action` and stops describing.

  §8 also records what is NOT a reason. It was argued that the tiers misclassified
  platform and ui. They did not -- I did, by assuming platform.os() read the host
  without opening provider.go:71, which delegates to a supplied field. That is
  written down because a rejection record crediting a design flaw for an analyst's
  mistake teaches the wrong lesson: distrust taxonomies, rather than read the
  implementation before classifying it.

op.Action's doc comments were describing effects and measuring fallibility
  Action claimed "no side effects" while holding platform's five and ui's six.
  FallibleAction claimed "has side effects" while holding regex's eight and
  json/yaml's seven. Both corrected. CompensableAction keeps its effects language,
  where it is accurate -- a compensator exists to reverse something, which is why
  mutation is the one property derived from a signature rather than claimed.

Every claim is verified against every platform, from one machine
  Step 29 asked for make check under GOOS=linux. Locally that only compiles: the
  cross-built test binary will not execute on the host. claimcheck.CheckGOOS loads
  with a chosen target instead, so the conformance test runs on the host and checks
  host, darwin, linux, and windows in 5.3s. Build tags select different bodies, so a
  claim genuinely can hold on one platform and fail on another; that is now checked
  rather than left to a CI leg that tests one.

  This verifies CLAIMS under those build tags. It does not verify the code builds
  and passes tests on those platforms -- that needs real machines (#671).

The catalog reports the code
  3.5-provider-catalog.md's Role column is regenerated from the announced values and
  gains a Claims column, which is what a hermetic runtime filters on. elevator reads
  "not announced", the truth until #679 renames it.

Also
  A testdata fixture pins every violation shape the checker must catch, including the
  one a call-only scan misses: os.Getenv stored as a function value, which is #683's
  exact shape. It also pins the shape it must NOT catch -- types, constants, and
  conversions -- because reporting those would fail ten real methods.
@David-Noble-at-work David-Noble-at-work self-assigned this Aug 26, 2026
David Noble added 2 commits August 26, 2026 15:24
One conflict, in pkg/op/provider/function/provider.go. Both sides were right about
different things: develop carried #676's sealing of function.Resource, and this
branch had added +devlore:claim=deterministic to Call afterward. The claim is kept;
develop had nothing at that position, having been squashed before it existed.

Everything else auto-merged, including the two files most likely to break quietly:

  .github/workflows/ci.yaml   #682's arm64 build-and-test matrix alongside this
                              branch's go-version 1.27 and golangci-lint v2.13.1.
                              All three go-version sites read 1.27, including the
                              matrix jobs #682 added.

  Makefile                    #682's build-system work alongside this branch's
                              GOLANGCI_LINT_VERSION pin, the golangci-lint-pinned
                              prerequisite, and lint routing through star.

Verified rather than assumed: make check exits 0 -- 104 packages, no lint issues --
and every claim still holds against host, darwin, linux, and windows build tags.
…o move

The darwin CI leg on #697 failed in codegen, not in tests:

  generate.star:357 in validate_action_name_consts
  make: *** [Makefile:647: cmd/star/provider/goast/gen/provider.gen.go] Error 1

goast declared LoadSourceFile twice: as a *Provider method, announced to starlark and
called by lint-go-style.star as goast.load_source_file, and as a package-level
function. Action-name consts live in the package ROOT, so emitting one for the method
collides with the function and will not compile. validate_action_name_consts exists
to catch exactly that.

Latent, and surfaced by this branch
  The validation sits behind the branch step 8 of #677 rewrote:
  `if access in ["planned", "both"]` became `if surface != "module"`. Under the old
  scheme goast was immediate, so the check never ran for it. Under the new one goast
  is eligible for both surfaces, so it runs -- and caught a defect that predates this
  work. Same class as ui and star's lint provider gaining action_names.gen.go, which
  the earlier commit noted; for goast the consequence was fatal rather than cosmetic.

Linux did not pass -- it never ran the validation
  Neither declaration is build-tagged, so the collision exists on every platform.
  make generate regenerates only when a grouped target is stale, and on a fresh CI
  checkout mtimes are near-identical, so which legs regenerate goast is luck. Darwin's
  did. This is the grouped-target staleness that produced a false negative on
  2026-08-24, so rather than trust it again, staleness was forced across every
  provider: 158 codegen invocations, no other collisions.

The fix names each for what it does
  The package-level function becomes parseSourceFile, and unexported -- it is used
  only inside the package. The METHOD takes a path and loads it; the FUNCTION takes
  content and parses it. Sharing a name was always inaccurate and only became a build
  failure once the const was emitted.

Verified on three legs, two of which make check does not run
  make check      104 packages, no lint issues
  make test-race  104 packages, no races
  make test-scenario  green

  Worth recording: make check runs `test`, not `test-race`, and no scenarios at all.
  Every "green" reported on this branch before now was green on a strict subset of
  what CI checks. That gap belongs on #670, which so far only covers lint.
David Noble added 3 commits August 26, 2026 16:46
quality-gate failed on #697 without running lint at all:

  ERROR: golangci-lint 2.13.1 on PATH, expected v2.13.1.
  make: *** [Makefile:375: golangci-lint-pinned] Error 1

`golangci-lint version --short` prints 2.13.1. The pin is written v2.13.1, which is the
form the install URL and the install command in the same recipe both require. The
comparison was string equality between the two, so the correct binary was rejected and
the gate failed closed on a leading character.

Both sides now have a leading v stripped before comparison, rather than the variable
being rewritten -- the variable still has to carry the v for the two install lines, and
normalizing at the comparison survives golangci-lint changing which form it reports.
test-race timed out on darwin-arm64 and windows-amd64:

  panic: test timed out after 2m0s
      running tests:
          TestEveryClaimHolds (2m0s)
          TestEveryClaimHolds/linux (41s)
  FAIL github.com/NobleFactor/devlore-cli/pkg/op/claimcheck  120.416s

The test swept "", darwin, linux, and windows, and each sweep entry is a full
type-checked load of the module -- about 40s apiece under -race. Four sequential loads
does not fit a 120s ceiling. Linux passed on faster runners, which is the only reason
this looked platform-specific.

The sweep was redundant, not just slow
  CI runs five legs and each ran the same four configurations, so twenty loads answered
  twelve questions. The matrix is darwin-arm64, linux-amd64, linux-arm64, windows-amd64,
  and windows-arm64: each leg checking its OWN host covers darwin, linux, and windows
  between them, at one load apiece. That is what makes the matrix load-bearing rather
  than decorative, and it is a quarter of the work.

Why not the alternatives
  Restricting the sweep to non-race builds looked right and is not: the legs run
  `make test-race` OR `make test`, never both, and only windows/arm64 takes the non-race
  path -- the gate would have run on one leg.

  Running the four in parallel trades wall clock for four concurrent type-check graphs,
  and packages.Load already saturates the cores it is given, so the speedup is small and
  the memory is not.

  Raising the timeout alone leaves the redundancy in place and makes a genuinely hung
  test take five minutes to report.

The local cost, and the way out of it
  `make test` on a mac now checks darwin and lets CI find a linux-only violation. Set
  DEVLORE_CLAIMCHECK_ALL_PLATFORMS to sweep all four from one machine before pushing.
…s, not a gap

Starlark ships two output builtins -- print and fail -- and devlore owns neither. A
script's print resolves to starlark.Universe and goes straight to stderr, so it escapes
--silent, escapes color and program-name prefixing, and would escape the diagnostics
stream of 2.8. The uniformity 3.5.16 claims holds for ui.print(...) and not for a bare
print(...). Adding op.RoleRoot closes that: the resolver checks predeclared before
universal, and CamelToSnake maps Print->print and Fail->fail, so the methods REPLACE the
builtins with no Universe mutation, no Thread.Print hook, and no interception code.

That raised the obvious question -- whether ui.Print(msg string) should widen to match
the builtin's print(*args, sep=" "). It should not, and the reason is worth recording
because both widenings look reasonable until you follow them through.

fmt.Printf semantics double-interpret the string
  Starlark's % is an OPERATOR (interpolate, in starlark/eval.go) that runs before the
  call, so print("%s" % name) arrives already rendered. Printf semantics would re-scan
  that finished string for verbs, turning "50% off" into "50%!o(MISSING)ff" whenever the
  DATA contains a %. Go dodges this by making Print and Printf different functions; one
  function cannot be both. The everyday form is print("100% done").

Variadic space-join arrives after the types are gone
  fillVariadicSlot runs toGoInto before dispatch, so a Go-side args ...any receives Go
  natives: True as bool, None as nil, a list as []any. Rendering those Go-side gives
  true, <nil>, [a b] where Starlark says True, None, ["a"]. Faithful output would mean
  reimplementing writeValue on the far side of a conversion that already discarded what
  it needs.

So one string is not a compromise short of the builtin -- it is the only contract that
can be correct. The script renders with str() and %, which is Starlark's own rendering,
deterministic by construction, and hands over a finished string. Requiring an explicit
str() is a feature in a configuration language: nothing is stringified by a rule the
author cannot see.

The tree already works this way without exception. Surveyed every call site:
print( is 64 sites, fail( is 65, and ZERO of either pass multiple arguments or a
non-string. Each builds one string with %, +, or str().

Residual cost, recorded rather than hidden
  print("a", "b") and print(42) are legal against the builtin and are errors against
  these methods. Nothing in the tree does either, but a Python reflex produces one. The
  errors are legible -- toGoScalar uses starlark.AsString with no coercion, so print(42)
  reports `expected string, got int`.

Also corrected: the Thesis said op.RoleModule; the generated roles are
op.RoleModule|op.RoleAction.

Root placement itself is designed, not landed -- #698, task #700, plan
docs/plans/ui-at-root.md. Landing it carries two consequences both recorded on #700:
231 ui.* call sites move to bare names, and placeModule's root path has a latent
return-for-continue defect that putting ui at root would arm.
@David-Noble-at-work
David-Noble-at-work deleted the seal-mem-and-function branch August 27, 2026 00:09
@David-Noble-at-work
David-Noble-at-work restored the seal-mem-and-function branch August 27, 2026 00:10
@David-Noble-at-work
David-Noble-at-work merged commit ca7744e into develop Aug 27, 2026
24 checks passed
@David-Noble-at-work
David-Noble-at-work deleted the seal-mem-and-function branch August 27, 2026 00:13
David-Noble-at-work added a commit that referenced this pull request Aug 27, 2026
…705)

* docs(claimcheck): the CheckGOOS comment still argued for the sweep that was removed

Narrowing the test to one platform per CI leg left the doc comment on CheckGOOS making
the case for the four-platform sweep it replaced:

  Verifying only the developer's platform would leave the others to CI, and CI runs
  one of them.

That is now both stale and an argument against the design around it -- a reader looking
for why the sweep is gone finds a paragraph explaining why it should not be. It also
misdescribes CI, which runs five legs spanning three platforms rather than one of them.

Rewritten to say where the coverage actually comes from: each leg checks its own host,
the legs span darwin, linux, and windows between them, and an explicit goos is for
sweeping the rest from one machine.

* feat(make): regenerate forces every codegen rule, ignoring mtimes

Codegen is mtime-gated, and a fresh clone stamps every file at checkout -- so whether a
provider looks stale is decided by write order, not by its source. Measured on CI run
33024633546, ONE commit across six jobs:

  test (darwin-arm64)    159 files   30 providers
  quality-gate             9 files    1 provider
  test (linux-amd64)       9 files    1 provider
  test (linux-arm64)       0 files    0 providers
  test (windows-amd64)     0 files    0 providers
  test (windows-arm64)     0 files    0 providers

Nothing varied but which runner took the job. Half of them ran no provider generator at
all, and every generator validation rides along with that -- so which defects get caught
was luck. #697 has the receipt: darwin regenerated goast, caught a duplicate declaration
through validate_action_name_consts, and failed; linux skipped it and passed. Neither
declaration was build-tagged.

Touching sources, not deleting outputs
  The obvious lever is `rm -f $(NEW_OP_INVENTORY)` -- one output per provider, each the
  head of a grouped rule, list already maintained. It cannot work. The generated files
  hold the op.Announce*() call sites devlore-inventory scans, inventory is a prerequisite
  of the star binary, and `build/star: FORCE` rebuilds star every run. THE OUTPUTS ARE
  ALSO INPUTS, so deleting them destroys the bootstrap that would regenerate them. Tried
  it: inventory found 8 packages instead of 19, then failed with `no op.Announce*() call
  sites found`.

  Deletion would be safe with an LKG present -- STAR resolves to build/star.lkg when it
  exists and build/star: FORCE is never consulted. Not taken: CI has no LKG, and a check
  that depends on an artifact CI lacks is not a check.

  `make -B generate` also forces the rules and forces far more than the question needs,
  propagating into the build/star sub-make.

Verified
  make regenerate   159 files, 30 providers -- matches the darwin leg exactly
  make generate       0 files -- the mtime gate still skips unchanged providers
  check / test-race / test-scenario   all pass

* ci: assert the committed generated files equal what the generator produces

Generated files are committed, CI never ran `generate` explicitly, and no step asserted
that generation produced no diff. Two consequences, both live until now:

  A stale committed .gen.go passed CI. Edit a provider.go, forget to regenerate, commit
  -- if the mtimes fell the wrong way, nothing noticed.

  When codegen DID run, CI tested the regenerated output rather than the committed
  output. The artifact under test was not the artifact in the repository, and the
  rewrite happened inside the build where nobody saw it.

The fix already existed in this file, applied to go.mod: run the tool, then assert
nothing changed. `make regenerate` forces every rule; `git diff --exit-code` holds the
committed tree to it.

One leg is the right scope
  Generated output is platform-independent -- it has to be, since the repository holds
  one copy. Confirmed rather than assumed: regenerating all 159 files on darwin
  reproduced the committed tree byte for byte. Forcing regeneration on quality-gate
  makes the generator validations fire deterministically there, and the diff makes every
  other leg's mtime-gated build safe, because the committed files are then known current.

A deletion the generator no longer emits also fails this check -- the #572 class of bug,
caught for free.

Proved it can fail before trusting it
  A check that has only ever passed is not a check. Removing one +devlore:claim directive
  from ui.Note changed pkg/op/provider/ui/gen/provider.gen.go, which the diff catches.
  Restored byte-identical afterward.

  Worth recording: the first probe edited a method doc comment and changed NOTHING --
  doc comments do not reach the generated artifacts. Stopping there would have concluded
  the check was broken.

* docs(plans): deterministic codegen, closes #702

---------

Co-authored-by: David Noble <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Method claims: deterministic, idempotent, sandboxed staticcheck flags a possible typed-nil interface guard in writ — locally, but not in CI

1 participant