Skip to content

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.

  1. Typed lanes. A #[harmos::lanes] catalog is the one authority for the boot-frozen lane set. One enum carries one variant per lane, and its catalog() 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::X or lanes = Lanes for the catalog's default — so Work::submit, Scope::submit, and SimulatedRuntime::submit take 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.
  2. Universally scoped attempts. Every Errand::attempt takes Scope<'_> 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. Explicit JobHandle::cancel stays, sync, for early teardown. The detached Work::submit stays for work meant to outlive the attempt, visibly spelled work. rather than scope..
  3. The self-lane refusal. A submission from an attempt to its own serial lane is refused as Refusal::SelfLane at 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 reports TimedOut ten minutes later. A concurrent(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.
  4. Per-submission policy. Job::under(policy) replaces the job's declared JobPolicy at the call site. The declaration stays the default; one operation needing two budgets no longer needs two declarations.
  5. The cause at the seam. Job::caused_by(position) names the recorded fact a submission answers. The attempt reads scope.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, and caused_by on a child re-roots that child and everything beneath it.
  6. One dialect, one seam. submit accepts impl IntoJob. A job is a struct whose fields are its arguments; #[harmos::job(...)] on the struct names its lane and policy, and a plain impl Errand names 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.
  7. 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.
#[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, because Lanes::Device already 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 same Builder::register call. Catalogs fold rather than collide: register extends, so a compiled-in feature crate declares its own lanes and the assembly passes both.
  • Capacities are serial, concurrent(n), and unbounded. Every catalog carries exactly one #[default] variant instead of a fourth capacity keyword, and the compiler assigns it unbounded — a job that names no specific variant (lanes = Lanes on its attribute) lands there, starts now or refuses now, and never queues, which is what the deleted Scope::spawn used 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 declaring unbounded — by name or by default — is a job saying it will supply its own.

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.

/// 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.

  • 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.

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.

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:

  1. The scoped flag is deleted; every attempt is scoped. Two shapes meant two contracts (Errand and ScopedErrand), 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. ScopedErrand is deleted from the census, Job::scoped/scoped_with with it, and writing scoped is a compile error that names where the scope is instead.
  2. 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 invent WatchLane: unbounded and 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.
  3. Job::answering is Job::caused_by, and so is Journal::correlate. Correlation is a symmetric word for a directional mechanism: the parameter is already named cause and 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; correlate as a verb does not.
  4. 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_by on a child re-roots it and everything beneath it. Detached work.submit inherits nothing — inheritance is a property of the scope, and a detached submission is precisely the one that has none.
  5. The lane/resident principle is written down, above, with its reviewer test.

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.

  1. under = … declares a named policy on both attributes. #[harmos::job(under = patient())] and #[harmos::errand(under = …)] take one expression resolving to a JobPolicy as the declared default; the macro pastes it into the declared_with path and the compiler holds it to the type, exactly as a job's return type travels. The reason is the same duplication Job::under already 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() -> JobPolicy declared once, spent at the attribute and at .under(…) call sites, the same word for the same value.

    under is 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.

  2. Scope::now() is the attempt's clock. An attempt measuring a duration reaches for std::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 on Scope for 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 keyed scope.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 a tokio::sync::watch::Receiver back from Work::services and Streams::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) grew tokio::time::Instant::now as a forbidden crossing inside work/, because reading the clock past the seam is the same bypass as waiting on it there.

  3. The child-await bridge is the application's, and it is documented rather than built. The endurance walkthrough mapped child-await errors to String to get out of its own function, which is the teaching example demonstrating the wart. The fix is a per-application impl From<JobError<ChildError>> for Error, so scope.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 in harmos would have to choose for every application what Failed means in its vocabulary.

    The canon those five lines encode: Failed unwraps 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. Box breaks the recursion the shape implies: the error type names a JobError over 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 Refusal was taken over a generic impl<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-app grew src/error.rs and a thiserror dependency, which is the shape schematic and autoroute already have.

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.

  1. harmos::lanes! is #[harmos::lanes], and a lane is a value. The old block emitted one unit type per lane plus a fixed-name Lanes owner answering declared(). 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, answering catalog(). 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()).

    Lane goes value-based with it: const NAME/const CAPACITY on a zero-sized type become fn name(&self) and fn 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 — submit takes a lane, never a string key, and JobError::UndeclaredLane stays 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 spelled E0599 instead of E0412.

    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; Lanes is house style, not a requirement. Two catalogs no longer collide — Builder::lanes folds — 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 a app.rs it has not finished writing, and no macro can tell it so.
    • Variants lose the Lane suffix. House style suffixed the unit types because a bare Device in a module of application types said nothing about what it was. Namespaced through the enum it does: Lanes::Device says 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, and Capacity keep their names and the authoring list keeps lanes — it changed family, from function-like to attribute, which is a spelling the macro namespace does not count.

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.

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.

  1. JobError<E> is deleted; Refusal and AbsorbsRefusal take its place. The combined type mixed two facts — the job's own error in Failed(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 put impl From<JobError<E>> for E on 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.

    Refusal is 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 is thiserror's to write.

    submit(...).await therefore answers Result<O, E>. A job's own error is handed back untouched — the Failed(e) => e unwrapping every application used to write is now written once, inside harmos, where it was always the same line — and a refusal converts through E: From<Refusal>.

  2. The bound is named, so the diagnostic can teach. From is 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_recommend on the blanket impl is load-bearing: without it rustc drills into the nested From<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 the Future impl on JobHandle — the last because a dropped runtime answers Refusal::Unavailable at poll time and cannot pre-compute it. It sits on neither IntoJob nor Errand: 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.

  3. The private Refused twin is deleted with it. board::Refused existed because a scope records refusals across children whose error types differ, and the record therefore could not be a JobError<E>. Refusal is 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: Refused carried the three submission refusals and Refusal carries five, so ClosedScope::unobserved now structurally admits a TimedOut or a Cancelled that cannot arise there. Unreachable by construction, and one type beats two types plus a conversion between them.

    Census delta: +1. JobError leaves, Refusal and AbsorbsRefusal enter, so the core surface is 100 names and work's share is twenty-six. minibench-app keeps its thiserror dependency 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.

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.

  1. 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 kind register accepted; a stream catalog and a declared LaneSet had their own verbs instead. Registration::register now answers a single closed question — which kind is this, and what does its fold need — through harmos::Catalog<A>: Catalog::Transactions { definitions, decode, inverses }, Catalog::Streams(Vec<Definition>), or Catalog::Lanes(LaneSet). Builder::register matches on the answer and folds it into the same builder state the verb it replaces reached. A Vec<Definition>, a [Definition; N], and a LaneSet implement the trait directly, so the stream and lane authoring macros needed no codegen change — only the transaction/fact catalog macro's generated Registration impl moved from three methods to one.
  2. 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.
  3. Simulation follows the same builder. Its repeatable recipe captures each Builder::register call, so simulated reboot needs no second public builder or parallel registration API. The inverse-table peek the round-trip oracle reads is taken from Catalog::Transactions rather than a bare C::inverses() call.

Census delta: +1. Catalog<A> enters as the twelfth composition name; nothing leaves, because a method deletion is not a census item.

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.

  1. One authoring dialect. A job is a struct whose fields are its arguments, #[harmos::job(...)] sits on the struct, and the author writes a plain impl Errand for X with no attribute of its own. The fn dialect — #[harmos::job] on an async fn, its identity/job.rs argument rules, its owned-ident checks, its per-attempt Clone, the JobResult projection trait, and the fn-dialect compile-fail fixtures — is deleted. So is the blanket impl Errand for FnMut closure 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 name Errand.
  2. The lane is declared on the job, not the submit site. #[harmos::job(lane = Lanes::Export, retries = 3, timeout_ms = 30_000)] travels lane = <expr> as an unparsed syn::Expr, exactly as under = <expr> already does. retries, timeout_ms, the two backoff_* knobs, and under keep their existing semantics and mutual-exclusion rules unchanged.
  3. Every lane catalog has exactly one default variant. #[default] marks it, it is always Capacity::unbounded(), and combining #[default] with an explicit #[lane(...)], or declaring zero or two #[default] variants, is a compile error. #[harmos::lanes] also emits impl Default for Lanes. Because the macro cannot infer which catalog a job belongs to, the attribute takes exactly one of lane = <expr> (a specific variant) or lanes = <path> (meaning <Lanes as Default>::default()); giving both or neither is a compile error reading "a job names its lane or its lane catalog."
  4. Job<O, E> carries its lane. Job::declared(retries, timeout_ms, lane, errand) and Job::declared_with(policy, lane, errand) both grow the lane-name parameter the generated IntoJob::into_job supplies from the attribute's lane/lanes argument. .under(policy) and .caused_by(position) remain builders on Job, and both land on IntoJob as well, as provided methods that call into_job() first, so scope.submit(X { .. }.under(patient())) compiles without an intermediate Job.
  5. One submit verb per seam, and it takes no lane. Work::submit<J: IntoJob>, Scope::submit<J: IntoJob>, and SimulatedRuntime::submit<J: IntoJob> all read the lane off job.lane rather than a parameter. Scope::spawn, Board::spawn, and the board's hidden spawned admission 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::start drops its Option<&'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.
  6. Nothing else in the runtime model moves. Cause inheritance, child cancellation on scope close, drain-before-retry, unobserved-refusal failing the attempt, the observed witness, the Refusal variants, AbsorbsRefusal, JobPolicy, and the sim trace bytes are exactly what the refusal and DX waves left them.
  7. Jobs stay application-bound; Job<O, E> as a value stays application-free. A job names its application's Lanes catalog by construction, the same coupling the fn dialect always had; the runtime's own Job<O, E> carries no application parameter, unchanged from every wave above.
  8. 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.
  9. 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.

  • 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 and impl Errand beside 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.

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.

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.

  • Scope was already taken, and the undo predicate yielded it. The census exported Scope<A> as the predicate narrowing undo and redo, and one word means one thing — two types cannot share harmos::Scope. This page's target API spells Scope<'_>, scope.submit, and scope.journal() throughout, so the frozen surface was kept exactly as written and the undo predicate was renamed to Focus everywhere it appears (surface, tests, examples, guide, vocabulary). That rename is a breaking change beyond the list above.

  • The old Lane capacity struct is now Capacity. The contract claims "lane" for the typed thing a submission routes on, so the trait the lanes! block implements is Lane and the serial/concurrent/unbounded value it freezes became Capacity. Same forced-patch phase, same re-spell.

  • scope.journal() names the application explicitly. A Job<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 as scope.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() -> Fut blanket that makes Job::declared(0, 100, || async { … }) compile. A closure taking Scope<'_> would have to return a future borrowing its own argument, which only AsyncFnMut expresses — and AsyncFnMut::CallRefFuture is unstable, so the Send bound Errand requires 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::attempt elides its lifetimes rather than naming one. The deleted ScopedErrand declared fn attempt<'s>(&'s mut self, scope: Scope<'s>), which only macro-generated code ever implemented. The merged contract has to be implementable by hand, as async fn attempt(&mut self, scope: Scope<'_>) — and an async fn desugars to two elided lifetimes, which do not match a declaration that names one (E0195). So the trait method is declared fn 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.rs is now work/executor.rs. The private module holding the one executor and clock crossing was named for tokio::spawn, and Scope::spawn now 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.

Stokker Technologies markDesigned and built by Stokker Technologies