`required_purpose`'s invalid-UTF-8 refusal told the caller that their
process purpose "is displayed to the user in *workers* and in the
modeline". Neither is a process surface. Stage 1 deliberately keeps
processes out of `*workers*` — which lists async JOBS — and out of the
statusline activity indicator; a process's purpose is exposed through
`pmacs.process.list` and nowhere else, and joining the two planes is
Stage 2's work (framing §3, Q#W-4).
The refusal is correct and stays: a purpose that cannot be displayed
anywhere should still be refused, and nothing spawns either way. What
was wrong is the reason given to the user, which pointed them at two
places their process will never appear. A diagnostic that misdescribes
the system is worse than a terse one, because it sends the reader
looking in the wrong place.
The job-side twin diverges rather than converging. `_push_dispatch_name`
refuses a non-UTF-8 handler name for the same reason, and there
`*workers*` and the modeline are the RIGHT answer — the name is composed
into every job's purpose and a job renders in both. It said only "as
part of every job's purpose", which names no surface at all, so it now
names the two it reaches. The two messages must not collapse into one
sentence: whichever wording won would be wrong on the other side.
Verification. `the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches`
asserts both directions, positive AND negative — the process message
contains `pmacs.process.list` and NOT `*workers*`/`modeline`, the job
message contains both of those and NOT `pmacs.process.list`. The
negative halves are the anti-collapse guard; without them a later
"unify the wording" edit reintroduces exactly one wrong sentence and
passes every other test in the file. The existing row-table assertion in
`spawning_without_a_real_purpose_is_refused_and_starts_nothing` now runs
as far as the surface name too, so the same edit breaks two tests.
Three mutation checks, each red on its own claim: restoring the old
process wording fails both content assertions; collapsing the job
message onto the process wording fails only the new test (which is the
point — the old job test asserted the prefix alone and could not see
it); restoring the job message's original vague wording fails it too.
The doc comments were fixed with the literals. `required_purpose`'s
rustdoc now states which surface its message names and why it names
neither of the other two, and the `_push_dispatch_name` comment states
the converse. A corrected string whose doc comment still argues the
other way is one refactor from reverting itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Review round 2, findings P2a and P2b, plus P3's stale recovery
summaries. Three defects, and the fix is deliberately different in each
place because the constraint is.
## P2a — invalid UTF-8 bypassed the `purpose` diagnostic
`required_purpose` read the field with `value.to_str()?`. Lua strings
are BYTE strings, so `purpose = string.char(255)` is a value a caller
can write, and `?` surfaced mlua's generic conversion error BEFORE this
lane's own diagnostic was ever constructed: the caller was told neither
the field nor the rule.
**This is the third time this project has hit the class** — an unowned
Lua string converted with `?` ahead of the owned message; the
destination-capture lane corrected the same shape two rounds ago. It
refused before spawning and nothing leaked, so the defect was the
message, not the outcome. The conversion failure is now mapped onto
this function's own message, and the new acceptance row asserts on
message CONTENT so retyping the read as a bare `?` breaks the test
rather than silently degrading the error.
Auditing the rest of the lane's diff for the same class turned up
exactly one more: `_push_dispatch_name` took `name: String`, so a
registered handler name that was not valid UTF-8 failed at first
dispatch with mlua's generic message. It now takes `mlua::String` and
maps that failure onto an owned diagnostic naming the argument and the
rule. Those are the only two Lua-string reads this lane added; every
other binding it adds takes `()`.
## P2b — no safe display-text boundary. Two halves, two different fixes
### Handler names are refused at the source
`pmacs.workers.register` type-checked its name and nothing more, which
was defensible while the name died inside `dispatch`. It no longer dies
there: the ambient carries it into every job the handler allocates and
composes it into `purpose`, which `*workers*` and the modeline both
render. So it now gets `purpose`'s meaningful-value standard —
non-empty, not whitespace-only — plus control characters, which have no
legitimate place in a registered identifier.
### Purposes are ESCAPED at presentation, not rejected at the registry
A purpose may legitimately contain a newline: a filesystem path can, and
`pmacs-magit`'s spawn purpose is a whole argv. **This is the shape of
the `#228` decision, and it is consistent with it** — the one-line
constraint belongs to the surface that has it, not to the registry that
does not. There, `Command.description` stays free-form and the two
single-row consumers clip with `description_first_line`. Here the
equivalent is escaping rather than clipping, because a purpose's later
words are load-bearing: an argv's second word says which file, and a
clip would drop it silently.
`purpose_for_one_row` states the property it exists for: **a row must
not be able to forge another row.** It escapes `\n`, `\r`, `\t` and the
rest of the Unicode `Cc` class (which covers ESC, so a purpose cannot
open a terminal escape sequence either), borrows unchanged when there is
nothing to escape — making byte-identity structural rather than
asserted — and deliberately does NOT escape backslashes: no number of
them produces a second row, and doubling them would cost byte-identity
for ordinary text.
Two surfaces call it: the `*workers*` rows, and `ActivitySummary`, which
exists for one consumer that has exactly one row.
`pmacs.workers.snapshot()` is this lane's `describe-command` and stays
raw, which is what makes this a rendering decision rather than data
loss — asserted, not assumed.
## P3 — two stale recovery summaries
`docs/worker-identity-framing.md` still said "Implementation may
proceed"; it is implemented. `docs/active-work.md` still said Stage 1
takes the "first two" of owner/purpose/parent — `owner` was REMOVED in
revision 2, so it takes one of the three, and the claim the whole
`owner` argument overturned was still standing in the volatile state of
record. Both fixed section-locally.
## Verification
`tests/worker_identity_acceptance.rs`, 18 -> 24 tests:
* invalid-UTF-8 purpose refused by THIS lane's message, asserted on
content, alongside the absent / empty / whitespace / wrong-type /
metatable rows;
* a whitespace-only handler name and a control-character one are each
refused AT `register`, asserted on the error and on the handler not
being installed (dispatch reports `unknown handler`);
* a non-UTF-8 handler name is refused before the handler runs, with the
dispatch-name stack left empty;
* a purpose containing a newline renders as ONE row in `*workers*` and
as one line in the modeline — through the real rendering path, the
latter through a painted frame as well as the evaluator;
* **a purpose crafted to look like a row boundary does not produce a
second row** — asserted by counting rows, with the escaped text
asserted present so a renderer that dropped the purpose entirely could
not pass;
* a purpose with no control characters is byte-identical on both
surfaces, fixtured with a literal backslash, a literal `\v`, quotes
and a non-ASCII character.
Mutation-checked, seven guards, each failing its own test and no other:
the purpose UTF-8 diagnostic; the `_push_dispatch_name` one; the
register whitespace guard; the register control-character guard; the
`*workers*` call site; the `ActivitySummary` call site; and
`purpose_for_one_row` itself neutered to the identity, which fails both
surfaces' tests and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Review round 1 on worker identity Stage 1. The lane shipped `purpose` as
a required field on `ProcessSpec` but made it OPTIONAL at the
`pmacs.process.spawn` Lua surface, defaulting to `label`.
**That preserved compatibility and delivered nothing.** `COHERENCE.md`
§9's complaint about `ProcessSpec` is precisely that `label` is
"caller-supplied, unvalidated convention" — so a purpose defaulting to
the label hands every existing caller back the exact convention this lane
exists to replace. The approved framing said required; this makes it
required where callers actually are.
The two fields answer different questions and neither substitutes for the
other. `label` IDENTIFIES — `lsp:rust-analyzer`, a terminal's buffer
name — so two processes running the same binary can be told apart.
`purpose` DESCRIBES: it answers "what is happening", which is what §3's
promise of visible asynchronous work is about, and which a label chosen
for uniqueness routinely does not answer.
**The refusal covers five shapes, not one.** Absent; empty;
whitespace-only; wrong type; and metatable-provided. The middle two
matter because they satisfy the type and defeat the point exactly as
copying the label across would — R42 already rejects whitespace-only
`description`s in the config registry for the same reason, and a required
field that accepts `""` is not required in any sense a reader benefits
from. The read is RAW, matching the posture `stdin` and `group` already
document in the same function: a spec table is plain data, so `__index`
cannot smuggle a purpose in.
Every refusal also asserts **the process list is unchanged**. A
validation that rejects after spawning has already done the thing it was
rejecting.
**This is a BREAKING CHANGE to a public Lua API, taken deliberately and
now rather than later.** Weighed and reported rather than decided
silently: §10 grades extension trust "missing (one class)" and P7 package
lifecycle has not started, so the third-party population calling this
binding is ~zero and the cost of the change only rises from here. Checked
for a reason that would be wrong and found none — `pmacs.process.spawn`
has no API-reference documentation and no stability promise anywhere in
`docs/`; the guide's only mentions are an audit-rule classification and a
pointer to the bundled REPL, and its semver language governs *packages'*
own versioning, not pmacs's Lua surface. `lua_to_spec` has exactly one
caller, so the blast radius is this one binding.
Eleven executable call sites updated, each with a real description rather
than the label copied across — copying it would satisfy the type and
defeat the point as surely as the default did:
builtin/packages/repl/init.lua "interactive <interpreter> session"
builtin/runtime/compile.lua "compiling: <cmdline>"
builtin/runtime/lean.lua "checking the Lean toolchain version…"
tests/fixtures/pmacs-magit/status.lua the full argv, not just the
subcommand the label carries — "git
log" and "git log --oneline -20" are
one label and different work
tests/compile_mode_acceptance.rs (4), tests/m4_acceptance.rs (1),
tests/worker_identity_acceptance.rs (2)
`lean.lua`'s site is the clearest case for the field: its comment said
the label was where "a user wondering why their editor touched `lake`
finds an owner" — one string doing identity AND explanation, which is the
conflation being undone. The label stays a key; the purpose is now the
sentence.
Two references are deliberately NOT updated: `src/audit/mod.rs` and
`tests/m7_9_acceptance.rs` contain `pmacs.process.spawn("ls")` as **audit
fixture source text**. It is lexed by the audit engine, never executed,
and editing it would change what those rule tests scan.
`required_purpose` is extracted rather than inlined because inlining it
pushed `lua_to_spec` past the 100-line clippy bound — the validation has
its own rules and its own rationale, so it gets its own function instead
of an `#[allow]`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
`COHERENCE.md` §9 grades the worker model "mechanism without identity",
and §0 names step 11 (background-work ownership) as one of the two
remaining thin ends of the golden journey. The mechanism half is solid —
cancellation, supersession, streaming, frame-aware draining, `*workers*`.
The identity half was absent: `PendingJob` carried no description of what
it was doing, `pmacs.workers.dispatch` discarded the registered handler
name three layers above anything that takes one, and §9's "no progress
indicator exists anywhere" was checkable and true.
Framing: `docs/worker-identity-framing.md` (revision 4, approved).
What lands:
**A required `purpose`, on the job and on the process.** Non-optional,
with no `Default`, so the compiler — not a test — is what proves every
dispatcher supplied one. `allocate` / `allocate_with_resource` collapse
into ONE private `JobSpec`-taking funnel (Q#W-1): the two-function split
existed only because one prior lane needed one extra parameter, and a
second lane doing the same produces `allocate_with_resource_and_identity`.
`register_external` gains a `purpose` parameter rather than deriving one,
because its `JobKind` is `McpRequest`/`LspRequest` for every method — a
category, not a description.
**A dispatch-name ambient (Q#W-2), read at that same single funnel.** The
capture point is Rust, not the Lua wrapper layer, because a handler
reaching straight for `pmacs._async._dispatch_*` bypasses the wrappers
entirely — and those are precisely the callers attribution exists for.
Seven rules; the ones that decide whether it is honest:
- **Rule 1 — the extent is NON-YIELDABLE, and that is ENFORCED.** Both
supported yield APIs refuse inside it, modelled on the `commit_to`
refusal already in `async.lua`. The guards reject BEFORE parking and
reject UNCONDITIONALLY: one placed after `_is_complete` would fire only
when a yield really occurred, passing under test and failing
intermittently in production.
- **A raw `coroutine.yield` is NOT covered, and nothing here claims it
is.** R46 is a convention, and the scheduler inspects the yielded value
only after `coroutine.resume` returns — by which point the coroutine has
already suspended — so no refusal sited in a yield helper is ever
consulted. The residual is recorded in the framing §2 and in the
suite's module docs rather than papered over with a test that would
imply coverage this design lacks.
- **Rule 5 — unwind-safe.** A raising handler still pops. A version that
did not would let one failure poison every later dispatch in the session
with a stale name: the feature would stop failing loudly and start lying
silently. The bracketing also has to preserve the tail call it replaced:
`dispatch` was `return handler(args, opts)` and propagated EVERY return
value, so the pop/rethrow runs behind a varargs boundary rather than a
`local ok, result = pcall(...)` that would silently truncate a
multi-value handler. Varargs rather than `table.pack`, because that is
Lua 5.2 surface and LuaJIT is this project's default backend.
- **Rule 6 — compose, do not replace.** `"<name>: <purpose>"`, because
letting the dispatcher's purpose win loses the third party again and
letting the name win discards the only description of the actual work.
**A statusline activity indicator** — the fourth `pmacs.statusline.register`
adopter, after `mode`, `terminal` and `lsp`. A count plus the OLDEST
in-flight job's purpose ("busiest" is not a defined quantity; jobs carry
no cost estimate), and **absent entirely** when idle rather than a
zero-width segment that costs modeline width forever to say nothing is
happening. Gated by one setting, `ui.activity-indicator` (boolean, default
true, Q#W-6) — a permanently-visible modeline element is a preference
someone genuinely holds on day one. No setting for purpose capture
itself: that is substrate.
**NO WIRE CHANGE.** The indicator rides the existing `StatuslineSegments`
vector, so a fourth provider adds an element, not a variant.
`PROTOCOL_VERSION` and `ADVERTISED_PROTOCOL_VERSION` are untouched — which
is the property that lets this run beside the two lanes holding the bump
slot.
**Q#W-7 — a pre-existing defect, repaired here, and NOT one anybody has
observed.** `Handle:await()` refuses inside `pmacs.window.commit_to`
precisely so a coroutine cannot park with the frontend scope pushed
(Journey Stage 1a, Q#JR14b). But `pmacs.async.yield_to_next_tick()` also
yields, is public, and carried no such refusal — so that invariant had a
second entrance, and a coroutine could produce exactly the misrouting the
`await` guard exists to prevent. It gains both refusals here: the same
supported yield helper, the same invariant, the same edit family, so
splitting it would have preserved a known hole without reducing
integration risk.
**Reachability by a real caller is UNPROVEN.** This was found by reading
the guard family while scouting rule 1, not by reproducing a fault. No
production caller is known to yield through that door inside a commit,
and the test pins the guard rather than reproducing a user-visible bug.
Nobody should later cite this commit as evidence the bug was observed in
the wild. Its witness is a PAIR, like rule 1's: the refusal fires **and**
the commit scope is restored afterwards — a guard that raises while
leaving the scope pushed converts a silent fault into a loud one and
fixes neither.
`journey_acceptance` carries the established `commit_to` pins —
forged-destination refusal, scope-and-restore on normal return and on
raise, the await refusal, delivery to the requesting frontend. It passes
**untouched**, which is what says this closed a gap in Journey Stage 1a's
semantics rather than altering them.
What is deliberately NOT here, and why it is worth saying:
- **No `owner`, in any spelling** — not `origin`, not `subsystem` (§3).
Populated from static per-subsystem constants it would be an origin,
not an owner, and would confidently misattribute third-party work to a
builtin at exactly the point §9 wants attribution. A field that asserts
a falsehood is worse than an absent one. The slot stays empty until P3
can fill it with a real package signal.
- **No `parent`** (Q#W-5). An unpopulated field renders as `None`
everywhere and reads as "this job has no parent" rather than "this
system does not track parents". Stage 3 builds the lifetime model and
the field together.
Consequences worth recording:
- `ProcessSpec::new` takes a third argument. The 40-odd call sites are
almost all tests; the three production ones (LSP, MCP, terminal) supply
real descriptions. `pmacs.process.spawn`'s Lua surface keeps `purpose`
OPTIONAL, falling back to the label — requiring it there would break
every existing caller for no coverage the compiler is not already
providing, and a caller's own label is not a fabrication.
- `pmacs.process.list` gains a `purpose` KEY on each row and enumerates
exactly the same processes (Q#W-4). Terminal PTYs stay hidden: three
acceptance suites use `#pmacs.process.list()` as a leak baseline, and
widening the accessor would inflate all three. Stage 2's unified view
owns that decision.
- `statusline_segments_acceptance`'s builtin-provider inventory grows to
`["activity", "mode", "terminal", "lsp"]`. That assertion exists to
grow when a builtin provider is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai