Skip to content

ui takes the root namespace and replaces print and fail - #708

Merged
David-Noble-at-work merged 7 commits into
developfrom
ui-at-root
Aug 27, 2026
Merged

David-Noble-at-work merged 7 commits into
developfrom
ui-at-root

Conversation

@David-Noble-at-work

Copy link
Copy Markdown
Contributor

Closes #700. Implements #698.

Starlark ships two output builtins — print and fail — and devlore owned neither. A script's print
resolved to starlark.Universe and went straight to stderr through starlark-go, escaping --silent, color,
program-name prefixing, and the diagnostics stream of
2.8. The uniformity 3.5.16 claims held for
ui.print(...) and not for a bare print(...).

+devlore:root=true closes it with no new mechanism. The resolver checks predeclared names before
universal ones, and CamelToSnake maps Print→print and Fail→fail, so the methods replace the
builtins — no Universe mutation, no Thread.Print hook, no interception code. Four names that were never
builtins — note, warn, error, succeed — become globals too.

Signatures unchanged, deliberately

The builtin is print(*args, sep=" "). Widening to match fails both available ways:

  • Printf semantics would re-scan a string Starlark's % operator already rendered, turning 50% off into
    50%!o(MISSING)ff whenever the data contains a %. Go avoids this by making Print and Printf different
    functions; one function cannot be both.
  • A variadic Go signature receives natives after toGoInto, rendering True as true and None as
    <nil> — Starlark's vocabulary is gone by the time the method sees the arguments.

One string is the correct contract: the script renders with str() and %, which is Starlark's own rendering,
and hands over a finished string. The tree already worked this way — 64 print and 65 fail call sites, zero
multi-argument, zero non-string.

A defect in the branch this activates

placeModule's root path skipped a refused method with return rather than continue, abandoning every
method the loop had not reached. ReceiverType.Methods yields sorted by name, so the loss was
deterministic — everything sorting after the refused method, every run. That is worse than order-dependence,
not better: a flaky wrong answer eventually gets investigated, a stable one reads as intended behavior.

It had never fired: no provider was RoleModule|RoleRoot, and ui claims deterministic on all six methods,
so nothing refuses them. This PR arms it. The branch's sibling assertion was inverted for the same reason until
#677 — two defects in the same unexercised code.

Tested for properties that are otherwise invisible

Both tests were proved to fail before being trusted:

Test Fails when
root_test.go +devlore:root=true is removed
TestNewRuntime_ModuleRoot_RefusedMethodDoesNotDropTheRest the continue reverts to return

A script calling print("x") looks identical whichever implementation it reaches — only the destination
differs. Without the first test, a lost directive would restore the builtins silently, every script would
keep working, and output would quietly return to stderr.

Not asserted: that the output reaches the narrator. These cover registration, not emission; proving
emission needs a runtime execution test with a capturing sink.

Landing it takes two passes

star/extensions/*.star is both the generator's input and a consumer of the API the generator emits —
generate.star calls these methods. Sweeping before codegen knows about the root placement fails at:

generate.star:1041:5: undefined: note

The order that works: directive in with scripts unchanged (old star still resolves ui.note, gen file gains
RoleRoot), then sweep and regenerate (star rebuilds with RoleRoot, bare names exist). Same shape as
#702's rm -f cycle — an output that is also an input. Recorded in 3.5.16-ui-provider.md, since it applies
to any provider-surface change the generator's own extensions use.

The sweep

232 call sites across 27 files. Root placement replaces the ui global rather than adding to it, so
ui.note stops resolving. No name collided — every .star file was checked for a module-scope assignment,
local def, or parameter named any of the six.

Also deleted: pkg/op/provider/ui/testdata/integration.star, which called ui.success(...) — not a method —
and claimed to exercise "all five messaging methods" when there are six. No Go test ever loaded it. #706
covers the 14 sibling providers carrying the same orphan, 305 lines that nothing executes.

Verified

make check, make test-race, make test-scenario — all pass.

David Noble added 4 commits August 26, 2026 19:29
…er it

placeModule's root path skipped a refused method with `return`, not `continue`:

  for m := range module.Methods() {
      snake := op.CamelToSnake(m.Name())
      if refused[snake] {
          return          // <- abandons the rest
      }
      predeclared[snake] = attr
  }

ReceiverType.Methods yields SORTED BY NAME from a slice, so this was not a random
loss -- every method sorting after the refused one vanished, every run. That is worse
than order-dependence rather than better: a flaky wrong answer eventually gets
investigated, a stable one reads as intended behavior.

It had never fired. No provider was RoleModule|RoleRoot, and the only candidate claims
deterministic on all six of its methods, so nothing refused anything. The branch's
sibling assertion was inverted for the same reason until #677 step 6 -- two defects in
the same unexercised code, which is a fair signal about the rest of it.

The test names its fixture methods Alpha, Bravo, Charlie so the refused one sorts
first, making the failure exact rather than dependent on what the iterator reaches
first. Verified in both directions: it fails against `return` and passes against
`continue`.
…rint and fail

Starlark ships two output builtins -- print and fail -- and devlore owned neither. A
script's print resolved to starlark.Universe and went straight to stderr through
starlark-go, escaping --silent, color, program-name prefixing, and the diagnostics
stream of 2.8-eventing-infrastructure.md. The uniformity 3.5.16 claims held for
ui.print(...) and not for a bare print(...).

+devlore:root=true closes it with no new mechanism. The resolver checks predeclared
before universal, and CamelToSnake maps Print->print and Fail->fail, so the methods
REPLACE the builtins -- no Universe mutation, no Thread.Print hook, no interception.
ui now announces RoleModule|RoleAction|RoleRoot, and four names that were never
builtins -- note, warn, error, succeed -- become globals too.

Signatures unchanged, deliberately
  The builtin is print(*args, sep=" "). Widening to match fails both available ways.
  Printf semantics would re-scan a string starlark's % operator already rendered,
  turning "50% off" into "50%!o(MISSING)ff" whenever data contains a %. A variadic
  Go-side signature receives natives after toGoInto, rendering True as true and None
  as <nil>. One string is the correct contract: the script renders with str() and %,
  which is starlark's own rendering, and hands over a finished string.

Tested for a property that is otherwise invisible
  A script calling print("x") looks identical either way; only the destination differs.
  root_test.go asserts the six globals are installed and that the ui global is NOT --
  root placement replaces the provider global rather than adding to it. Proved failing
  by removing the directive. Without it, a lost directive would restore the builtins
  silently and every script would keep working.

Not asserted: that the OUTPUT reaches the narrator. The test covers registration, not
emission; proving emission needs a runtime execution test with a capturing sink.

Also deleted: testdata/integration.star, which called ui.success(...) -- not a method --
and claimed to exercise "all five messaging methods" when there are six. No Go test ever
loaded it. #706 covers the 14 sibling providers carrying the same orphan.
Root placement REPLACES the ui global rather than adding to it: placeModule installs
each method as its own predeclared entry and never sets predeclared["ui"]. So ui.note
stops resolving and every call site has to move. No name collided -- checked every
.star file for a module-scope assignment, local def, or parameter named error, fail,
note, print, succeed, or warn, and found none.

This lands in a second pass, which is a property of the repo worth knowing
  star/extensions/*.star is both the generator's INPUT and a consumer of the API the
  generator emits -- generate.star calls these methods. Sweeping before codegen knows
  about the root placement fails at:

    generate.star:1041:5: undefined: note

  because the scripts reach for an API the binary running them does not have yet. The
  order that works is: directive in with scripts unchanged, so the old star still
  resolves ui.note and the gen file gains RoleRoot; then sweep and regenerate, so star
  rebuilds with RoleRoot and the bare names exist. Same shape as #702's rm -f cycle --
  an output that is also an input. Recorded in 3.5.16-ui-provider.md, since it applies
  to any provider-surface change the generator's own extensions use.

232 sites, not the 231 surveyed: four were in the deleted testdata fixture, and the
rest are references without a trailing paren, in comments, which the survey's regex
required.
3.5.16 and its status companion move from "designed" to "landed", and gain the two-pass
property: star/extensions/*.star is both the generator's input and a consumer of the API
it emits, so a provider-surface change the generator's own extensions use cannot land in
one pass.

Corrected in the plan: it said the refused-method defect dropped survivors according to
MAP ITERATION ORDER. It did not. ReceiverType.Methods yields sorted by name from a slice,
so the loss was deterministic -- everything sorting after the refused method, every run.
Worse than described, and the reason the regression test could be made exact.
David Noble added 3 commits August 26, 2026 23:07
… narrator

root_test.go proved the six names are INSTALLED as predeclared globals, which given
starlark's predeclared-before-universal resolution means a script's print resolves to
the provider. It did not prove the output arrives anywhere -- and where the bytes go is
the entire reason for taking the root namespace.

Four fixtures run in devlore-test
  test_imm_ui.star            all five non-fatal methods, print now among them
  ..._print_replaces_builtin  print("a", "b") ERRORS
  ..._fail                    fail() halts and reports through the narrator
  ..._one_string              a bool is rejected

The arity fixture is the load-bearing one: it is the only BEHAVIORAL discriminator
between our print and starlark's. The builtin is variadic, ours takes one string, so a
two-argument call failing is proof the replacement is live. Everything else about the
two is identical at the call site.

And a Go test for emission
  TestUIAtRoot_PrintReachesTheNarrator executes a script through Runtime.Invoke against
  a capturing sink and asserts the narrator saw the text. Proved to fail: with the root
  directive removed it reports the narrator holding only the selection report, because
  print fell through to the builtin and went to stderr. That is exactly the silent
  regression this exists to catch.

Two fixtures I tried and could not keep
  A ui.note(...) fixture is unwritable here. That is a RESOLVE error, so it fires before
  t.expect_error on line 10 ever executes, no expectation is registered, and the harness
  reports a hard runner error rather than a satisfied one. Any resolve-time failure is
  untestable through expect_error, which is a property of the harness rather than of ui.
  root_test.go covers the property by inspecting predeclared directly.

  print(42) does not error -- it prints "*". Dispatch reaches op.Convert before
  toGoScalar's starlark.AsString path, and there int64 counts as convertible to string,
  yielding the RUNE at that code point. Measured: Convert(int64(65) -> string) = "A".
  That is #709, it affects every provider method taking a string, and the fixture pins
  the bool rejection while naming the integer gap.

Patterns are pinned to the real messages, not to guesses. The arity one asserts
`ui.print: got 2 arguments, want at most 1` AS IS -- naming a symbol no script can type,
which is #710 and pre-existing. Pinning it means fixing #710 has to come back here.
…ext root placement

The design document asserted that print(42) reports `expected string, got int` because
toGoScalar uses starlark.AsString with no coercion. That is the wrong code path and the
wrong outcome. Dispatch reaches op.Convert first, where reflect.Value.Convert treats
int64 as convertible to string and yields the RUNE: print(65) emits "A". Measured, not
inferred. Tracked as #709; the commit message and PR body for this branch carry the same
error, and the squash merge is where that gets corrected.

Two traps recorded in the plan, both the same mistake -- an operation that was safe
earlier is not safe once the call sites move:

  The negative probe deadlocks after the sweep. Removing +devlore:root=true to prove a
  test fails is fine before the sweep and fatal after: the swept scripts call bare
  note(), so a star rebuilt without RoleRoot cannot load generate.star, and codegen is
  the only thing that could restore RoleRoot. Recovery needs a checkout from the
  pre-sweep commit, since HEAD already carries the swept files.

  The sweep rewrites prose ABOUT the old name. Re-running it turned "the message names
  ui.print" into "the message names print", destroying a comment written to explain
  #710. A regex literal in the same file survived only because its backslash broke the
  pattern.
The previous commit carried 26 .star files backwards to their pre-sweep form, putting
ui.fail(...) and ui.note(...) back into the extensions. ui is not a global any more, so
every leg of CI failed at load: the extensions cannot resolve the name.

How it happened
  Recovering from the codegen deadlock needed the pre-sweep scripts, restored with:

    git checkout 3b06d8c -- '*.star'

  That form of checkout writes the INDEX as well as the working tree. The scripts were
  re-swept in the working tree afterwards, but the index still held the rolled-back
  copies, and `git commit` commits the index -- not the paths named in the preceding
  `git add`. So the test commit reported 32 files when two were intended, and the extra
  30 undid the sweep.

  Same shape as the staged deletion earlier on this branch: `git add <paths>` narrows
  what is ADDED, never what is COMMITTED.

Nothing about the swept content changed -- this restores what 09fdf78 established, from
the working tree that has been running green locally throughout.
@David-Noble-at-work

Copy link
Copy Markdown
Contributor Author

Note on the red run in this PR's history — it was self-inflicted, not a defect in the change.

Commit 0eda439 reported 32 files changed when 2 were intended. The extra 30 were pre-sweep .star files left staged by a git checkout <commit> -- '*.star' used to recover from the codegen bootstrap deadlock. That form of checkout writes the index as well as the working tree, and git commit commits the index rather than the paths named in the preceding git add — so the commit silently reverted the sweep, putting ui.fail(...) back into the extensions where ui no longer resolves. Every leg failed at extension load.

b87960b8 restores the swept content. All 12 checks green.

Two additions since the PR was opened, both worth a look:

e2e coverage in devlore-test — four fixtures. The load-bearing one is test_imm_ui_print_replaces_builtin.star: the builtin is variadic and ours takes one string, so print("a", "b") failing is the only behavioral proof the replacement is live. Everything else about the two is identical at the call site.

An emission test — TestUIAtRoot_PrintReachesTheNarrator executes a script through Runtime.Invoke against a capturing sink. This closes the gap the PR body named as unverified: root_test.go proved the names are installed, nothing proved the bytes arrive. Proved to fail — with the directive removed the narrator holds only the selection report, because print fell through to the builtin and went to stderr.

One correction to the PR body above: it claims print(42) reports expected string, got int. It does not — it prints *. Dispatch reaches op.Convert before toGoScalar's AsString path, and there int64 counts as convertible to string, yielding the rune at that code point. Measured: Convert(int64(65) -> string) = "A". That is #709 — it affects every provider method with a string parameter, and go vet's stringintconv cannot see through reflect. The design doc is corrected; this comment corrects the body.

Also filed from writing these tests: #710 — dispatch errors on a root-placed provider name a symbol no script can type (ui.print: got 2 arguments, want at most 1). Pre-existing; flow has reported flow.failed for plan.failed calls since root placement existed. The fixture pins the current message as-is, so fixing #710 has to come back through it.

@David-Noble-at-work
David-Noble-at-work merged commit a7d1841 into develop Aug 27, 2026
12 checks passed
@David-Noble-at-work
David-Noble-at-work deleted the ui-at-root branch August 27, 2026 06:22
David-Noble-at-work added a commit that referenced this pull request Aug 27, 2026
* docs(plans): the plan template asks for a test plan

Zero of 118 plans in docs/plans carry one, and the template not providing a section is
why. Added between Implementation Phases and Migration Path, so new plans inherit it.

The section asks for three things a bare list of tests does not:

  A "fails when" column per row. A test that has only ever passed has not been shown to
  be a test. Twice this week writing that column was the thing that caught a problem: an
  assertion that turned out to be unwritable in the harness at all, and a case that did
  not fail the way the design document claimed.

  Failing tests written first. A test authored after its fix has to be re-proved by
  breaking the fix, which is slower and sometimes impossible -- on #708 that deadlocked
  the build, because removing the directive broke the tool that would put it back.

  An explicit "not covered" list. An untested property is a decision, not an oversight,
  and belongs where a reviewer can weigh it.

* docs(plans): number fidelity on reload -- read a number against its field

Plan for #711. json has one number type, so LoadGraph's plain json.Unmarshal yields
float64 for every number and an authored 0o644 reaches an fs.FileMode as a float.
Convert truncates it back, which is why nothing has noticed.

The rule: convert from the value we serialize to the type of the field it fills. Decode
with UseNumber so the literal survives, and convert at ASSEMBLY -- assembleGraph already
resolves each unit's action through the registry, so the declared parameter types are in
scope there. Converting later leaves json.Number in the graph's in-memory form, and the
canonical form is computed from that: a json.Number renders as a quoted string in yaml
where a float64 renders bare, which is the checksum mismatch a prototype produced.

Scope is parameters with a DECLARED type. An `any` slot is excluded by ruling (#712):
json.Marshal(float64(42)) emits 42, so a float in an `any` slot loses its type at SAVE
and no load-side fix can reach it. There the document must store the type.

The test plan's row 5 is the experiment rather than a regression guard: verify the
checksum does not move, BEFORE writing the conversion. Unchanged means the fix is
contained in deserialization; changed means graph identity is in scope and needs a
ruling before anything merges.

* fix(op): a reloaded number is read against the field it fills

json has ONE number type, so unmarshalling into an `any` slot cannot say whether 420 was
written as an integer or a float. LoadGraph used plain json.Unmarshal, so every number in
a reloaded graph came back as float64 and an authored 0o644 reached an fs.FileMode as a
float. Nothing noticed because op.Convert truncated it back -- the conversion layer was
compensating for a serialization defect.

Two things were already true, before #709 touched anything
  The codecs disagreed. yaml.v3 decodes an integer as int; json yielded float64. The same
  graph reloaded to different in-memory values depending on which codec wrote it.

  float64 cannot represent every int64. A large enough integer in a saved graph was
  ALREADY corrupted on reload; truncation made the corruption look like a clean number.

The rule: convert from the value we serialize to the type of the field it fills
  LoadGraph decodes with UseNumber, so json.Number keeps the literal text. assembleBindings
  reads it against the parameter -- assembleNode has already resolved the action through the
  registry, so the declared types are in scope at exactly the point the value is bound.

  Yaml goes through the same path. It keeps an integer an integer, but has no notion of the
  fs.FileMode the field declares, so both codecs now reload to identical values.

NUMBERS ONLY, which the first implementation got wrong
  Reading EVERY value against its field is the honest reading of the rule and it breaks a
  ruling. Convert has a registered-Resource-construction step, so the URI string a
  resource-typed slot carries was built into a Resource at load time -- and §5.6 of
  docs/plans/resource-construction.md says that string stays a KEY until dispatch resolves
  it through the run catalog. The canonical form is computed from these values, so
  constructing early moved the checksum:

    op.LoadGraph: checksum mismatch:
      document   "sha256:8e5bf1a1..."
      recomputed "sha256:1bcc33d7..."

  That mismatch was the design refusing a rule violation, not a fragile hash. Narrowed to
  numbers, checksums are unchanged -- verified, which was the plan's step 5 and the question
  that decided whether this reached graph identity. It does not.

Two tests that passed for the wrong reason, both caught by asking which path ran
  The range test edited a saved document, so LoadGraph refused it for a CHECKSUM mismatch
  and the test's early return fired without ever reaching range handling. Moved to Convert,
  where the behavior lives. The ordering it exposed is worth keeping: integrity is a
  stronger gate than range and runs first, so a graph-level range test cannot reach the code
  it claims to cover.

  Step 8 asked whether this resolves the five save/load failures #709 produced. Running
  those five here proves nothing -- they pass on develop too, and only fail once #709's rule
  removes the truncation. Pasting that rule on temporarily: all five pass, and the only
  remaining failure is #709's own convert_struct_test.go, a literal float64 in a map
  reaching an int field with no json involved.

An `any` parameter is out of scope by ruling (#712): json.Marshal(float64(42)) emits 42, so
a float in an `any` slot loses its type at SAVE and no load-side fix can reach it. There the
document must carry the type.

* docs(plans): number fidelity complete -- what step 5 and step 8 actually measured

Step 5 was written as the experiment rather than a regression guard: verify the checksum
does not move. It answered yes, but only after the first implementation moved it by
violating §5.6's resource-construction ruling -- which is a more useful answer than the
one the step was written to get. It says which values may be interpreted at load time at
all, not merely whether the hash is stable.

Step 8 records why checking #709's five failures on this branch proves nothing, and what
was done instead.

---------

Co-authored-by: David Noble <[email protected]>
David-Noble-at-work added a commit that referenced this pull request Aug 27, 2026
… about it (#717)

* docs(op): root applies to every surface, and the surface-to-role mapping is written down

Three corrections, all surfaced by ui becoming the first RoleModule|RoleAction|RoleRoot
provider (#708) and making differences observable that had never been exercised.

RootProviders described a filter that does not exist
  Its doc said callers filter the returned slice by dispatch zone and named plan.Provider
  as the example that "filters to RoleAction". plan.Provider does not filter --
  buildPromotedBuiltins passes registry.RootProviders() through untouched, so every method
  of every root provider is promoted into plan.*. The code is correct; the comment
  described an intent never implemented. Corrected, with a note on why nobody noticed:
  flow is the only other root provider and it is surface=graph, so the distinction had no
  observable consequence until ui arrived holding both dispatch bits.

RoleRoot now states its intent so it cannot be lost
  One bit, every surface, deliberately. Placement is orthogonal to dispatch, so a provider
  holding both dispatch bits surfaces flat in both namespaces from a single
  +devlore:root=true: note() in a script AND plan.note() in a graph. There is no way to be
  root in one zone and nested in the other, and nothing a provider could mean by asking --
  a name is promoted because it reads better without its qualifier, and that is as true of
  a graph as of a script.

The surface-to-role mapping existed only in generate.star
  As a switch, with no prose anywhere. The provider-development guide's directive table
  listed lifetime and bind and omitted BOTH surface and root. Now in the guide and in
  3.6-method-classification.md:

    absent (default)  RoleModule|RoleAction    + root -> |RoleRoot
    graph             RoleAction               + root -> |RoleRoot
    module            RoleModule               + root -> |RoleRoot

  Both documents also carry the root-applies-everywhere rule, so a reader arriving at
  either one gets it.

Related: #715 renames these roles, because surface=graph produces RoleAction -- two
vocabularies for one idea -- and the op->workflow rename retires both words.

* docs(plans): the rename plan says what is stale about it, and what it lacks

The plan presented 2026-05-26 measurements as current and claimed a prerequisite that has
been satisfied for months. A reader arriving at it got stale facts with no sign that a
re-audit was chartered -- every finding lived in issues the plan did not reference.

  A banner up front: prerequisite satisfied, numbers stale, do not start a phase from
  them, with pointers to #716 and #715.

  Prerequisites corrected in place, with the old blocker struck through rather than
  deleted. It was true when written, and the record should show that.

  Staleness (measured 2026-08-27), beside the Current State it corrects: root .go files
  62 -> 108, provider files 76 -> 238, qualified op.X references ~2,499 -> 3,880. The
  subpackage list is wrong in both directions -- sops/ has left, claimcheck/ and server/
  arrived. What still holds: all five renames are well-formed and the collision claim
  survives, Step being declared only in internal/console.

  New evidence FOR the target, also recorded: plan.Provider now exposes
  AssembleDefinition, LoadDefinition, and SaveDefinition, all returning *op.Graph, and
  scripts read plan.assemble_definition(...). The API already calls a graph a definition
  while the Go type is still Graph. The rename closes a gap that widened on its own.

  Appendix: Name Mapping -- TO BE WRITTEN. The Rename Mapping section is five rows, the
  top-level types, and nothing maps the derived identifiers -- 53 exported identifiers
  contain Graph, 37 contain Subgraph, 18 contain RecoveryStack, and 14 files carry a
  renamed word in their name. Goal 4 promises reviewers can verify by inspection; without
  this appendix there is nothing to inspect against.

  The appendix section works through why it needs judgment rather than derivation. Node is
  ambiguous by construction: it maps to Step AND is the target of ExecutableUnit, so it
  appears on both sides. GenerateNodeID is the proof -- flow/helpers.go calls it as
  GenerateNodeID(string(Subgraph)), so it mints ids for subgraphs and names the ABSTRACT
  VERTEX, which under the new taxonomy keeps the name Node. A mechanical Node -> Step
  sweep would rename it GenerateStepID and quietly make it wrong.

  And the requirement that identifiers KEEPING their name be listed explicitly: a table of
  only the changes cannot distinguish "correctly unchanged" from "missed".

  Related Rename: the provider roles, pointing at #715.

Status stays draft. Leaving draft is #716's last acceptance item, and should not happen
while the numbers are annotated rather than re-measured.

---------

Co-authored-by: David Noble <[email protected]>
David-Noble-at-work added a commit that referenced this pull request Aug 28, 2026
…lue (#723)

Plan for #715, under the op->workflow rename epic (#451).

The defect: +devlore:surface=graph produces RoleAction and +devlore:surface=module
produces RoleModule -- two vocabularies for one idea, connected only by a switch in
generate.star. An author writes graph and the generated code says Action. The rename
forces it: Graph becomes Definition, so surface=graph will name a type that no longer
exists, and module is starlark's word for a namespace rather than a term in the new
taxonomy. Neither current word survives.

The target
  +devlore:surface=script|workflow     a list; default both
  +devlore:placement=promoted          one value; default qualified

  type ProviderFlags uint16   packs both
  type Surfaces uint8         BIT FIELD -- one or more
  type Placement uint8        VALUE -- exactly one

The zones are not the same kind of thing, and the design says so three ways
  Plural for the set, singular for the value: flags.Surfaces() returning
  SurfaceScript|SurfaceWorkflow reads correctly, where flags.Surface() returning two
  things reads as a contradiction. Someone reading only the accessor signatures learns
  which zone is which.

  Distinct types per zone, following reflect, where flag.kind() returns Kind rather than
  another flag. That makes flags.Placement() == SurfaceScript a compile error rather than
  nonsense that compiles. C# disagrees -- MethodAttributes packs a MemberAccessMask zone
  and flag bits in one enum -- and Go's answer was taken for the type safety.

  PlacementQualified = 0x00 is the zero VALUE of an enumeration, not an absent flag, so
  nothing can express "promoted and qualified" -- not because a check rejects it, but
  because the zone holds one value of a type with two.

Why ProviderFlags rather than something specific
  ProviderRole was specific and went stale: #677 changed the zone from invocation mode to
  surface eligibility, and RoleAction, Dispatch(), and ProviderRole all outlived what they
  named. A deliberately generic name cannot fail that way, and the accessors carry the
  meaning. Precedent: 14 *Flag/*Flags types in Go's stdlib, and C#'s BindingFlags.

The bootstrap is the hard part, and it has no two-pass escape
  generate.star emits the constant names into every .gen.go. Renaming them breaks all 58
  generated references at once; the tree stops compiling; star cannot rebuild; and codegen
  -- the only thing that could rewrite those files -- cannot run. Unlike #708 there is no
  ordering that avoids it, because the constant name and its generated use must change
  together.

  The LKG escape hatch is exactly this case: STAR resolves to build/star.lkg when it
  exists, and build/star: FORCE is never consulted, so a prebuilt star regenerates a tree
  it could not itself compile. cmd/devlore-inventory does not import pkg/op (verified), so
  make generate's inventory step survives too.

  make star-lkg is therefore step 1 and a PRECONDITION, not a convenience: taken after the
  rename begins it would snapshot a binary built from a tree that no longer compiles.

Scope: ~400 references, 58 of them generated, and FOUR authored directive lines -- ui,
plan, and flow are the only providers that declare anything.

Co-authored-by: David Noble <[email protected]>
David-Noble-at-work added a commit that referenced this pull request Aug 28, 2026
…alue (#726)

Closes #715.

+devlore:surface=graph produced RoleAction and +devlore:surface=module produced
RoleModule -- two vocabularies for one idea, connected only by a switch in generate.star.
An author wrote graph and the generated code said Action. The op->workflow rename forces
the issue: Graph becomes Definition, so surface=graph would name a type that no longer
exists, and module is starlark's word for a namespace rather than a term in the new
taxonomy. Neither word survives.

  +devlore:surface=script|workflow     a list; default both
  +devlore:placement=promoted          one value; default qualified

  type ProviderFlags uint16   packs both
  type Surfaces uint8         BIT FIELD -- SurfaceScript, SurfaceWorkflow
  type Placement uint8        VALUE -- PlacementQualified, PlacementPromoted

The zones are not the same kind of thing, and the design says so three ways
  PLURAL for the set, singular for the value. flags.Surfaces() returning
  SurfaceScript|SurfaceWorkflow reads correctly; flags.Surface() returning two things
  would read as a contradiction. The accessor signatures alone say which zone is which.

  DISTINCT TYPES per zone, following reflect, where flag.kind() returns Kind rather than
  another flag. flags.Placement() == SurfaceScript is now a compile error rather than
  nonsense that compiles. C# disagrees -- MethodAttributes packs a MemberAccessMask zone
  and flag bits in one enum -- and Go's answer was taken for the type safety.

  PlacementQualified = 0x00 is the zero VALUE of an enumeration, not an absent flag, so
  nothing can express "promoted and qualified" -- not because a check rejects it, but
  because the zone holds one value of a type with two.

Why ProviderFlags rather than something specific
  ProviderRole was specific and went stale: #677 changed the zone from invocation mode to
  surface eligibility, and RoleAction, Dispatch(), and ProviderRole all outlived what they
  named. Dispatch() -> Surfaces() for the same reason. A generic name cannot fail that
  way, and the accessors carry the meaning. Precedent: 14 *Flag/*Flags types in Go's
  stdlib, and C#'s BindingFlags.

The bootstrap had no two-pass escape
  generate.star emits the constant names into every .gen.go, so renaming them broke all 58
  generated references at once, the tree stopped compiling, star could not rebuild, and
  codegen -- the only thing that could rewrite those files -- could not run. Unlike #708
  no ordering avoids it: the constant name and its generated use must change together.

  make star-lkg first, so a prebuilt star could regenerate a tree it could not itself
  compile. It took five iterations of generate.star scope errors to get through, and the
  tree was uncompilable during all of them; each would have been unrecoverable without it.
  The LKG was then removed and a source-built star reproduced all 159 files identically,
  proving it was an escape hatch rather than load-bearing.

A property that was documented three times and tested zero times
  #717 established that ui, reaching both surfaces, promotes six names into plan.* as well
  as installing six globals. That claim lived in three doc comments and no test -- and it
  is the half of placement this rename routes through new code.
  TestProvider_ResolveAttr_Tier2_PromotedAcrossBothSurfaces now asserts it, with a
  negative case proving file.copy stays qualified. Without the negative half it would pass
  on a change that promoted everything.

Also: promoteRootMethods -> promoteMethods and rootNames -> promotedNames, resolving an
identifier that carried two words for one idea. Placement test functions renamed;
fsroot, run-root, scoped-root, and git-root names deliberately untouched -- a word-level
sweep over "root" would break their meaning without breaking the build (#725 tracks the
last two artifacts).

The accessors mask explicitly rather than relying on the conversion to truncate, which
gosec G115 caught: the bare form is correct only while both zones are a byte wide.

Co-authored-by: David Noble <[email protected]>
David-Noble-at-work added a commit that referenced this pull request Aug 28, 2026
…ed (#728)

Closes #710.

  >>> print("a", "b")
  ui.print: got 2 arguments, want at most 1

ui.print does not exist. ui is promoted, so its methods ARE the top-level globals and the
provider name is not a symbol any script can use -- an author who takes the error at its
word and writes ui.print(...) gets `undefined: ui`. The error sent them toward a second,
unrelated error, which is worse than a vague message.

Pre-existing, not introduced by #708: flow has done this since promotion existed.

The rule
  Where a call site exists, an error names what the author typed; where none exists, it
  names the action. The two cannot be one name because the action name is a node's
  identity field -- it is in the serialized document and the checksum -- so tying it to
  placement would rewrite every saved workflow when a provider's placement changed.

    print("a", "b")          script evaluation -- the call site is right here
    -> print: got 2 arguments, want at most 1

    plan.failed("db down")   runs later, possibly from a workflow loaded off disk
    -> flow.failed executed: db down          unchanged; there is no call site to name

Three sites, and each already knew its own surface
    starlarkbridge/go_receiver.go   script     consults Placement(): bare when promoted
    plan/provider.go                workflow   promoted   -> plan.<method>
    plan/adapter.go                 workflow   qualified  -> plan.<provider>.<method>

  Only the first consults placement. The other two ARE the promoted and qualified paths,
  so the site itself determines the shape.

  This is what #715 unblocked. Before it, placement was a bit tangled with dispatch and
  "which name would the author have typed" had no clean answer.
  flags.Placement() == op.PlacementPromoted is now a one-line question.

actionName was a misnomer, and it made the change look risky
  Both workflow sites named the variable actionName and passed it to dispatchBuiltinBody,
  which reads as though changing it would move the serialized action name. It does not:
  that function uses the string ONLY in error text, and a node's action name comes from
  unit.Action().Name() at marshal. A display string was wearing an identity's name.
  Renamed to callSiteName at both sites, with a comment saying which it is.

Step 1 classified every fixture before anything changed
  Four expectations name a provider-qualified symbol; exactly ONE moves.
  test_imm_ui_print_replaces_builtin is script evaluation. The three file.* expectations
  and the flow.* one are execution errors and stay exactly as they are. "Runs a graph" is
  the mechanical discriminator -- every execution fixture calls t.run(graph).

  The change then confirmed the classification rather than merely being consistent with
  it: those four fixtures passed untouched, because they never reach these sites.

Tests assert both directions
  A promoted provider reports the bare name AND a qualified one keeps its qualifier --
  together, because a fix that made every name bare would satisfy the first alone. And
  flow.choose, flow.gather, and flow.failed must still resolve through BuildAction: a
  saved workflow says flow.choose and has to load.

  test_imm_ui_print_replaces_builtin pinned `ui\.print: got 2 arguments` deliberately,
  with a comment saying the fix to #710 had to come back through it. It did.

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.

Mark ui +devlore:root=true and sweep the ui.* call sites

1 participant