The Submit Surface
Frozen contract for the submit-surface wave, decided 2026-08-11 in the central design thread, amended the same day by the composition-polish wave whose decisions are recorded in What The Polish Wave Changed, amended again by the DX wave in What The DX Wave Changed, a third time by the lane-catalog wave in What The Lane-Catalog Wave Changed, a fourth by the refusal wave in What The Refusal Wave Changed, a fifth by the one-register wave in What The One-Register Wave Changed, and a sixth by the job-ergonomics wave in What The Job-Ergonomics Wave Changed. Evidence lives in the porting ledger (five entries, all reshaping the same seam) and in a keel-era hardware workflow whose error paths leaked running children at roughly fifteen early-return sites. This page is the authority for every wave; deviations an implementer finds necessary are recorded here, named as deviations.
The polish wave was taken while the surface was one day old and had no external adopters, which is the only window in which breaking it costs nothing. The body below is the surface as it now stands, not as it first shipped.
What The Wave Delivers
Section titled “What The Wave Delivers”- Typed lanes. A
#[harmos::lanes]catalog is the one authority for the boot-frozen lane set. One enum carries one variant per lane, and itscatalog()set the assembly consumes verbatim, so the set cannot drift between job declarations and boot. A job names its lane on its own#[harmos::job(...)]attribute —lane = Lanes::Xorlanes = Lanesfor the catalog's default — soWork::submit,Scope::submit, andSimulatedRuntime::submittake no lane argument at all; submitting a job whose lane nobody declared stops being a runtime error and becomes a compile error, and the undeclared-lane refusal is deleted. - Universally scoped attempts. Every
Errand::attempttakesScope<'_>as its required parameter, and the runtime constructs one per attempt. Children started through the scope are cancelled on any attempt exit — return, error, deadline, cancellation from outside — and a retry's next attempt starts only after the previous attempt's children are drained to termination. ExplicitJobHandle::cancelstays, sync, for early teardown. The detachedWork::submitstays for work meant to outlive the attempt, visibly spelledwork.rather thanscope.. - The self-lane refusal. A submission from an attempt to its own serial
lane is refused as
Refusal::SelfLaneat the seam: awaited it deadlocks, unawaited it queues behind a parent whose scope close will cancel it before it runs. Either way the submission is a mistake the runtime can name immediately instead of a silent hang that reportsTimedOutten minutes later. Aconcurrent(n)lane is not refused — the deadlock there needs n holders and is not knowable from one submission — and the work chapter states the residual rule. - Per-submission policy.
Job::under(policy)replaces the job's declaredJobPolicyat the call site. The declaration stays the default; one operation needing two budgets no longer needs two declarations. - The cause at the seam.
Job::caused_by(position)names the recorded fact a submission answers. The attempt readsscope.journal(), a journal handle already derived on that cause, so its facts join the causal family without the author re-deriving it. A scope hands its cause down to every child it starts, andcaused_byon a child re-roots that child and everything beneath it. - One dialect, one seam.
submitacceptsimpl IntoJob. A job is a struct whose fields are its arguments;#[harmos::job(...)]on the struct names its lane and policy, and a plainimpl Errandnames the outcome, the error, and the one attempt. There is no second, stateless dialect: the struct receives the attempt's scope exactly as every job does. Serde and job ids are deliberately absent: a durable, wire-shaped command is a transaction the journal records, answered by submitting a job — never the job itself. - One verb on the scope.
scope.submit(job)routes a child onto the lane its own attribute names — a specific variant, queued for that variant's capacity, or the catalog's#[default]variant, which is always unbounded and never queues. Admission is a property of the job, not of which verb submitted it; an observer is simply a job that names the default.
Lane Vocabulary
Section titled “Lane Vocabulary”#[harmos::lanes]pub enum Lanes { /// Everything that names no specific lane. #[default] Open, /// Long-running orchestration, one at a time. #[lane(serial)] Workflow, /// One hardware command at a time. #[lane(serial)] Device,}let runtime = Runtime::<Device>::builder((), ()) .register(DeviceStreams::catalog()) .register(Lanes::catalog()) .runtime() .await?;- Lane names are the application's idents, and the enum is where they belong:
app.rs, beside the identity card and the declaration that freezes them. A variant is spelled for the work it admits and not suffixed, becauseLanes::Devicealready says lane once. - The set itself is answered by the catalog's own
catalog(), so the lane line reads like every other declaration line beside it — a lane set is closed the way a change catalog is, down to the verb, and both now register through the very sameBuilder::registercall. Catalogs fold rather than collide:registerextends, so a compiled-in feature crate declares its own lanes and the assembly passes both. - Capacities are
serial,concurrent(n), andunbounded. Every catalog carries exactly one#[default]variant instead of a fourth capacity keyword, and the compiler assigns itunbounded— a job that names no specific variant (lanes = Laneson its attribute) lands there, starts now or refuses now, and never queues, which is what the deletedScope::spawnused to guarantee by taking no lane argument at all. A catalog may still spell a named#[lane(unbounded)]variant beside the default, for detached work a submitter should see named rather than land on anonymously. Either way the bound is not structural, and the rustdoc says so: a bounded lane's queue is the runtime's back-pressure, and declaringunbounded— by name or by default — is a job saying it will supply its own.
The Scope, And What It May Never Carry
Section titled “The Scope, And What It May Never Carry”Scope carries only what the runtime alone knows about the attempt: its
lifetime (child ownership), its admission lane (the SelfLane refusal), its
cause (scope.journal(), and what its children inherit), and the one value the
application declared as Resources and the assembly passed at boot
(scope.resources::<A>()). Anything else an author could capture arrives
through arguments, exactly as today.
The distinction is the whole guard-rail, and it is a keyed lookup versus a named
one. scope.resources::<A>() borrows exactly one value of exactly one type,
named on the application's own identity card, so there is no key to get wrong and no
"did someone register this?" to answer at runtime. The day
scope.resource::<T>() compiles — a lookup keyed by whatever type a caller
happens to name — this design has failed; that door is the reason keel's
Context grew into a service locator, and it stays shut.
There is no flag electing the shape. Every attempt receives its scope, a leaf
writes _scope, and writing scoped is a compile error naming where the scope
actually is. The macro still never sniffs a parameter to decide anything — it
sees tokens, not types — because there is no longer a decision to make: the
scope is a named parameter of Errand::attempt by rule, and the compiler
holds it to Scope<'_> where the generated impl calls the helper.
Target API
Section titled “Target API”/// One endurance pass: boot-cycle the tool `samples` times, record each recovery.#[harmos::job(lanes = Lanes, retries = 0, timeout_ms = 600_000)]pub struct ConnectionLoop { pub tool: Tool, pub samples: u16,}
impl Errand for ConnectionLoop { type Outcome = Report; type Error = Error;
async fn attempt(&mut self, scope: Scope<'_>) -> Result<Report> { // Observers — named on the catalog's default lane, so they start now; // scope-owned, so they end with this attempt. The handle gets the // meaningful name. let dialogs = scope.submit(WatchDialogs { tool: self.tool.clone(), priority: Priority::confirm_first(), });
// Mutators — construct, then run; the outcome gets the meaningful name. scope.submit(WaitUntilReady { tool: self.tool.clone() }).await?;
let start = scope.submit(TakeMeasurement { tool: self.tool.clone(), what: Measure { fast: false, only_angles: false }, }).await?;
// Everything that shapes the job stays on the construction line. let job = PostProcess { report: report_root()? }.under(patient()); scope.submit(job).await?;
Ok(report(start)) }} // scope closes: observers cancelled here, on every exit path above/// A leaf: it starts no children, and it says so by naming the scope `_scope`.#[harmos::job(lane = Lanes::Device, retries = 2, timeout_ms = 30_000)]pub struct TakeMeasurement { pub tool: Tool, pub what: Measure,}
impl Errand for TakeMeasurement { type Outcome = Reading; type Error = Error;
async fn attempt(&mut self, _scope: Scope<'_>) -> Result<Reading> { self.tool.measure(self.what).await }}/// The one dialect: attempt 2 resumes where attempt 1 died.#[harmos::job(lane = Lanes::Device, retries = 3, timeout_ms = 30_000)]pub struct DrainBacklog { tool: Tool, drained: Vec<Row> }
impl Errand for DrainBacklog { /* attempt(&mut self, scope: Scope<'_>) */ }
let report = scope.submit(DrainBacklog { tool: tool.clone(), drained: Vec::new() }).await?;The one-statement canon. One statement is the norm: the job's own
attribute already names its lane, so construction and submit are one
expression. Split into two — let job = …; then submit(job) — only once an
override (.under, .caused_by) would otherwise stretch the construction
line past a glance. When it splits, the scratch name is always job;
meaningful names go to handles and outcomes, and rebinding job is
deliberate: a reader never wonders whether an old job value is still live.
Guide and examples follow the canon — Run Work states
the full nesting table it implies; the application skill states the canon itself.
Ruled Forks
Section titled “Ruled Forks”- A refused, never-awaited child must not let its attempt report success. Refusals stay folded into the handle as today, but a scope that closes over an unobserved refusal fails the attempt with it. An observer that never existed is not an attempt that succeeded. The mechanism is the implementer's within the census budget; the property is the contract.
- Retry drains. The next attempt starts only after the previous attempt's children terminated — deterministic under simulation; the backoff sleep may overlap the drain, admission may not.
- No
until_cancelled. Every job keeps a declared deadline; a child's effective lifetime is bounded tighter by its scope. Observers declare their parent's budget as their deadline, and the guide says so. An unbounded deadline is a lie the first hung tool exposes.
Residents Serialize, Lanes Admit
Section titled “Residents Serialize, Lanes Admit”One rule for the question every application asks once it has both: should this be a serial lane or a resident?
Residents serialize resource access; lanes bound work admission. If a serial lane exists only because a device cannot take two commands at once, the serialization belongs to the resident that owns the device; the lane is then a throughput choice, not a correctness one.
The reviewer test is one question: widening the lane must corrupt nothing.
Change serial to concurrent(4) and read what breaks. If the answer is
"more work runs at once, and some of it waits longer" — the lane was a
throughput choice and the declaration is honest. If the answer is "two commands
reach the instrument" — the lane was carrying an invariant it cannot enforce,
because a lane is a queue and a queue is not a lock. Move the invariant to the
resident that owns the device, and let the lane go back to being a number.
A lane cannot enforce the invariant for two reasons worth stating. It admits attempts, not operations: an attempt that hands work to something else and awaits the answer holds its slot without holding the device. And a lane is declared once at boot, where nobody is looking, while the resident that owns the device is the one place a second command has to pass through.
What The Polish Wave Changed
Section titled “What The Polish Wave Changed”Recorded 2026-08-11, the day after the surface above froze and with no external adopter yet on it. Five decisions, each replacing something the body already said:
- The
scopedflag is deleted; every attempt is scoped. Two shapes meant two contracts (ErrandandScopedErrand), two constructor pairs, and a flag whose only job was electing between them — and the election was one an author got wrong in exactly the case that mattered, the leaf that later grew a child. Composition is what a job does, so the seam is handed to every attempt and a leaf writes_scope.ScopedErrandis deleted from the census,Job::scoped/scoped_withwith it, and writingscopedis a compile error that names where the scope is instead. Scope::spawn(job)is the observer verb. Submitting an observer meant naming a lane for something that must never queue, which is why the frozen body had to inventWatchLane: unboundedand then argue that its bound was structural. Spawning is not admission: no lane argument, starts now or refuses now, scope-owned, and the handle is not meant to be awaited for a value. Everything else is the submit rule verbatim — teardown, drain, inheritance, and the unobserved-refusal failure.Job::answeringisJob::caused_by, and so isJournal::correlate. Correlation is a symmetric word for a directional mechanism: the parameter is already namedcauseand the thing it builds is already called the causal family. Both carriers now spell the same concept the same way — consume a carrier, answer the version of it that sits inside one family — so a reader who has met either has met both. "Correlation" survives as the noun for the family;correlateas a verb does not.- A scope's cause flows down until someone re-roots it. Naming a cause on
every child by hand was the correlation-threading the wave existed to
delete, one level down. A scope carries its attempt's cause; children
submitted or spawned through it inherit that cause; because each child's
own attempt opens a scope carrying what it inherited, the cause reaches
grandchildren without anything walking a hierarchy.
.caused_byon a child re-roots it and everything beneath it. Detachedwork.submitinherits nothing — inheritance is a property of the scope, and a detached submission is precisely the one that has none. - The lane/resident principle is written down, above, with its reviewer test.
What The DX Wave Changed
Section titled “What The DX Wave Changed”Recorded 2026-08-11, the same day as the polish wave and against the surface it left. Three decisions, none of them breaking: each one deletes a ceremony the worked examples were performing in front of the reader.
-
under = …declares a named policy on both attributes.#[harmos::job(under = patient())]and#[harmos::errand(under = …)]take one expression resolving to aJobPolicyas the declared default; the macro pastes it into thedeclared_withpath and the compiler holds it to the type, exactly as a job's return type travels. The reason is the same duplicationJob::underalready answered one level up: the moment a second job wants a budget, four numbers repeated on two attributes is a policy with no name and two copies. Now the name is the policy —fn patient() -> JobPolicydeclared once, spent at the attribute and at.under(…)call sites, the same word for the same value.underis mutually exclusive with every flat knob (retries,timeout_ms,backoff_base_ms,backoff_cap_ms), and mixing them is a compile error whose message names both forms. A preset silently winning over a knob an author wrote is the one outcome a declaration must not have, which is the rule that already refused a backoff base with no cap. The flat shorthand is untouched and stays the common-leaf idiom: a leaf whose budget is nobody else's business says so in four words, and a name earns itself when a second caller wants the same one. -
Scope::now()is the attempt's clock. An attempt measuring a duration reaches forstd::time::Instant::now(), which compiles, runs, and silently measures wall time under a simulation — the number is microseconds wearing the name of an hour, and nothing fails to say so. The scope answers the clock the runtime already sleeps and times out on, so a measurement and the deadline beside it are in one currency. Elapsed time is subtraction:scope.now() - earlier.The guard-rail holds. Which clock is in force is a fact about the attempt that only the runtime knows — the same test
journal()and the lane passed — so it belongs onScopefor the reason those do. It is a reading rather than a declaration: it takes no type parameter, because there is nothing for an application to have named. The keyedscope.resource::<T>()is as absent as it ever was.No census name. The answer is the work clock's own instant type,
tokio::time::Instant, returned as-is. The core crate already hands atokio::sync::watch::Receiverback fromWork::servicesandStreams::demand; a clock reading is the same kind of borrowing, and an application never has to spell the type —let started = scope.now();and a subtraction are the whole idiom. The seam audit (WORK-AC009) grewtokio::time::Instant::nowas a forbidden crossing insidework/, because reading the clock past the seam is the same bypass as waiting on it there. -
The child-await bridge is the application's, and it is documented rather than built. The endurance walkthrough mapped child-await errors to
Stringto get out of its own function, which is the teaching example demonstrating the wart. The fix is a per-applicationimpl From<JobError<ChildError>> for Error, soscope.submit(lane, job).await?compiles bare, and it stays an application's five lines rather than runtime machinery: the generics do not force a helper, and a blanket impl inharmoswould have to choose for every application whatFailedmeans in its vocabulary.The canon those five lines encode:
Failedunwraps rather than wraps — a child that refused on its own terms already said why, and "a child job failed" over the top of that says strictly less than the child did — and everything else keeps its type, because a deadline, an external cancel, and an inadmissible submission are the runtime ending the child rather than the child ending itself.Boxbreaks the recursion the shape implies: the error type names aJobErrorover itself, so the value needs an indirection to be sized, and rendering it terminates because no value of the type can be an infinite one.The concrete
From<JobError<Refusal>> for Refusalwas taken over a genericimpl<E: Into<Error>> From<JobError<E>> for Error. Both cohere; the concrete one is what a reader can hold in their head, one impl per child error type is a bill nobody has trouble paying, and a teaching example should not spend its reader's attention on coherence reasoning. Census delta: zero.minibench-appgrewsrc/error.rsand athiserrordependency, which is the shapeschematicandautoroutealready have.
What The Lane-Catalog Wave Changed
Section titled “What The Lane-Catalog Wave Changed”Recorded 2026-08-12, against the surface the three sections above left. One decision, and it breaks: the lane set is closed by an attribute on the application's own enum rather than by a function-like block that emits types.
-
harmos::lanes!is#[harmos::lanes], and a lane is a value. The old block emitted one unit type per lane plus a fixed-nameLanesowner answeringdeclared(). It was the only declaration in the surface that worked that way: a change catalog, a fact catalog, and a stream catalog are all an attribute on an enum the application wrote, answeringcatalog(). A lane set is the same kind of thing — a closed list frozen at boot — so it is closed the same way, and the reading at the assembly is now literally the line beside it:.register(Transactions::catalog()),.register(Lanes::catalog()).Lanegoes value-based with it:const NAME/const CAPACITYon a zero-sized type becomefn name(&self)andfn capacity(&self)on the enum, answered by one generated match per catalog. What the trait is for is unchanged, and so is the property the first wave bought —submittakes a lane, never a string key, andJobError::UndeclaredLanestays deleted. What moved is where the compile-time miss comes from: it was a type nobody declared, and it is now a variant nobody declared, which is the same guarantee spelledE0599instead ofE0412.Three consequences worth naming, because each replaces something the body above said before this wave amended it:
- The fixed name is gone, and with it the one-block-per-module rule. The
enum's name is the application's;
Lanesis house style, not a requirement. Two catalogs no longer collide —Builder::lanesfolds — which is not a regression but the composition story arriving where it was always going to be needed: a compiled-in feature crate declares its own catalog and the assembly passes both. An application with two catalogs of its own has aapp.rsit has not finished writing, and no macro can tell it so. - Variants lose the
Lanesuffix. House style suffixed the unit types because a bareDevicein a module of application types said nothing about what it was. Namespaced through the enum it does:Lanes::Devicesays lane exactly once, and the suffix was the cost of a per-lane type name. - Validation splits the way it already did, and now says so. A
#[lane(concurrent(0))]literal is refused at expansion, where the author wrote it; a malformed capacity is refused there too, by a message naming all three accepted forms. Duplicate and empty names stay boot's, because only boot sees the whole folded set.
Census delta: zero.
Lane,LaneSet, andCapacitykeep their names and the authoring list keepslanes— it changed family, from function-like to attribute, which is a spelling the macro namespace does not count. - The fixed name is gone, and with it the one-block-per-module rule. The
enum's name is the application's;
Boundary. A direct Wasm guest cannot contribute a lane after boot: lane sets are compiled application catalogs and are frozen before artifact activation. A guest instead uses the host capabilities the application explicitly offers.
What The Refusal Wave Changed
Section titled “What The Refusal Wave Changed”Recorded 2026-08-12, against the surface the four sections above left. One decision, and it breaks: the awaited answer is the application's own error type, and the runtime's refusals are a non-generic enum an application absorbs.
-
JobError<E>is deleted;RefusalandAbsorbsRefusaltake its place. The combined type mixed two facts — the job's own error inFailed(E), and the runtime's own in every other variant — and being generic over the application's error is what made the mixture expensive. The orphan rule putimpl From<JobError<E>> for Eon the application's side of the wall, so every consumer owed a bridge harmos could not write for it, and most discovered the debt through a?that would not compile with no note saying what to add.Refusalis the runtime's half alone:TimedOut,Cancelled,SelfLane,UndeclarablePacing,Unavailable— the same five variants under the same five messages, minus the parameter. Non-generic is the whole point: an application absorbs it with one derived variant,#[error(transparent)] Refused(#[from] harmos::Refusal), and the conversion isthiserror's to write.submit(...).awaittherefore answersResult<O, E>. A job's own error is handed back untouched — theFailed(e) => eunwrapping every application used to write is now written once, inside harmos, where it was always the same line — and a refusal converts throughE: From<Refusal>. -
The bound is named, so the diagnostic can teach.
Fromis a foreign trait and cannot carry#[diagnostic::on_unimplemented], so the bound both submit seams state is a harmos-owned supertrait with a blanket impl:pub trait AbsorbsRefusal: From<Refusal> {}#[diagnostic::do_not_recommend]impl<E: From<Refusal>> AbsorbsRefusal for E {}do_not_recommendon the blanket impl is load-bearing: without it rustc drills into the nestedFrom<Refusal>obligation and reports the impl chain, and with it the message stays on the sentence an application author can act on. An error type missing the conversion reads:error[E0277]: a job's error type absorbs the runtime's own refusals= help: the trait `AbsorbsRefusal` is not implemented for `MyError`= note: add the one derived variant to it:`#[error(transparent)] Refused(#[from] harmos::Refusal)`The bound sits on
Work::submit,Scope::submit,Scope::spawn,SimulatedRuntime::submit,SimulatedRuntime::settle, and theFutureimpl onJobHandle— the last because a dropped runtime answersRefusal::Unavailableat poll time and cannot pre-compute it. It sits on neitherIntoJobnorErrand: a bound there would fire inside macro expansion, and the whole value of this shape is that the error points at the line the author wrote. The macros are untouched by this wave, which is the check that confirms the placement. -
The private
Refusedtwin is deleted with it.board::Refusedexisted because a scope records refusals across children whose error types differ, and the record therefore could not be aJobError<E>.Refusalis not generic, so it is the record, and the conversion happens where an error type is in hand. One type crosses the seam where two used to.Worth naming honestly:
Refusedcarried the three submission refusals andRefusalcarries five, soClosedScope::unobservednow structurally admits aTimedOutor aCancelledthat cannot arise there. Unreachable by construction, and one type beats two types plus a conversion between them.Census delta: +1.
JobErrorleaves,RefusalandAbsorbsRefusalenter, so the core surface is 100 names and work's share is twenty-six.minibench-appkeeps itsthiserrordependency and spends it on one derived variant instead of a hand-written impl.
What this deletes from the record. The DX wave's third decision — the
documented From<JobError<ChildError>> bridge pattern, its Failed-unwraps
canon, and the Box that made the recursive variant sized — described an
application obligation that no longer exists. That section stays above as history,
and this is the notice that it is history: there is no bridge to write, no
recursion to break, and no allocation on the failed path.
What The One-Register Wave Changed
Section titled “What The One-Register Wave Changed”Recorded 2026-08-12, against the surface the four sections above left, and
against the same-day lane-catalog and refusal waves it lands beside. One
decision, and it breaks: Builder::streams and Builder::lanes are deleted;
every catalog — transaction, stream, and lane — registers through
Builder::register alone.
Registration<A>answers one question instead of three. The trait used to carry a transaction catalog's own shape —definitions/decode_transaction/inverses— because that was the only kindregisteraccepted; a stream catalog and a declaredLaneSethad their own verbs instead.Registration::registernow answers a single closed question — which kind is this, and what does its fold need — throughharmos::Catalog<A>:Catalog::Transactions { definitions, decode, inverses },Catalog::Streams(Vec<Definition>), orCatalog::Lanes(LaneSet).Builder::registermatches on the answer and folds it into the same builder state the verb it replaces reached. AVec<Definition>, a[Definition; N], and aLaneSetimplement the trait directly, so the stream and lane authoring macros needed no codegen change — only the transaction/fact catalog macro's generatedRegistrationimpl moved from three methods to one.- Per-kind boot refusal is unchanged. A conflicted stream catalog — a shared id, a shared row type, a zero-row window — still refuses the boot exactly as before; a duplicate, empty, or zero-width lane still refuses exactly as before; an undeclared transaction version still refuses replay exactly as before. Only the declaration call changed, not what boot accepts or rejects.
- Simulation follows the same builder. Its repeatable recipe captures each
Builder::registercall, so simulated reboot needs no second public builder or parallel registration API. The inverse-table peek the round-trip oracle reads is taken fromCatalog::Transactionsrather than a bareC::inverses()call.
Census delta: +1. Catalog<A> enters as the twelfth composition name;
nothing leaves, because a method deletion is not a census item.
What The Job-Ergonomics Wave Changed
Section titled “What The Job-Ergonomics Wave Changed”Recorded 2026-09-15, against the surface the five sections above left, decided in the job-ergonomics design thread. Nine decisions, and it breaks: the fn dialect and the observer verb both leave the surface entirely, and the lane moves from the submit site onto the job.
- One authoring dialect. A job is a struct whose fields are its
arguments,
#[harmos::job(...)]sits on the struct, and the author writes a plainimpl Errand for Xwith no attribute of its own. The fn dialect —#[harmos::job]on anasync fn, itsidentity/job.rsargument rules, its owned-ident checks, its per-attemptClone, theJobResultprojection trait, and the fn-dialect compile-fail fixtures — is deleted. So is the blanketimpl Errand for FnMutclosure impl; tests that used a closure become small structs. The struct attribute's own name — the old#[harmos::errand]on the impl block — is deleted with it, because there is exactly one attribute now and it lives on the struct; the trait itself keeps the nameErrand. - The lane is declared on the job, not the submit site.
#[harmos::job(lane = Lanes::Export, retries = 3, timeout_ms = 30_000)]travelslane = <expr>as an unparsedsyn::Expr, exactly asunder = <expr>already does.retries,timeout_ms, the twobackoff_*knobs, andunderkeep their existing semantics and mutual-exclusion rules unchanged. - Every lane catalog has exactly one default variant.
#[default]marks it, it is alwaysCapacity::unbounded(), and combining#[default]with an explicit#[lane(...)], or declaring zero or two#[default]variants, is a compile error.#[harmos::lanes]also emitsimpl Default for Lanes. Because the macro cannot infer which catalog a job belongs to, the attribute takes exactly one oflane = <expr>(a specific variant) orlanes = <path>(meaning<Lanes as Default>::default()); giving both or neither is a compile error reading "a job names its lane or its lane catalog." Job<O, E>carries its lane.Job::declared(retries, timeout_ms, lane, errand)andJob::declared_with(policy, lane, errand)both grow the lane-name parameter the generatedIntoJob::into_jobsupplies from the attribute'slane/lanesargument..under(policy)and.caused_by(position)remain builders onJob, and both land onIntoJobas well, as provided methods that callinto_job()first, soscope.submit(X { .. }.under(patient()))compiles without an intermediateJob.- One submit verb per seam, and it takes no lane.
Work::submit<J: IntoJob>,Scope::submit<J: IntoJob>, andSimulatedRuntime::submit<J: IntoJob>all read the lane offjob.lanerather than a parameter.Scope::spawn,Board::spawn, and the board's hiddenspawnedadmission are deleted outright — not deprecated, deleted — because the shape the deleted verb existed for is now what naming a catalog's default lane already does: admitted at once, never queued.ScopeCore::startdrops itsOption<&'static str>parameter as a result. The self-lane refusal is unchanged: a submission is refused only when the child's lane equals the parent's own admission and that admission is serial, which a default lane — always unbounded — can never be. - Nothing else in the runtime model moves. Cause inheritance, child
cancellation on scope close, drain-before-retry, unobserved-refusal
failing the attempt, the
observedwitness, theRefusalvariants,AbsorbsRefusal,JobPolicy, and the sim trace bytes are exactly what the refusal and DX waves left them. - Jobs stay application-bound;
Job<O, E>as a value stays application-free. A job names its application'sLanescatalog by construction, the same coupling the fn dialect always had; the runtime's ownJob<O, E>carries no application parameter, unchanged from every wave above. - The nesting table is the reference for admission, restated in Run Work: a parent's lane and a child's lane together decide refusal, queueing, or immediate admission, and "any lane into the default" is the row that used to need a separate verb.
- Clean break. No compatibility shim for the deleted fn dialect, no deprecated alias for the deleted observer verb, no dual-dialect period: the same forced-patch phase every prior wave in this record used, because the surface still has no external adopter it must not break under.
Census delta: not fixed by this design page — the concurrent implementation
records the exact number in crates/harmos/tests/census.rs, which this page
does not own; the fn dialect's JobResult trait leaves and IntoJob's wider
surface lands there, not here.
What This Wave Does Not Do
Section titled “What This Wave Does Not Do”- No parent-child relation in runtime state, no propagating hierarchy, no shutdown ordering between jobs: the scope is the whole composition model, and its lifetime is one attempt.
- No serde on jobs, no job ids, no registry: durable meaning is the journal's.
- No resource access on
Scope, per the guard-rail above. - Cancellation stays cooperative: a mutator cancelled mid-flight (parent deadline, external cancel) stops at its next await and the device state is whatever it is — the same exposure as today's deadline expiry, now stated.
- No dual dialects, no fn-based job, no closure
impl Errand, and no compatibility shim for either: a job is a struct with#[harmos::job(...)]on it andimpl Errandbeside it, full stop. - No lane argument at the submit site: the lane is a fact about the job, not a fact about the call, and the deleted observer verb was not a smaller version of that fact — naming a catalog's default lane already says what it used to say.
Breaking Changes
Section titled “Breaking Changes”From the first wave: Work::submit takes a typed lane; JobError::UndeclaredLane
is deleted; applications and examples re-spell their lane strings as lanes!
entries.
From the polish wave: every #[harmos::job] function grows a required
Scope<'_> first parameter and every Errand::attempt grows a Scope<'_>
argument; the scoped flag is a compile error; ScopedErrand,
Job::scoped, and Job::scoped_with are deleted; Job::answering is
Job::caused_by; and Journal::correlate is Journal::caused_by — the last
of which reaches consumers that never touched the work layer.
From the lane-catalog wave: harmos::lanes! is deleted in favour of
#[harmos::lanes] on the application's own enum; Lanes::declared() is
Lanes::catalog(); Lane's two associated consts are the two methods
name and capacity; and every submit site names a variant rather than a
type. No shim and no deprecation path — the surface is one day past the DX
wave and still has no external adopter, which is the window in which a
declaration can be re-spelled for free.
From the refusal wave: JobError<E> is deleted; submit(...).await answers
Result<O, E>; every job's error type must implement From<harmos::Refusal>,
which one #[error(transparent)] Refused(#[from] harmos::Refusal) variant
supplies; and the hand-written impl From<JobError<E>> for E every application
owed is deleted rather than deprecated. An application that had written the bridge
deletes it and adds the variant, which is a smaller edit than the one it
replaces.
From the one-register wave: Builder::streams, Builder::lanes, and the
simulation-only builder are deleted; every catalog registers through
Builder::register instead;
Registration<A> sheds definitions/decode_transaction/inverses for one
register method answering harmos::Catalog<A>. No shim and no deprecation
path — the same window the lane-catalog wave used.
From the job-ergonomics wave: the fn dialect is deleted — every #[harmos::job]
function, its identity/job.rs argument rules, its JobResult projection
trait, and its compile-fail fixtures are gone, along with the blanket
impl Errand for FnMut closure impl; a job is now exclusively a struct
carrying #[harmos::job(...)] with a plain impl Errand beside it, and the
struct-impl attribute that used to carry the same knobs is deleted with it.
The lane moves onto the job: the attribute takes exactly one of
lane = <expr> or lanes = <catalog>, omitting or doubling them is a
compile error, and Work::submit, Scope::submit, and
SimulatedRuntime::submit drop their lane parameter accordingly —
Job<O, E> carries lane: &'static str instead, and
Job::declared/Job::declared_with grow it as a parameter.
#[harmos::lanes] requires exactly one #[default] variant per catalog,
always Capacity::unbounded(); combining #[default] with #[lane(...)],
or declaring zero or two of them, is a compile error, and the macro emits
impl Default for Lanes alongside catalog(). The scope's own observer
verb and the board's hidden admission for it are deleted; the behaviour it
gave an observer — start now or refuse now, never queue — is what landing on
a catalog's default lane already does, so an observer is a job that names
lanes = <catalog> instead of a different verb. ScopeCore::start drops its
Option<&'static str> parameter; the self-lane refusal rule itself is
unchanged. No shim and no deprecation path — the same forced-patch phase the
lane-catalog and refusal waves used.
All six are within the 0.2.x forced-patch phase; consumers repin on their own schedule via the request ledgers.
Deviations
Section titled “Deviations”Found necessary during implementation (2026-08-11), recorded per this page's own rule. Everything above stands as written except where named here. The last three are the polish wave's.
-
Scopewas already taken, and the undo predicate yielded it. The census exportedScope<A>as the predicate narrowing undo and redo, and one word means one thing — two types cannot shareharmos::Scope. This page's target API spellsScope<'_>,scope.submit, andscope.journal()throughout, so the frozen surface was kept exactly as written and the undo predicate was renamed toFocuseverywhere it appears (surface, tests, examples, guide, vocabulary). That rename is a breaking change beyond the list above. -
The old
Lanecapacity struct is nowCapacity. The contract claims "lane" for the typed thing a submission routes on, so the trait thelanes!block implements isLaneand the serial/concurrent/unbounded value it freezes becameCapacity. Same forced-patch phase, same re-spell. -
scope.journal()names the application explicitly. AJob<O, E>carries no application parameter by design — both authoring dialects emit application-free values — so the scope cannot be generic over the application either, and the handle is answered asscope.journal::<A>()(or through an annotated binding). The runtime holds the one journal a scope could answer; naming a different application is a wiring bug and panics rather than failing quietly. -
Cancellation of a running attempt now drops it at its next await. The cooperative-cancellation ruling above ("stops at its next await") is incompatible with the previous behavior, which let a cancelled running attempt settle until its own deadline — under that rule a scope drain would stall for its slowest child's full deadline on every teardown. Prompt drop is what makes teardown and drain-before-retry immediate; the exposure — a mutator stopped mid-flight leaves the device however the operation left it — is unchanged and now stated in the guide.
-
The bare-closure errand receives no scope. "Every attempt receives the scope" holds for both authoring dialects and for the erased contract the runtime drives; it does not hold for the
FnMut() -> Futblanket that makesJob::declared(0, 100, || async { … })compile. A closure takingScope<'_>would have to return a future borrowing its own argument, which onlyAsyncFnMutexpresses — andAsyncFnMut::CallRefFutureis unstable, so theSendboundErrandrequires of every attempt could not be written. The blanket therefore takes the scope and drops it, the rustdoc says why, and a closure that wants its scope is written in one of the two dialects. Deleting the blanket instead was the alternative considered and refused: it would take the ad-hoc job form away from every test and example without buying a property the dialects do not already carry. Superseded by the job-ergonomics wave (2026-09-15): the blanket is deleted along with the fn dialect, and every job is a struct that receives its scope. -
Errand::attemptelides its lifetimes rather than naming one. The deletedScopedErranddeclaredfn attempt<'s>(&'s mut self, scope: Scope<'s>), which only macro-generated code ever implemented. The merged contract has to be implementable by hand, asasync fn attempt(&mut self, scope: Scope<'_>)— and anasync fndesugars to two elided lifetimes, which do not match a declaration that names one (E0195). So the trait method is declaredfn attempt(&mut self, scope: Scope<'_>) -> impl Future<…> + Send, and the erasure the retry loop drives instantiates both at the attempt's own lifetime. Nothing about the guarantee moves: the scope still cannot escape into the outcome. -
work/spawn.rsis nowwork/executor.rs. The private module holding the one executor and clock crossing was named fortokio::spawn, andScope::spawnnow means something else entirely in the same layer. One word means one thing, so the seam took the name it already used for its simulated twin (sim::executor), and the two source scans that name the file by hand — the work-layer seam audit and the census's dependency scan — moved with it.