Continuation Extension — Normative Specification

Version: 1.23

Status: Active Depends: ENTITY-CORE-PROTOCOL.md (v7.33+) Proposal: PROPOSAL-CONTINUATION-MODEL.md, PROPOSAL-CONTINUATION-SPEC-AMENDMENT.md (A1-A6), PROPOSAL-DELIVERY-AND-INBOX-RENAME.md (D5, D6), PROPOSAL-CONTINUATION-TRANSFORM-AND-ENVELOPE-AMENDMENTS.md (S1), PROPOSAL-COHERENT-CAPABILITY-AUTHORITY.md (CT1, CT2, CT3), PROPOSAL-CONTINUATION-STANDING-MODEL.md (v1.21: §3 advance authority — §3.1b/§6.1; §4 join completion policy — §2.3 fields + §3.5 round-identity guard + §3.5a deadline/abandon/fire-partial/sweep-all/round-id; §4.2 finite-exhaustion MUST-delete — §3.4/§3.5)


Path notation. Paths in this document use peer-relative notation (without leading /{peer_id}/). All peer-relative paths resolve to the local peer's namespace: system/tree means /{local_peer_id}/system/tree. Every path in the entity tree is absolute at rest — rooted at a peer identity. See ENTITY-CORE-PROTOCOL.md §1.4 for the path model. Cross-peer examples use absolute paths with explicit peer identities.

1. Overview

The continuation extension enables execution chaining — determining what happens when a result arrives at a path. A continuation entity at a path describes what to do with an incoming result: dispatch to a target handler, accumulate for fan-in, or record exhaustion.

The system/delivery-spec type referenced by this extension (in on_error and deliver_to fields) is defined in ENTITY-CORE-PROTOCOL.md §3.11.

This extension defines execution logic only:

This extension is independent of EXTENSION-INBOX.md. The advancement algorithm takes a path and a result — it does not depend on how the result arrived. EXTENSION-INBOX.md provides one delivery mechanism (async inbox transport). Direct advance operations, future mechanisms, or any handler that dispatches to system/continuation with an advance operation are equally valid sources.

Installation combinations:

CONTINUATIONINBOXResult
yesyesFull flow: async delivery triggers continuation advancement
yesnoSuspended continuations, direct advancement via advance operation
noyesAsync delivery with tree storage (EXTENSION-INBOX tree storage behavior)
nonoSync-only peer

1.1 Coherent Capability

system/continuation and system/continuation/join entities are load-bearing: their dispatch_capability field authorizes a future dispatch by the continuation handler when advance fires. Use system/continuation:install to create them. The install operation validates the embedded dispatch_capability against the writer's authority chain (§3.2) and persists the entity under the handler's own grant.

Direct tree:put to system/continuation/suspended/* is permitted but bypasses the install operation's validation; continuations created that way are not guaranteed to satisfy the dispatch_capability authority invariant. Application-level capability grants should cover system/continuation:install rather than direct tree:put to the namespace. Direct tree:put is appropriate for system-extension code (handlers writing under their own grant) and administrative/bootstrap contexts. See ENTITY-CORE-PROTOCOL.md §6.3 for the kernel-vs-handler principle.

Sub-identity behavior (informative). Delegated sub-identities (non-peer actors with delegated grants from a peer's root) calling install directly are naturally rejected by the §3.1a in-chain check — the dispatch_capability must chain to a peer identity that the continuation handler can wield, and the sub-identity isn't in that chain. This is by design: sub-identities don't set up deferred dispatches directly. They invoke handler operations that may internally create continuations under the handler's own authority.


2. Type Definitions

2.1 Forward Continuation

Stored at a path. Describes what to do when a result arrives. The continuation is a closure: params is the captured environment, result_field is the argument slot, {target, operation} is the function to call.

system/continuation := {
  fields: {
    target:               {type_ref: "system/tree/path"}
    operation:            {type_ref: "primitive/string"}
    resource:             {type_ref: "system/protocol/resource-target", optional: true}
    params:               {type_ref: "primitive/any", optional: true}
    result_transform:     {type_ref: "system/continuation/transform", optional: true}
    result_field:         {type_ref: "primitive/string", optional: true}
    result_merge:         {type_ref: "primitive/bool", optional: true}
                           ; When true, the (transformed) result — which MUST be
                           ; a map, typically a `select` output — is shallow-merged
                           ; into static `params` at top level (Merge mode, below).
                           ; Mutually exclusive with `result_field`.
    on_error:             {type_ref: "system/delivery-spec", optional: true}
    deliver_to:           {type_ref: "system/delivery-spec", optional: true}
    remaining_executions: {type_ref: "primitive/uint", optional: true}
    dispatch_capability:  {type_ref: "system/hash", optional: true}
                           ; MUST be present on continuations that dispatch
                           ; (advance operation). The creating handler — caller
                           ; or system — provides this at creation time. Optional
                           ; only for continuations that do not dispatch (e.g.,
                           ; storage-only inbox entries without continuation
                           ; semantics).
  }
}
FieldPurpose
targetHandler path to dispatch to
operationOperation to invoke on target handler
resourceResource scope for the dispatched EXECUTE
paramsPre-built params — the captured environment
result_transformStructural navigation on result before injection
result_fieldField name in params to inject the (transformed) result into
result_mergeWhen true, shallow-merge the (transformed) map result into static params at top level (Merge mode). Mutually exclusive with result_field.
on_errorDelivery spec for error delivery (compensation) — symmetric with deliver_to
deliver_toChain link — delivery spec for the next step in the chain
remaining_executionsnull = unlimited (standing). N = N executions remaining. 0 = exhausted. Decremented on successful dispatch acceptance, not on dispatch completion — for async chains the dispatch is fire-and-forget at this point.
dispatch_capabilityCapability token hash authorizing dispatch to target

Dispatch modes (determined by field presence):

paramsresult_fieldresult_mergeModeBehavior
absentabsentfalse/absentPass-throughfinal_params = result (result IS the params)
presentpresentfalse/absentInjectfinal_params = params; params[result_field] = result
presentabsentfalse/absentTriggerfinal_params = params (result ignored)
presentabsenttrueMergefinal_params = shallow_merge(params, result) — top-level key union; result keys win on collision; result MUST be a map (non-map → static-only + merge_value_not_map marker, §3.4)
absentabsenttrueMerge (empty scaffold)final_params = result when result is a map (merge into {})
absentpresentInvalidReturn error 400 (invalid_continuation)
presenttrueInvalidresult_merge + result_field are mutually exclusive — return error 400 (invalid_continuation) at install

Merge mode (v1.16). result_merge: true shallow-merges the post-transform value (which MUST be a map — typically a result_transform.select output) into the static params at top level, producing a flat param map. This is the assembly path for dispatching an op that takes a static scaffold plus several dynamic fields pulled from the result (e.g. op(prefix, base, target) where prefix is static and base/target come from a notification) — which Inject mode cannot express (it nests the whole value under one result_field key) and Pass-through cannot express (it drops the static scaffold). Semantics: shallow only (top-level key union, no recursion); result keys win on collision (dynamic data overrides static defaults); result_merge: false is identical to absent. Per proposals/implemented/PROPOSAL-CONTINUATION-MERGE-ASSEMBLY.md.

2.2 Structural Transform

Specifies structural navigation on the result. Applied before dispatch mode logic.

system/continuation/transform := {
  fields: {
    extract:           {type_ref: "primitive/string", optional: true}
    select:            {map_of: {type_ref: "primitive/string"}, optional: true}
    transform_ops:     {array_of: {type_ref: "system/continuation/transform-op"}, optional: true}
                       ; Ordered list of closed, total, pure, bounded field
                       ; operations. Applied after extract/select, before the
                       ; *_extract fields. See "Bounded field operations" below.
    resource_extract:  {type_ref: "primitive/string", optional: true}
                       ; Dotted path into the post-extract/post-select value.
                       ; Produces the resource targets for the dispatched EXECUTE.
                       ; Overrides static resource on the continuation entity when
                       ; present and resolves.
    target_extract:    {type_ref: "primitive/string", optional: true}
                       ; Dotted path producing target URI for the EXECUTE.
    operation_extract: {type_ref: "primitive/string", optional: true}
                       ; Dotted path producing operation name for the EXECUTE.
  }
}

extract: Dotted path into the result. "data.metrics.score" navigates to result.data.metrics.score. If any segment is missing, the extracted value is null.

select: Map of {destination_field: source_path}. Each source_path is a dotted path into the extracted value. Produces a new map with the specified field names and extracted values.

Processing pipeline: extract -> select -> transform_ops -> *_extract. All are optional. When both extract and select are present, extract runs first and select operates on the extracted value; transform_ops then runs on the post-extract/post-select value; the *_extract fields run last.

EXECUTE field extraction. Three additional optional fields allow the transform to populate EXECUTE fields from the navigated value:

resource_extract: Dotted path into the post-extract/post-select value. The resolved value produces the resource targets for the dispatched EXECUTE. If the resolved value is a string, it is wrapped as {targets: [value]}. If the resolved value is an array, it is wrapped as {targets: value}. If the resolved value is already a well-formed resource target object (has a targets field), it is used as-is. Overrides the static resource on the continuation entity when present and when the path resolves. If navigation fails, the static resource is used.

target_extract: Dotted path into the post-extract/post-select value. The resolved value (a string) becomes the target URI for the dispatched EXECUTE. Overrides the static target on the continuation entity when present and when the path resolves. If navigation fails, the static target is used.

operation_extract: Dotted path into the post-extract/post-select value. The resolved value (a string) becomes the operation for the dispatched EXECUTE. Overrides the static operation on the continuation entity when present and when the path resolves. If navigation fails, the static operation is used.

The *_extract fields are applied after extract, select, and transform_ops. They navigate the same value that dispatch mode logic receives. The EXECUTE field extractions and dispatch mode assembly are independent — both operate on the post-extract/post-select value.

Capability interaction. The dispatch_capability on the continuation is checked at dispatch time (step 5 in §3.5) against the final EXECUTE — including any dynamically-resolved fields. A capability scoped to {resources: {include: ["/peer_b/data/shared/*"]}} authorizes any dynamically-extracted path matching that pattern. No capability model change is needed.

The leading / is required and is not cosmetic. A dispatch_capability is minted by one peer, persisted at another (§3.2 step 5), and later wielded by that second peer against its own namespace (§3.6 step 5) — so its granter is never the peer that dispatches it. Under ENTITY-CORE-PROTOCOL §5.5a a capability's resource patterns canonicalize against its granter's peer_id, which makes the peer-relative form peer_b/data/shared/* mean /{granter}/peer_b/data/shared/* — a literal segment named peer_b inside the granter's own tree, authorizing nothing at peer B. Continuation install helpers MUST write resources in explicit cross-peer form; §5.5a names them among the helper classes bound by that rule.

Bounded field operations (transform_ops). An optional ordered list of typed field operations applied after extract/select, before the *_extract fields. Each op reads and/or writes named fields of the post-navigation value. They exist so a common, total, declarative need — chiefly the cross-peer-notification→local-path rewrite (strip one statically-known prefix, prepend one statically-known prefix) — stays inside the declarative, statically-analyzable transform instead of forcing an opaque handler step into an otherwise-declarative chain (which would puncture the declarative legibility the transform exists to provide).

system/continuation/transform-op := {
  fields: {
    op:      {type_ref: "primitive/string"}        ; operation name (table below)
    field:   {type_ref: "primitive/string", optional: true}
    into:    {type_ref: "primitive/string", optional: true}
    fields:  {array_of: {type_ref: "primitive/string"}, optional: true}
    prefix:  {type_ref: "primitive/string", optional: true}
    literal: {type_ref: "primitive/string", optional: true}
    from:    {type_ref: "primitive/string", optional: true}
    to:      {type_ref: "primitive/string", optional: true}
    sep:     {type_ref: "primitive/string", optional: true}
    range:   {type_ref: "primitive/string", optional: true}
  }
}
opShapeEffect
strip_prefix{field, prefix}field with the literal prefix removed if present (no-op if absent)
prepend{field, literal}literal prepended to field
append{field, literal}literal appended to field
join{fields:[...], sep, into}the listed fields joined by sep into into
replace_literal{field, from, to}every literal (non-regex) from in field replaced with to
split{field, sep, into}field split on sep into an array at into
slice{field, range, into}bounded segment of field (by range) into into
collect_keys{field, into} OR {fields:[...], into}array of keys from the map(s) projected, written to into. Singular form (field) projects one map's keys. Plural form (fields: [...]) projects keys from each listed map and concatenates the result in list order. Empty map → empty array; concatenating empties → empty array. Missing/non-map at any listed field → no-op (best-effort rule applies). field and each entry in fields follow the dotted-path navigation rules from extract (§2.2). A single op MUST NOT set both field and fields; impls MUST reject install with 400 invalid_transform_args when both are present. Empty into is a silent no-op per the general best-effort rule.
deref_included{field}reads field as a system/hash reference and replaces it with the entity bound to that hash in the envelope's included map. Missing field, non-hash value, or hash absent from included → no-op (best-effort rule). In-flight envelope navigation — the entity was bundled by the sender per ENTITY-CORE-PROTOCOL.md §3.1 and is structurally present at advance time — not a tree/store read (see Boundary). Does not assume any fixed hash length (system/hash is variable-length, ENTITY-CORE-PROTOCOL.md §1.2). Lets a continuation chain consume an entity delivered in the envelope — e.g. an include_payload notification (EXTENSION-SUBSCRIPTION.md §2.2) — and feed it into a tree:put without an opaque handler step. Requires request-side included preservation (ENTITY-CORE-PROTOCOL.md §3.3) so the map reaches the continuation.

Admissibility (the contract — this, not the table, is what conformance binds). An op is admissible iff it is total (defined for every input; a missing field is a documented no-op/null, consistent with the best-effort rule below), pure (output a function of inputs only — no clock, RNG, I/O, tree, or capability access), bounded (no unbounded iteration; split/join/slice bounded by input length), and statically analyzable (the analyzer can determine the op's effect on field shapes). Conditionals, loops, regular expressions, and arbitrary expressions are not admissible — those remain a handler step (the legitimate off-ramp). transform_ops is field plumbing, not a computation surface. Implementations supporting transforms MUST implement the agreed op set with these semantics; an unrecognized op MUST be rejected at install (fail-closed), never silently skipped — with status 400 unknown_transform_op (the error-code string is pinned for cross-impl conformance assertions; reject-with-this-code is the binding behavior, the string is the pinned spelling). The op table is the initial set; it grows on demonstrated need under the same admissibility contract — not all ops need ship in the first implementation, but the contract is fixed.

Boundary: Extract, select, and EXECUTE field extraction are pure structural navigation. transform_ops additionally provides a closed, total, pure, bounded set of field operations. Together they remain free of conditionals, iteration, regular expressions, tree references, and general computation — any of which is a handler step, not a transform.

Envelope navigation is in-scope; tree navigation is not. deref_included resolves a hash field against the envelope's included map — the in-flight message payload, structurally available at advance time and pure (a function of the input envelope, exactly as extract is a function of the prior-step result). This is categorically distinct from a tree reference (a read of mutable tree/store state), which remains excluded. Justification: the receiver already resolves entities referenced by hash from EXECUTE.data against included per ENTITY-CORE-PROTOCOL.md §3.1 — deref_included exposes that same structural navigation to the transform layer, adding no new capability. It is total (no-op on miss), pure (included is part of the request, not the tree — no I/O), bounded (one lookup), and statically analyzable (hash → entity-shape).

Transform error handling: If CBOR decoding of the result fails during transform processing, or if extract navigates to a missing path, the implementation MUST pass the original unmodified result to the dispatch mode logic. Transforms are best-effort structural navigation — they do not produce errors.

If select produces a map where all values are null (every source path missed), the implementation SHOULD pass the null map to dispatch mode logic (not fall back to the original result). The select operation succeeded — it produced the specified shape with null values.

2.3 Join Continuation

Fan-in continuation — accumulates results from parallel operations.

system/continuation/join := {
  fields: {
    expected:             {array_of: {type_ref: "primitive/string"}}
    received:             {map_of: {type_ref: "primitive/any"}, optional: true}
    target:               {type_ref: "system/tree/path"}
    operation:            {type_ref: "primitive/string"}
    resource:             {type_ref: "system/protocol/resource-target", optional: true}
    params:               {type_ref: "primitive/any", optional: true}
    result_field:         {type_ref: "primitive/string", optional: true}
    on_error:             {type_ref: "system/delivery-spec", optional: true}
    deliver_to:           {type_ref: "system/delivery-spec", optional: true}
    remaining_executions: {type_ref: "primitive/uint", optional: true}
    dispatch_capability:  {type_ref: "system/hash", optional: true}
                           ; MUST be present on continuations that dispatch
                           ; (advance operation). The creating handler — caller
                           ; or system — provides this at creation time. Optional
                           ; only for continuations that do not dispatch (e.g.,
                           ; storage-only inbox entries without continuation
                           ; semantics).
    completion_deadline_ms: {type_ref: "primitive/uint", optional: true}
                           ; per-round wall budget (§3.5a). Absent = wait forever
                           ; (the pre-completion-policy behavior; no silent change).
    on_incomplete:          {type_ref: "primitive/string", optional: true}
                           ; "abandon" | "fire-partial"; default "abandon" (§3.5a).
    round_id:               {type_ref: "primitive/uint", optional: true}
                           ; current round generation (§3.5a). Monotonic;
                           ; incremented on every reset/fire. A slot advance is
                           ; tagged with the round_id it targets. Absent on joins
                           ; that do not opt into completion policy.
  }
}

Completion policy (standing joins). completion_deadline_ms, on_incomplete, and round_id are the standing-join completion surface — the lifecycle half of the standing-continuation model (§3.1b is the authority half). A standing join (remaining_executions: null) that misses a slot must not wedge forever; it owns a completion policy independent of any single trigger. The normative mechanics — the per-round deadline, abandon/fire-partial, the sweep that reaps expired rounds, and the round_id straggler guard — are specified in §3.5a. Absent all three fields, a join behaves exactly as before (wait-forever, no rounds).

expected: Array of slot names. Results arrive at {join_path}/{slot_name}.

received: Accumulated results. Initially absent or empty. Updated on each slot delivery. When all expected slots are present in received, the join dispatches.

Slot delivery: When a result arrives at a child path of a join entity, the handler:

  1. Reads join entity at the parent path
  2. Validates slot_name is in expected
  3. Rejects if slot_name is already in received (exactly-once per round)
  4. Copies received, sets received[slot_name] = result
  5. If all expected slots are filled -> dispatch (the completed received map is the result, injected via result_field or passed through)
  6. If not complete -> update join entity at parent path using CAS (expected_hash)

Concurrency: Simultaneous slot arrivals are serialized by the tree's emit pathway. CAS via expected_hash on the join entity update prevents lost slot results. Duplicate delivery to an already-filled slot is rejected with 409 slot_already_filled (§3.4). The CAS retry loop re-reads the join entity, so the "slot already filled" check is always against current state — this gives exactly-once slot semantics within each round. After a standing join resets received on dispatch, slots can be filled again for the next round.

Implementation note: Implementations MAY use process-local locking (e.g., a mutex per join path) as an optimization when CAS is not required (single-process deployment). The CAS mechanism via expected_hash remains normative for multi-process and distributed deployments. Implementations using process-local locking SHOULD clean up lock state when join entities are deleted.

2.4 Suspended Continuation

System-created when an operation cannot complete.

system/continuation/suspended := {
  fields: {
    target:          {type_ref: "system/tree/path"}
    operation:       {type_ref: "primitive/string"}
    resource:        {type_ref: "system/protocol/resource-target", optional: true}
    params:          {type_ref: "primitive/any", optional: true}
    reason:          {type_ref: "primitive/string"}
    chain_id:        {type_ref: "primitive/string"}
    original_author: {type_ref: "system/hash"}
    suspended_at:    {type_ref: "primitive/uint"}
  }
}

Stored at system/continuation/suspended/{id}. Created by:

The suspended entity captures enough state to reconstruct the original EXECUTE with fresh bounds. reason identifies why the operation was suspended. original_author identifies who initiated the chain. suspended_at records when suspension occurred.

2.5 Advance Request

Input type for the advance operation on the continuation handler.

system/continuation/advance-request := {
  fields: {
    result: {type_ref: "primitive/any", optional: true}
    status: {type_ref: "primitive/uint", optional: true}
  }
}
FieldPurpose
resultThe result value to advance with. Absent = null result.
statusHTTP-style status of the result. Absent = success (200).

The continuation handler owns this contract. Callers (inbox handler, direct users) construct an advance-request and dispatch to system/continuation with operation advance.

2.6 Resume and Abandon Requests

system/continuation/resume-request := {
  fields: {
    bounds:     {type_ref: "system/bounds", optional: true}
    resolution: {type_ref: "primitive/any", optional: true}
    deliver_to: {type_ref: "system/delivery-spec", optional: true}
  }
}

system/continuation/abandon-request := {
  fields: {}
}

2.7 Install Request and Result

The install operation is the proper create path for continuation entities — see §3.2 and §1.1 (Coherent Capability).

No wrapper request type (v1.7). Callers pass a system/continuation or system/continuation/join entity directly as params. The install path is carried in EXECUTE.resource.targets[0] per the path-as-resource convention (ENTITY-CORE-PROTOCOL.md §3.2). The install handler discriminates forward vs join on params.type. There is no system/continuation/install-request wrapper — every field that would have been on it is either data on the continuation entity itself (target, operation, resource, params, result_field, dispatch_capability, on_error, deliver_to, remaining_executions) or the install path (now resource).

system/continuation/install-result := {
  fields: {
    path: {type_ref: "system/tree/path"}            ; echoes the suspended path where the continuation was installed
  }
}

(install-result retained for forward-compat — confirms the install path and leaves room for additional install metadata in future revisions.)


3. Handlers

3.1 Continuation Handler

system/handler := {
  pattern:    "system/continuation"
  name:       "continuations"
  operations: {
    install: {
      input_type:  "system/continuation"          ; or system/continuation/join — dispatched on params.type
      output_type: "system/continuation/install-result"
    }
    advance: {input_type: "system/continuation/advance-request"}
    resume:  {input_type: "system/continuation/resume-request"}
    abandon: {input_type: "system/continuation/abandon-request"}
  }
}

Handler entity at pattern path system/continuation. Dispatch resolves any URI under system/continuation/ to this handler via longest-prefix match (ENTITY-CORE-PROTOCOL.md §6.6).

The install operation is the proper create path for continuation entities — see §3.2.

The advance operation implements the continuation advancement algorithm (§3.3-§3.6). The continuation path is specified via resource.targets[0]. Who may trigger advancement — and under what authority — depends on whether the advance is a reactive delivery or an administrative invoke; see §3.1b (normative).

The resume operation reconstructs an EXECUTE from a suspended continuation and dispatches it (§3.7).

The abandon operation deletes a suspended continuation (§3.8).

3.1a Authority-chain check: in-chain, not chain-root (normative)

The install-time authorization check on an embedded dispatch_capability (§3.2 step 4, via check_creator_authority, ENTITY-CORE-PROTOCOL.md §5.5) is an in-chain check: it collects the full authority chain and verifies the writer's identity appears as a granter anywhere in the chainnot that the chain roots at the writer. The error path is 403 embedded_cap_unauthorized ("Writer identity not in dispatch_capability authority chain").

This distinction is silent for local continuations and load-bearing for cross-peer ones:

Wherever this spec or a conformance test says "chain root against author" / "chain-root check" for the install-time authorization, read it as the in-chain check defined here. §3.2, §4.2, and §8.1 are written to this rule; the prior chain-root phrasing was pre-correction residue that, taken verbatim cross-peer, reproduces the dispatch-capability collapse this section prevents.

3.1b Advance authority: reactive trigger vs administrative invoke (normative)

A standing continuation (remaining_executions: null) is decoupled from any single trigger: it always advances under its stored dispatch_capability (§3.6 step 5), regardless of who reached it. A trigger reaches it; it does not own it. The deliverer-declared reactive_trigger signal on the advance context classifies the advance:

reactive_trigger is cross-peer-observable — it decides which gate applies, so a divergent reading would spring apart at the peer seam.

Safety (privilege-escalation, §6.1). A reactive trigger causes the continuation to do only what its owner pre-authorized at install (the scoped dispatch_capability) — the trigger gains nothing it could not already do, exactly as an emitter triggering a subscription gains none of the subscriber's rights. The escalation mitigation is the install-time in-chain check on dispatch_capability (§3.1a / §3.2 step 4), not an advance-time caller check on the reactive path — the latter breaks reactive standing continuations without adding containment. The residual is timing/frequency (over-triggering) — a DoS surface bounded by the continuation's own bounds (chain_depth/TTL, ENTITY-CORE-PROTOCOL §5.9) and by idempotency at the channel.

3.2 Install Operation

Creates a continuation entity (forward or join) at a suspended path under system/continuation/suspended/*. This is the proper create path — direct tree:put of system/continuation / system/continuation/join entities is reserved for system-extension and administrative use (see §1.1 and ENTITY-CORE-PROTOCOL.md §6.3).

The caller passes a continuation entity directly as params; the install path is carried in EXECUTE.resource.targets[0] per the path-as-resource convention (ENTITY-CORE-PROTOCOL.md §3.2). The handler dispatches forward vs join on params.type — one operation, two accepted entity types.

handle_install(ctx, params):
  ; Step 1: install path comes from EXECUTE.resource per V7 §3.2.
  if ctx.resource is null or len(ctx.resource.targets) != 1:
    return error(400, "ambiguous_resource",
      "install requires exactly one resource target (the suspended path)")
  install_path = ctx.resource.targets[0]

  ; Step 2: discriminate forward vs join on entity type. params IS the
  ; continuation entity to install.
  if params.type != "system/continuation" and params.type != "system/continuation/join":
    return error(400, "invalid_params",
      "install expects system/continuation or system/continuation/join in params")
  continuation = params

  ; Step 3: validate required fields on the entity.
  if continuation.data.dispatch_capability is null:
    return error(400, "missing_dispatch_capability",
      "continuation requires dispatch_capability for the deferred dispatch")
  if continuation.type == "system/continuation":
    if continuation.data.target is null or continuation.data.operation is null:
      return error(400, "invalid_continuation",
        "forward continuation requires target and operation")
    ; result_merge and result_field are mutually exclusive (Merge vs Inject mode) — v1.16
    if continuation.data.result_merge == true and continuation.data.result_field != null:
      return error(400, "invalid_continuation",
        "result_merge: true is mutually exclusive with result_field")

  ; Step 4: R1 creator-authorization check on the embedded dispatch_capability.
  ; Uses check_creator_authority (V7 §5.5) which collects the full authority
  ; chain via collect_authority_chain, verifies reachability, then checks
  ; whether the writer's identity appears as granter anywhere in the chain.
  cap_entity = resolve(continuation.data.dispatch_capability, ctx)
  if cap_entity is null:
    return error(404, "dispatch_capability_not_found",
      "Referenced capability entity not in content store or envelope")
  (found, chain, err) = check_creator_authority(cap_entity, ctx.execute.data.author, ctx)
  if err == ChainUnreachable:
    return error(404, "chain_unreachable",
      "Embedded capability's authority chain is not fully resolvable")
  if not found:
    return error(403, "embedded_cap_unauthorized",
      "Writer identity not in dispatch_capability authority chain")

  ; Step 5: Persist the continuation + authority chain to local content store.
  ; The chain was already collected by check_creator_authority — no re-walk.
  ; The continuation is bound at install_path under the handler's own grant
  ; (handler-managed namespace per V7 §6.8).
  ctx.entity_tree.put(install_path, continuation)
  for entity in chain:
    ctx.content_store.put(entity)

  return {status: 200, result: {path: install_path}}

System-created continuations. When a system handler creates a continuation on behalf of an external caller (e.g., the inbox handler installing a continuation for delivery routing), the handler invokes install under its own grant. The handler's identity is the ctx.execute.data.author for the install dispatch; the embedded dispatch_capability is the handler's own scoped grant attenuated from its authority. The chain roots at the local peer identity and the handler is in it as granter, so the §3.1a in-chain check passes by construction (the local case where in-chain ⇔ rooted-at-installer). No special exemption needed — the rule applies uniformly.

3.3 Advancement Algorithm

advance_at_path(path, result, status):
  ; status is optional — null means success (200)
  effective_status = status or 200

  ; 1. Check for continuation entity at the path
  continuation = entity_tree.get(path)

  ; 2. Forward continuation — advance immediately
  if continuation != null and continuation.type == "system/continuation":
    return advance_forward(continuation, result, effective_status, path)

  ; 3. Join slot — check if parent is a join entity
  if continuation is null or continuation.type not in ["system/continuation", "system/continuation/join"]:
    parent_path = parent_of(path)
    slot_name = last_segment(path)
    join = entity_tree.get(parent_path)
    if join != null and join.type == "system/continuation/join":
      return advance_join_slot(join, parent_path, slot_name, result)

  ; 4. Direct advance to join path (not a slot) — error
  if continuation != null and continuation.type == "system/continuation/join":
    return error(400, "join_requires_slot_path")

  ; 5. No continuation at path — no-op
  return {status: 200, result: {advanced: false}}

3.4 Forward Advancement

advance_forward(continuation, result, status, continuation_path):
  ; Check exhaustion
  if continuation.data.remaining_executions != null:
    if continuation.data.remaining_executions == 0:
      return {status: 200, result: {advanced: false, exhausted: true}}

  is_error = status >= 400

  ; Error path: status >= 400
  if is_error and continuation.data.on_error != null:
    ; on_error is a system/delivery-spec — deliver through inbox handler
    error_delivery_spec = continuation.data.on_error
    dispatch({
      uri:       error_delivery_spec.uri
      operation: error_delivery_spec.operation or "receive"
      resource:  {targets: [error_delivery_spec.uri]}
      params:    {
        type: "system/inbox/delivery"
        data: {original_request_id: context.request_id, status: status, result: result}
      }
    })
    ; Error delivery uses the same dispatch mechanics as normal continuation
    ; dispatch: same resource resolution, same capability check (via
    ; dispatch_capability), same authorization rules.
    ;
    ; Error delivery is best-effort — dispatch result is not checked.
    ; Transient failure during on_error delivery is silently lost (no suspension,
    ; no propagation). This is intentional: on_error is a compensation path,
    ; not a guaranteed delivery. Adding suspension to on_error would create
    ; recursive error-handling complexity with diminishing returns.
    ;
    ; If the continuation at the on_error path also has on_error, the error
    ; can chain through multiple error paths. Chain depth (§3.9) is the
    ; backstop against unbounded error routing.
    return {status: 200, result: {advanced: true, error_routed: true}}
  else:
    ; Forward path
    dispatch_result = execute_dispatch(continuation, result)

  ; Handle dispatch result
  if dispatch_result.error != null:
    if dispatch_result.error.transient:
      ; Transient failure — suspend for later retry
      ; Do NOT decrement remaining_executions
      suspend_execution(
        target:    continuation.data.target
        operation: continuation.data.operation
        resource:  continuation.data.resource
        params:    dispatch_result.assembled_params
        reason:    dispatch_result.error.reason
        chain_id:  dispatch_result.chain_id or generate_id()
      )
      ; Note: the write-ahead delivery entity stored by the inbox handler
      ; (EXTENSION-INBOX.md §3.2) remains in the tree since advancement
      ; did not succeed. Implementations SHOULD clean up orphaned write-ahead
      ; entities when the suspended continuation is resumed or abandoned.
      return {status: 200, result: {advanced: false, suspended: true}}

    if dispatch_result.error.permanent:
      ; Permanent failure — route to on_error if available
      if continuation.data.on_error != null and not is_error:
        ; Avoid infinite error loops: only route to on_error when the
        ; original delivery was not itself an error (prevents the forward
        ; path's dispatch failure from looping through on_error if the
        ; inbound delivery already carried error status).
        ; on_error dispatch is best-effort — result not checked (see above).
        error_delivery_spec = continuation.data.on_error
        dispatch({
          uri:       error_delivery_spec.uri
          operation: error_delivery_spec.operation or "receive"
          resource:  {targets: [error_delivery_spec.uri]}
          params:    {
            type: "system/inbox/delivery"
            data: {original_request_id: context.request_id, status: dispatch_result.error.status, result: dispatch_result.error}
          }
        })
      ; Do NOT decrement remaining_executions
      return error(dispatch_result.error.status, dispatch_result.error.code)

  ; Lifecycle — only on successful dispatch
  if continuation.data.remaining_executions != null:
    current_hash = continuation.content_hash
    if continuation.data.remaining_executions - 1 == 0:
      ; Exhaustion — MUST delete (delete-if-last, one CAS with the final
      ; decrement so 0 never persists). See "Exhaustion" note below.
      entity_tree.delete(continuation_path, expected_hash: current_hash)
    else:
      updated = copy(continuation)
      updated.data.remaining_executions = continuation.data.remaining_executions - 1
      entity_tree.put(continuation_path, updated, expected_hash: current_hash)
    ; On 409 conflict: re-read continuation, re-check remaining_executions, retry
    ; The entity MUST NOT advance when at 0 (defensive — normally already deleted).

  return {status: 200, result: {advanced: true}}

Exhaustion — delete, do not retain (MUST) [STANDING-MODEL §4 close-out, 2026-07-29]. When remaining_executions reaches 0 — for both a forward continuation (above) and a join (§3.5) — the entity MUST be deleted, not left in the tree at remaining_executions: 0. This is cross-peer-observable, not hygiene: a consumed finite continuation MUST resolve to absent at its path on every peer (a tree = path → hash fact), so a re-tree:get returns not-found and a post-exhaustion advance resolves to not_found (§3.3) — a consumed finite continuation is gone, the direct analogue of abandon deleting a suspended continuation (§3.8, "killing a stopped process"). The delete is one CAS with the final decrement (delete-if-last), so the entity never persists at 0; the remaining_executions == 0 → exhausted: true entry guard (§3.4/§3.5) is a defensive no-op for any impl that briefly materializes 0 before deleting. This was formerly a SHOULD clean up, whose two conformant readings (delete vs retain-at-0) split across the peer seam — pinned MUST per the cross-peer-observable-SHOULD discipline. Convergence: Go and Rust delete on every fire path (delete-if-last); Python deletes on its fire-partial path but retained on its ordinary completion path — so Python's ordinary completion path is the one build owed, which also removes an internal Python inconsistency. State this once here; §3.5 references it rather than restating.

Forward-dispatch outcome classification (normative — v1.10). dispatch_result.error above denotes a dispatch delivery/processing failure: the EXECUTE could not be delivered or processed as a dispatch — transport failure, unresolvable target, malformed dispatch, or the dispatch's own capability check failing. It is not the dispatched handler's response status. A delivered EXECUTE that returns a handler-level non-2xx (e.g. 403/404/500) is a completed forward dispatch, not a dispatch_result.error: a forward continuation propagates the result onward — it is a closure invocation, not an RPC, and the dispatched response is not threaded back (in the success path either). On a delivered handler-level non-2xx the algorithm therefore proceeds past the dispatch_result.error branch: remaining_executions is decremented and {advanced: true} is returned. remaining_executions counts completed dispatch attempts, not successful downstream outcomes. Implementations MUST classify uniformly: a returned handler-level non-2xx MUST NOT be promoted to dispatch_result.error.transient/.permanent, and MUST NOT be silently retried or suspended on that basis. (This pins behavior that §3.4 previously left undefined — the cross-impl ambiguity surfaced by the entity-core-go v1.9 peers-status report; the reference impl already behaves this way.)

Observability of a forward dispatch's downstream verdict. Forward continuations are fire-and-forget by design (a closure invocation, not an RPC). Three cases produce observable failure markers (all informational, none reactive), all of type system/runtime/chain-error-lost, bound at a per-occurrence path system/runtime/chain-errors/lost/{chain_id}/{step_index}/{reason}/{marker_hash} (v1.20 path scheme — canonical home is §3.10.1):

The bind obligation is for unhandled failure, and a handled loop MUST NOT manufacture markers [MUST]. The three cases below are MUST-bind. That obligation exists because a silent chain failure is unacceptable — not because every failure deserves a record. A failure that is broadcast on an observable surface and handled by a re-arming loop is neither silent nor unhandled, and binding a marker for it adds zero observability at unbounded cost.

The concrete shape, because it is the common one: a retry loop whose continuation carries no on_error makes every expected retry failure the no-on_error case below, so a dead counterpart mints markers forever — roughly 1,440 per day, per dead peer. Every implementation would be required to write a record that nothing is required to collect.

The fix is at the source, not in collection. A chain that fails by design, repeatedly, MUST handle its own failure — give the loop an on_error that re-arms it. The no-on_error case then stops firing and the marker returns to catching the exceptional failure: the on_error dispatch itself failing. The marker is a backstop, not a log.

Path-scheme cross-reference (v1.20). The path strings below show the v1.16-era per-reason form .../{reason} for historical continuity; the v1.20 normative form appends /{marker_hash} as a terminal segment per §3.10.1 (each distinct observation lands at its own path; tree IS the event log; redelivery dedupes when impls capture timestamp at failure-origination per §3.10.6). Treat any v1.16/v1.13/v1.9 path string in this section as superseded by §3.10.1; the historical idempotency claims (re-binding-overwrites / flap-doesn't-multiply) were structurally false under v1.16/v1.19 (latent since v1.9 A.1 when timestamp body field was introduced) and are corrected by §3.10.1's v1.20 path scheme.

Per-occurrence path (v1.16 per-reason subsegment + v1.20 terminal marker_hash). Markers are bound at .../{chain_id}/{step_index}/{reason}/{marker_hash} (v1.20 path scheme; canonical home §3.10.1). The {reason} is a path subsegment, not just a body field, so distinct reasons occupy distinct sibling subtrees rather than overwriting each other. Each distinct occurrence within a reason lands at its own {marker_hash} terminal segment (v1.20) — a flapping target produces multiple markers, intentionally; the tree IS the event log. Same-content re-binding (e.g., subscription redelivery of the same logical event under §3.10.6's timestamp-capture discipline) is genuine content-addressed tree:put no-op. This replaces the v1.10–v1.14 shared .../{chain_id}/{step_index} path (where the latest writer of any reason clobbered the others) AND the v1.16/v1.19 .../{chain_id}/{step_index}/{reason} path (where the v1.16 "re-binding is idempotent" claim was structurally false given the body timestamp field — corrected by v1.20). The motivating case: merge_value_not_map is an assembly-phase observation that causally precedes the dispatch outcome — under the old shared path it was guaranteed to be overwritten by the downstream forward_dispatch_non2xx it usually triggers, hiding the root cause behind the symptom. The subsegment lets both survive and be listed under .../{chain_id}/{step_index}/. Behavioral-for-observability change only; markers carry no reactive behavior, so no control path depends on the path shape. (Per proposals/implemented/PROPOSAL-CONTINUATION-MERGE-ASSEMBLY.md §10a, option 2a.)

Neither marker triggers advancement, retry, or any reactive behavior; all preserve the fire-and-forget semantics while making the failure observable. remaining_executions is still decremented normally on every completed dispatch (success or non-2xx); the marker adds visibility, not behavior.

Lost-error marker (on_error delivery failure). The two on_error dispatches above are best-effort by design — a compensation path, not guaranteed delivery (adding suspension would create recursive error-handling complexity with diminishing returns). The hazard that leaves is a chain whose on_error delivery itself fails (mis-shaped error result, wrong delivery URI, downstream handler not ready) having no observable surface at all — from the developer's side: "I installed the chain, fed it input, nothing happened, no errors anywhere."

When an on_error dispatch fails (transient or permanent), implementations MUST bind a lost-error marker entity — entity type: system/runtime/chain-error-lost — at system/runtime/chain-errors/lost/{chain_id}/{step_index}/on_error_dispatch_failed/{marker_hash} (v1.20 path scheme; canonical home §3.10.1) capturing:

Path and type are pinned (cross-impl): the marker entity type MUST be system/runtime/chain-error-lost so the marker's content hash agrees across implementations. {step_index} is the original request ID of the dispatch whose on_error delivery failed — there is no numeric step counter. Under v1.20 (per §3.10.1): each distinct failure observation lands at its own {marker_hash} terminal segment under .../{step_index}/on_error_dispatch_failed/. A retried-and-still-failing delivery produces a new observation (new timestamp per §3.10.6 timestamp-capture discipline → new content_hash → new path); the tree records the full retry history rather than overwriting. Bytes-identical redeliveries (same logical event redelivered) dedupe by content-addressing (same content_hash → same path → tree:put no-op). Distinct reasons at the same step occupy distinct sibling {reason} subtrees (unchanged). The v1.16 "re-binding overwrites" framing was structurally false under any version since v1.9 A.1 introduced the body timestamp field — corrected by v1.20.

The marker is informational. Consumers MAY aggregate it for diagnostics or operator surfacing. The marker MUST NOT trigger advancement, retry, or any other reactive behavior — it is an observation sink, not a control path; this preserves the best-effort semantics above (the marker adds visibility, not delivery). Collection is MUST for any peer that binds [MUST], and the collector is the binder. A MUST-write paired with a MAY-collect is a leak by construction, whatever default the MAY suggests — and elevating the bind obligation above created exactly that pairing. The actor falls out of §3.10.7's invariant: markers are bound in the observing peer's own tree under its own authority, so the binder can always collect them. The authority answer and the actor answer are the same answer. Retention is configured at system/config/chain-errorsretention_ms, default 24 hours — the value this section already suggested, now with a home, since "a configured retention window" had named a knob no document defined.

This codifies system/runtime/chain-errors/ as the conventional sub-purpose for chain-error sinks (per GUIDE-PEER-CONCERNS-AND-NAMESPACES §4.1 — "specs describing particular purposes enumerate sub-purposes"); .../lost/ is the lost-error sink (covering both the A.1 on_error-delivery-failure case above and the v1.13 no-on_error forward-dispatch non-2xx case below).

Lost-error marker (no-on_error forward dispatch non-2xx — v1.13; reason vocabulary AMENDED v1.19). When a forward continuation with no on_error receives a handler-level non-2xx response, implementations MUST bind a lost-error marker entity — entity type: system/runtime/chain-error-lost (same type as the A.1 case above) — at:

system/runtime/chain-errors/lost/{chain_id}/{step_index}/{result.data.code}/{marker_hash}

where {result.data.code} is the response's code value verbatim per §3.10.5 (e.g., capability_denied for 403, not_found for 404, internal for 500, etc.). The marker body captures:

Trigger range: all status codes ≥ 400 (consistent with the is_error definition above). Trigger timing: bound on every non-2xx occurrence (not only on remaining_executions exhaustion). Under v1.20 (per §3.10.1): each occurrence lands at its own {marker_hash} terminal segment — a flapping target that fails 10 times at 10 wall-clock moments produces 10 distinct marker entities at 10 distinct paths under .../{step_index}/{result.data.code}/ (the tree IS the event log). Same-content re-binding (e.g., subscription redelivery of the same logical event under the §3.10.6 timestamp-capture discipline) is genuine content-addressed tree:put no-op. Distinct error codes at the same step coexist as sibling {reason} subtrees — a 500 then a 503 at the same step produces marker entities under .../{step_index}/internal/{hash_a} and .../{step_index}/unavailable/{hash_b} respectively. The v1.13 "re-binding overwrites within an error code" framing was structurally false under any version since v1.9 A.1 introduced the body timestamp field — corrected by v1.20.

DEPRECATION (v1.19): the v1.13 reason "forward_dispatch_non2xx" is deprecated. It conflated distinct error codes under a single catch-all path — silently broke the v1.16 sibling-paths property by clobbering the actual error vocabulary the response already provides. New binds use result.data.code as {reason}; existing markers under the deprecated reason value are still readable as historical state. Migration ~5-10 LoC per impl; single-commit per impl (no long-lived transition window).

{step_index} for v1.13 markers (v1.14 pin). {step_index} MUST be the original request ID of the forward dispatch that returned non-2xx — identical to the A.1 convention pinned above. Not the cascade depth, not an internal step counter; the request ID is the only stable, content-addressable key for the logical step. Under v1.20 (per §3.10.1): step-identity stability is still required (so redeliveries of the same logical step's same observation land at the same (chain_id, step_index, reason, marker_hash) coordinate and dedupe via content-addressing). The retry-idempotency framing of v1.14 is now interpreted as same-content re-bind idempotency (per §3.10.1 + §3.10.6 timestamp-capture discipline); distinct observations within the same logical step occupy distinct {marker_hash} terminal segments. Cross-impl absorption found Rust picked cascade_depth (not stable across the impl's internal book-keeping) while Go picked RequestID; this pin matches Go and the A.1 convention.

The marker is informational. Consumers MAY aggregate it for diagnostics or operator surfacing. The marker MUST NOT trigger advancement, retry, or any other reactive behavior — remaining_executions is still decremented normally; the dispatch is still classified as a completed forward dispatch (per the v1.10 classification above); the chain still advances. The marker adds visibility, not behavior. Collection is MUST and self-collected, on the system/config/chain-errorsretention_ms window (§3.4 A.1).

This closes the silent-burn observability gap that v1.10's "Known limitation (flagged, not closed here)" note deliberately deferred. Per proposals/PROPOSAL-CONTINUATION-NO-ON-ERROR-SINK.md (I-8). The fix is purely additive — no behavioral change to dispatch, no new entity types beyond the existing A.1 marker, no wire-format change.

Lost-error marker (merge-mode non-map value — v1.16). When a continuation with result_merge: true (§2.2 dispatch-mode table) reaches the assembly step with a post-transform value that is not a map, the merge degrades to static-params-only and the intended dynamic fields are silently dropped — the dispatch proceeds, but with incomplete params (and so usually returns non-2xx downstream). Because this is an assembly-phase misconfiguration that causally precedes — and is masked by — the downstream outcome, implementations MUST bind a lost-error marker entity — entity type: system/runtime/chain-error-lost — at system/runtime/chain-errors/lost/{chain_id}/{step_index}/merge_value_not_map/{marker_hash} (v1.20 path scheme; canonical home §3.10.1) capturing:

{step_index} is the original request ID (same convention as above). The marker is informational — MUST NOT trigger advancement, retry, or any reactive behavior; the dispatch proceeds with static-only params regardless; the chain advances. Same retention obligation as the other lost-error markers — MUST-collect, self-collected (§3.4 A.1). The per-reason subsegment is load-bearing here: this marker is the root cause and the dispatch it precedes usually emits a forward_dispatch_non2xx marker (the symptom) for the same step — under the old shared path the symptom would overwrite the cause within milliseconds and post-hoc poll/list inspection would never recover it. Per proposals/implemented/PROPOSAL-CONTINUATION-MERGE-ASSEMBLY.md.

Chain-trace anomaly detection. A forward dispatch that completes with NEITHER (i) a remaining_executions decrement visible on the continuation entity at <install_path> NOR (ii) a chain-error marker under system/runtime/chain-errors/{lost,rejected}/{chain_id}/{step_index}/ is anomalous — it indicates a dispatcher-level silent drop. Inspect consumers MAY treat this case as a §9 #8 anomaly signal (per GUIDE-INSPECTABILITY.md v1.1 §9 #8); validate-peer MAY treat it as a CAT-CHAIN-COMPLETION conformance failure for forward-dispatch chains. The substrate provides no behavior on detecting the anomaly — it's an observability invariant that lets tooling distinguish "the chain ran and completed (success or marked failure)" from "the chain never ran or silently dropped." Per the chain-participation-invariants proposal §2.1.

3.5 Join Slot Advancement

advance_join_slot(join, join_path, slot_name, result):
  ; Check exhaustion
  if join.data.remaining_executions != null:
    if join.data.remaining_executions == 0:
      return {status: 200, result: {advanced: false, exhausted: true}}

  ; Round-identity guard (§3.5a) — drop a straggler from an abandoned round.
  ; A slot advance carries the round_id it targets; a slot whose targeted round
  ; is not the join's current round MUST NOT be admitted and MUST be dropped
  ; loudly (a `stale_round` marker naming the slot). This keeps an abandoned
  ; round's late slot out of a later round — no mixed-generation stitch.
  if join.data.round_id != null and result.targeted_round != null:
    if result.targeted_round != join.data.round_id:
      bind_lost_marker(join_path, reason: "join_late", slot: slot_name,        ; §3.5a — durable marker
                       targeted_round: result.targeted_round,
                       current_round: join.data.round_id)
      return {status: 200, result: {advanced: false, dropped: "stale_round",   ; §3.5a — wire drop-body
                                    slot: slot_name,
                                    targeted_round: result.targeted_round,
                                    current_round: join.data.round_id}}

  ; Validate slot
  if slot_name not in join.data.expected:
    return error(400, "unexpected_slot")

  ; Exactly-once: reject if slot already filled in this round
  received = copy(join.data.received or {})
  if slot_name in received:
    return error(409, "slot_already_filled")

  ; Accumulate
  received[slot_name] = result

  ; Check completeness
  if set(keys(received)) == set(join.data.expected):
    ; All slots filled — dispatch
    ; The completed received map is the "result" for dispatch mode logic
    dispatch_result = execute_dispatch(join, received)

    ; Handle dispatch result
    if dispatch_result.error != null:
      ; Do NOT decrement remaining_executions
      ; Do NOT reset received (preserve accumulated state)
      if dispatch_result.error.transient:
        suspend_execution(...)
        return {status: 200, result: {advanced: false, suspended: true}}
      return error(dispatch_result.error.status, dispatch_result.error.code)

    ; Lifecycle — only on successful dispatch
    if join.data.remaining_executions != null:
      if join.data.remaining_executions - 1 == 0:
        ; Exhaustion — MUST delete the join entity (delete-if-last); do not
        ; retain a zero-count husk. See the "Exhaustion" note in §3.4 (one rule,
        ; both continuation kinds — cross-peer-observable pin).
        entity_tree.delete(join_path)
      else:
        updated = copy(join)
        updated.data.remaining_executions = join.data.remaining_executions - 1
        updated.data.received = {}  ; Reset for next round
        if updated.data.round_id != null:            ; §3.5a — open the next round
          updated.data.round_id = join.data.round_id + 1
        entity_tree.put(join_path, updated)
    else:
      ; Standing join (remaining_executions: null) — reset received, open next round
      updated = copy(join)
      updated.data.received = {}
      if updated.data.round_id != null:              ; §3.5a — open the next round
        updated.data.round_id = join.data.round_id + 1
      entity_tree.put(join_path, updated)
    return {status: 200, result: {advanced: true}}
  else:
    ; Not complete — update join entity
    updated = copy(join)
    updated.data.received = received
    current_hash = join.content_hash
    entity_tree.put(join_path, updated, expected_hash: current_hash)
    ; On 409 conflict: re-read join, merge received, retry
    return {status: 200, result: {advanced: true}}

3.5a Join Completion Policy — deadline, abandon, fire-partial, round identity (normative)

A standing join (remaining_executions: null) that misses a slot would otherwise wedge forever — it never fires and, being standing, never resets. This subsection is the lifecycle half of the standing-continuation model: a standing continuation owns a completion policy independent of any single trigger (the §3.1b authority half is its twin — a trigger reaches a standing continuation, it does not own it). The policy is opt-in: a join with none of completion_deadline_ms / on_incomplete / round_id behaves exactly as §3.5 (wait-forever, no rounds), so no existing join changes behavior.

The per-round deadline. With completion_deadline_ms set, each round has a wall budget, armed when the round opens (reset with received). On the deadline passing with received ⊊ expected, the round's outcome is governed by on_incomplete:

The sweep — reap all tracked expired joins on touch, no timer (MUST). Reaping is traffic-driven, not timer-driven: on any continuation operation, an implementation MUST sweep all tracked deadline-carrying joins (throttled) and reap any whose round deadline has passed with received ⊊ expected, applying its on_incomplete outcome — not only the join being touched. A background timer/reaper subsystem MUST NOT be required (it would introduce a peer lifecycle the model deliberately declines). Sweeping only the touched join is non-conformant: a join that goes silent while the peer stays continuation-active would then be reaped by a sweep-all peer but never by a touched-only peer, so the join_incomplete marker would appear on one peer and not another — a cross-peer divergence on exactly the liveness signal this policy exists to make deterministic. (The truly-quiescent peer — zero continuation ops — is uniform: nothing sweeps anywhere. Whether a peer should eventually abandon expired joins with no activity at all is a separate, deferred design question for all impls at once, not a convergence gap — no impl does it today.)

Round identity — the straggler guard (MUST). abandon resets received and opens the next round, but a bare slot advance carries no round identity, so a straggler from the abandoned round (a merely-slow slot, not a lost one) is byte-identical at the join to a next-round slot and would land in it — a round stitched from two generations, producing a boundary hash that is wrong yet deterministic-looking. The join therefore carries a monotonic round_id, incremented on every reset/fire (§3.5); every slot advance is tagged with the round_id it targets, and the §3.5 round-identity guard drops any slot whose targeted round ≠ the join's current round.

Delivered-error slots — the target must reject (MUST). A delivered non-2xx fills its slot and the join fires normally; the failure then surfaces at the stitch. The received map MUST preserve each slot's status (a non-2xx / compute/error payload passes through as-is, not coerced into boundary bytes). A target requiring all-good slots (the determinism-critical stitch) MUST reject on any error slot — surfacing a join_error_slot marker for the round rather than emitting a boundary entity computed from an error payload. An error slot MUST NOT silently produce a boundary hash that diverges across peers.

Determinism preserved. Both mechanisms — undelivered (deadline/abandon) and delivered-error — are failure-path only; the success path is untouched (received keyed by slot name, read in expected order, byte-identical to serial). Failure produces a marker, never a partial boundary entity — a failed round is observably failed, not silently divergent.

Markers and the drop-response body (cross-peer-observable — MUST). Completion markers are durable lost-sink entities (type system/runtime/chain-error-lost) bound under the §3.4 scheme at system/runtime/chain-errors/lost/{chain_id}/{step_index}/{reason}/{marker_hash}. The {reason} segment is a continuation-family code (snake), one of three — and MUST NOT mint strings colliding with the bounds family (bounds_exceeded / chain_depth_exceeded):

Distinct from the durable marker, a stale-slot drop also returns a wire body to the slot sender (which may be a remote peer, so the shape is cross-peer-observable and pinned exactly): {advanced: false, dropped: "stale_round", slot, targeted_round, current_round} — all five keys present, status 200, dropped the value stale_round (snake — not re-cased to kebab). Note stale_round is the drop-body value; the durable marker for the same event carries reason join_late. (These reason strings + the drop-body shape are source-verified converged three-way at Go 1ccdd51 / Rust aec13b1 / Python 1590d8a.)

3.6 Execute Dispatch

execute_dispatch(continuation, raw_result):
  ; Step 1: Transform
  value = raw_result
  if continuation.data.result_transform != null:
    transform = continuation.data.result_transform
    if transform.extract != null:
      extracted = navigate(value, transform.extract)
      if extracted != NAVIGATION_FAILED:
        value = extracted
      ; else: pass original value through (best-effort navigation)
    if transform.select != null:
      mapped = {}
      for dest_field, source_path in transform.select:
        mapped[dest_field] = navigate(value, source_path)
      value = mapped

  ; Step 2: Assemble params
  ; (result_merge + result_field is rejected at install — §2.1, mutually exclusive)
  if continuation.data.result_merge == true:                      ; merge (v1.16)
    if not is_map(value):
      bind_lost_error_marker(chain_id, request_id,                ; §3.4 — observable, no reactive behavior
                             reason: "merge_value_not_map",
                             value_type: type_of(value))
      final_params = continuation.data.params or {}               ; no-op merge: static-only; dispatch still proceeds
    else:
      final_params = shallow_merge(continuation.data.params or {}, value)   ; top-level union; value keys win
  else if continuation.data.params is null and continuation.data.result_field is null:
    final_params = value                                          ; pass-through
  else if continuation.data.params != null and continuation.data.result_field != null:
    final_params = copy(continuation.data.params)
    final_params[continuation.data.result_field] = value          ; inject
  else if continuation.data.params != null:
    final_params = continuation.data.params                       ; trigger
  else:
    return error(400, "invalid_continuation")                     ; result_field without params

  ; Step 3: Build EXECUTE
  ;
  ; Resolve dynamic EXECUTE fields from the transform, falling back to
  ; static values on the continuation entity.
  if continuation.data.result_transform != null:
    transform = continuation.data.result_transform
  else:
    transform = null

  execute = {
    uri:       resolve_or_default(value, transform, "target_extract",    continuation.data.target)
    operation: resolve_or_default(value, transform, "operation_extract", continuation.data.operation)
    resource:  resolve_or_default_resource(value, transform, "resource_extract", continuation.data.resource)
    params:    final_params
  }
  ; Note: the *_extract fields navigate the post-transform `value` (above),
  ; independently of how Step 2 assembled `final_params`. Merge mode does not
  ; change which value *_extract reads — it is still the post-transform value,
  ; not `final_params`. This holds identically for pass-through, inject, and
  ; merge assembly modes.

  ; Step 4: Chain link — the deliver_token is NOT a new primitive; its three
  ; slots are already pinned by §4.2's capability table (result deliver_token
  ; row). Minted per §3.6a; `servicing_peer` is the peer that will execute this
  ; EXECUTE and therefore perform the delivery.
  if continuation.data.deliver_to != null:
    execute.deliver_to = continuation.data.deliver_to
    execute.deliver_token = mint_result_deliver_token(continuation.data.deliver_to,
                                                      servicing_peer)   ; §3.6a

  ; Step 5: Capability — dispatch_capability MUST be present for dispatching continuations.
  ; Revocation is handled automatically by dispatch-layer verify_request
  ; (ENTITY-CORE-PROTOCOL.md §5.2 step 4, per V3 C2): if the stored
  ; dispatch_capability has been revoked since the continuation was created,
  ; the dispatched EXECUTE fails with permission_denied, and the error flows
  ; back through the continuation's error handling path (§17 — error
  ; continuation). No continuation-specific revocation check is needed.
  if continuation.data.dispatch_capability == null:
    return error(400, "missing_dispatch_capability",
      "Continuation must have dispatch_capability to dispatch")
  execute.capability = continuation.data.dispatch_capability

  ; Step 6: Dispatch. chain_depth inherited+incremented; ttl/budget decrement per
  ; ENTITY-CORE-PROTOCOL §5.9 (0.8.1) — NOT refilled. (Corrects the earlier
  ; `peer_default_ttl` refill language, which contradicted §5.9 and every shipped impl.)
  dispatch(execute, bounds: {
    ttl:         decrement(context.ttl)             ; resource backstop — §5.9, one decrement per dispatch
    budget:      decrement(context.budget)
    chain_id:    context.chain_id or generate_id()
    chain_depth: (context.chain_depth or 0) + 1     ; inherited from the wire (§3.11), monotonic — §3.9
  })

Helper functions for dynamic EXECUTE field resolution:

resolve_or_default(value, transform, field_name, default):
  if transform is null: return default
  extract_path = transform[field_name]
  if extract_path is null: return default
  extracted = navigate(value, extract_path)
  if extracted is null or extracted == NAVIGATION_FAILED: return default
  return extracted

resolve_or_default_resource(value, transform, field_name, default):
  if transform is null: return default
  extract_path = transform[field_name]
  if extract_path is null: return default
  extracted = navigate(value, extract_path)
  if extracted is null or extracted == NAVIGATION_FAILED: return default
  ; Wrap extracted value into resource-target structure
  if is_string(extracted): return {targets: [extracted]}
  if is_array(extracted):  return {targets: extracted}
  if is_object(extracted) and extracted.targets is not null: return extracted
  return default

resource_extract has special handling because the resource field is system/protocol/resource-target ({targets: [...]}) but the extracted value is typically a URI string or array of URI strings. The resolve wraps a string into the targets array structure. If the extracted value is already a well-formed resource target object (has a targets field), it is used as-is.

3.6a Result deliver_token — mint_result_deliver_token (normative; v1.22)

Step 4 previously read execute.deliver_token = generate_internal_deliver_token(...). That name was defined nowhere in the corpus — it was the only call site, and no section, guide, or appendix said what it returned. This subsection defines it. Nothing here is a new design: §4.2's capability table already pins every slot, and this is that row written as the algorithm Step 4 invokes.

mint_result_deliver_token(deliver_to, servicing_peer):
  ; The host peer (the peer running this continuation) mints from its own authority
  ; over the inbox it owns. deliver_to.uri is in the host peer's namespace.
  return mint_capability({
    parent:     null                       ; self-rooted at the inbox owner — §4.2 table
    granter:    host_peer_identity
    grantee:    servicing_peer             ; the peer that will author the delivery EXECUTE
    handlers:   [handler_of(deliver_to.uri)]
    operations: [deliver_to.operation or "receive"]
    resources:  {include: [deliver_to.uri]}   ; one path — see the scope MUST below
    expires_at: <= the continuation's own dispatch window
  })

When deliver_token is absent or does not name the delivering peer, the receiver MUST refuse the delivery [MUST]. It MUST NOT fall back to the connection's session capability, to the inbound dispatch capability, or to any other capability it happens to hold. There is no permissive reading here: the field exists precisely to authorize this delivery, and a peer that substitutes a broader credential when it is missing is authorizing the delivery on authority the sender never conferred.

Why this is a MUST and not a SHOULD, recorded because the cost was measured. With the mint undefined, each implementation improvised the absent-field case differently, and both improvisations were locally reasonable: one receiver fell back to the connection's session capability, which verifies on a dialed connection; the other presented the inbound dispatch capability and authored as itself, failing the sender's grantee == author check. Same-implementation pairings passed — both ends improvised the same way — so the only signal in the entire system was a single cross-implementation failure, which was then routed at the peer that reported it, twice, across two cycles. A MAY-shaped silence at a cross-peer seam does not produce two conformant readings; it produces one bug that only the cross-peer pairing can see. (GUIDE-EXTENSION-DEVELOPMENT.md interop pitfall (a).)

3.6b Advance authority — what the advance's caller capability is (normative; v1.22)

The continuation advances under its own dispatch_capability, and that capability — not the trigger's — is the caller capability of the advance context [MUST]. §3.1b already says a trigger reaches a continuation and does not own it; this states the same rule at the layer that was actually consulting the wrong value.

Consequence — the dispatch_capability must cover the resource, which §4.2 already requires. §4.2 ("When present") says the capability MUST grant the continuation's target, operation, and resource. A dispatch_capability scoped to handler and operation only is already non-conformant; it works today only where something broader is inherited underneath it. A vector whose fixture is scoped that way is asserting the escalation, not the rule — fix the fixture, not the rule. Where the target is resolved at advance time (target_extract), scope by pattern; §2.2's capability-interaction note already establishes that a pattern-scoped resources.include authorizes any dynamically-extracted path matching it, so nothing is lost in expressiveness.

3.7 Resume Operation

handle_resume(ctx, params):
  ; params is system/continuation/resume-request
  suspended_path = ctx.resource.targets[0]
  suspended = entity_tree.get(suspended_path)

  if suspended is null:
    return error(404, "not_found")
  if suspended.type != "system/continuation/suspended":
    return error(400, "not_suspended")

  ; Build EXECUTE from suspended state
  execute = {
    uri:       suspended.data.target
    operation: suspended.data.operation
    resource:  suspended.data.resource
    params:    suspended.data.params
    bounds:    root_chain_depth_0(params.bounds or peer_defaults)  ; fresh operator-authorized root — see note
  }

  ; Merge resolution if provided
  if params.resolution != null:
    execute.params = merge(execute.params, params.resolution)

  ; Add delivery spec if provided
  if params.deliver_to != null:
    execute.deliver_to = params.deliver_to

  ; Delete suspended entity
  entity_tree.put(suspended_path, null)

  ; Dispatch
  return dispatch(execute)

Resume roots chain_depth at 0 (normative). Resume is an operator-authorized fresh dispatch after a suspension — often one caused by bounds_exceeded on depth (§3.9). It MUST root bounds.chain_depth at 0: fresh operator intent is a fresh root, exactly as a fresh external trigger roots a standing continuation (§3.9; ENTITY-CORE-PROTOCOL §3.11, 0.8.1). Otherwise a chain resumed after a depth suspension re-suspends immediately.

3.8 Abandon Operation

handle_abandon(ctx, params):
  suspended_path = ctx.resource.targets[0]
  suspended = entity_tree.get(suspended_path)

  if suspended is null:
    return error(404, "not_found")
  if suspended.type != "system/continuation/suspended":
    return error(400, "not_suspended")

  entity_tree.put(suspended_path, null)
  return {status: 200, result: {abandoned: true, path: suspended_path}}

3.9 Suspension from Dispatch Layer

The dispatch layer MAY register a suspension handler. When registered, the dispatch layer creates a suspended continuation instead of returning an error response on:

The suspension handler interface:

suspend(target, operation, resource, params, reason, chain_id, author) -> suspended_path

The dispatch layer calls suspend() with the EXECUTE context that could not complete. The suspension handler creates a system/continuation/suspended entity at system/continuation/suspended/{generated_id} and returns the path.

The dispatch layer returns the error response with an additional field:

{status: 429, result: {code: "bounds_exceeded", suspended_at: suspended_path}}

If no suspension handler is registered, the dispatch layer returns the error response without creating a suspended entity. This preserves backward compatibility with sync-only peers.

Chain depth tracking: chain_depth is carried in system/bounds (ENTITY-CORE-PROTOCOL §3.11) and inherited across peer boundaries, exactly as cascade_depth is. The dispatch layer sets it two ways:

When bounds.chain_depth exceeds the peer's maximum, the dispatch layer calls suspend() with reason: "bounds_exceeded" (ENTITY-CORE-PROTOCOL §4.10(b), 0.8.1 Ruling 3 — not chain_depth_exceeded, which is the capability-chain depth limit, 400). The maximum is peer-local but MUST be uniform across the cohort; the value tested is global, because chain_depth is inherited rather than reset. TTL/budget decrement (§5.9) does not touch chain_depth.

Cross-peer chain length: Global chain length is bounded by chain_depth, carried in system/bounds (§3.11) and inherited across peer boundaries (the cascade_depth precedent), suspended uniformly at the cohort-wide ceiling with reason: bounds_exceeded. TTL/budget are a resource backstop that decrements per §5.9; they do not define chain length. Deliver-token expiry remains a wall-clock backstop, not a hop bound.


3.10 Chain-Error Marker Convention (cross-cutting; normative)

This section is the canonical home for the chain-error marker convention. §3.4's lost-marker discussion (v1.16, A.1, v1.13) remains in place for the forward-advancement-specific context, but the convention details — kinds, paths, binding authority, body fields, reason registry — live here. New chain-error variants reference this section.

Two kinds of chain-error markers are bound under system/runtime/chain-errors/:

The kind distinction lives in the path; the entity type for both is system/runtime/chain-error-lost (unchanged from v1.16 §3.4 to avoid type proliferation; the kind segment carries the distinction).

3.10.1 Path scheme (normative; v1.20)

system/runtime/chain-errors/{kind}/{chain_id}/{step_index}/{reason}/{marker_hash}

where:

Per-occurrence addressing (normative; v1.20). Each distinct marker observation lands at its own path because each distinct observation produces a distinct marker content_hash (the body's timestamp field at minimum varies per occurrence — see §3.10.6 timestamp-capture discipline). The tree is therefore the event log for chain errors: a flapping target that fails 10 times at 10 wall-clock moments produces 10 distinct marker entities at 10 distinct paths.

Content-addressed idempotency (normative; v1.20 — corrected from v1.16/v1.19). Re-binding bytes-identical body (same content_hash) at the same path is a tree:put no-op by construction. This covers the redelivery case (subscription redelivery of the same logical event under the §3.10.6 timestamp-capture discipline produces bytes-identical bodies, so does not multiply markers). It does NOT cover the re-occurrence case (10 actual flaps produce 10 markers, intentionally — observers want the full event history).

Sibling-path coexistence. Distinct {reason} codes at the same (chain_id, step_index) coexist as distinct sibling paths under that prefix; each {reason} then has zero-or-more {marker_hash} children (one per distinct observation).

Migration note (v1.16/v1.19 → v1.20). Prior versions terminated the path at {reason}, claiming "same-reason re-binding is idempotent (content-addressed)" — this was structurally false because timestamp body field (introduced v1.9 A.1) made each occurrence's content_hash differ. Three impls would have resolved the contradiction independently at the tree:put boundary (last-writer-wins / CAS-create / overwrite) → silent cross-impl divergence on first flap. v1.20 closes the contradiction by adding the terminal {marker_hash} segment; the §3.10.1 idempotency claim is now structurally true. Pre-v1.20 markers at the shorter path remain readable as historical state; new binds land at the v1.20 path. ~5-10 LoC per impl — single string concat at the bind site. Per the Stage-4 transport-and-observability proposal §1.2c.

3.10.2 Lost variant (sender / originator side)

Bound by the dispatching component on the sender side when an outbound EXECUTE fails or downstream returns a non-2xx. The {reason} segment is the code field of the error description per §3.10.5 (one rule for all {reason} values; covers internal-engine, response-derived, and transport-level failures uniformly).

Examples by category:

Bound under the sender-side component's component-owned authority (per §3.10.7).

Cap-rejection mirror. When the sender's outbound EXECUTE comes back as a 403 carrying ErrorData.rejected_marker (i.e., the receiver bound a rejected marker per §3.10.3), the sender binds a corresponding lost marker with {reason} = capability_denied (the same code) and includes rejected_marker_hash in the body per §3.10.6. Both peers' markers share the same {reason} segment; the kind segment (lost vs rejected) tells you which side observed it.

3.10.3 Rejected variant (receiver / dispatcher side)

Bound by the core dispatcher on the receiver side when an inbound EXECUTE with Bounds.ChainID is refused on cap-check. The dispatcher binds the marker BEFORE returning the response.

Scope (normative): the rejected variant fires ONLY when the rejected inbound EXECUTE carries a Bounds.ChainID (i.e., it is a chain dispatch). Ordinary point-to-point EXECUTE cap-rejections continue to surface via the response only — no rejected marker — because the caller synchronously sees the rejection in the response. Chain-error sinks exist precisely to cover the chain-dispatch case where the originating step does not synchronously observe downstream failures.

Bound under the core dispatcher's core/chain-errors component-owned authority (per §3.10.7).

Reason value (normative). The {reason} segment is the dispatcher's result.data.code value verbatim (ENTITY-CORE-PROTOCOL.md §3.3 line 736; same code that goes on the wire response). For cap-rejection, the canonical code is capability_denied (ENTITY-CORE-PROTOCOL.md §3.3 line 736's own example; all three reference impls converge to this value as of v1.19 ratification). Impls that emit a different code at the response layer (e.g., the HTTP-family-only forbidden) migrate to capability_denied as part of the v1.19 spec-text landing.

The {reason} IS the code — same value, recorded once on the wire and once in the path. The body's code field (per §3.10.6) carries the same value denormalized.

3.10.4 Mirror-pointer pattern (SHOULD)

When a rejected-variant marker is bound on the receiver, the receiver SHOULD include the marker's content hash in the EXECUTE_RESPONSE error metadata via the optional rejected_marker field on ErrorData:

ErrorData {
  code:             text             // e.g. 'forbidden'
  message:          text
  rejected_marker:  Hash (omit)      // receiver's marker hash; new in this revision
}

The sender then binds a lost-variant marker at .../lost/{chain_id}/{step_index}/capability_denied/{marker_hash} (v1.20 path scheme; {reason} = capability_denied per §3.10.5). The body of the sender's marker SHOULD include rejected_marker_hash: Hash (the receiver-side marker hash from the response) so cross-peer audit can walk the pair from either side. Note (v1.20): rejected_marker_hash body field value equals the terminal {marker_hash} segment of the receiver-side marker's path — same value, two surfaces (path tail + body field).

The mirror reference is informational; absence does not invalidate either marker. The ErrorData.rejected_marker field is additive — implementations without it produce/consume valid envelopes.

3.10.5 {reason} value (normative; v1.19 — collapsed from v1.18 reserved-reasons registry)

The rule. {reason} is the code field of the system/protocol/error shape (ENTITY-CORE-PROTOCOL.md §3.3) associated with the failure. Same value across both kinds. Same value across all failure categories. No separate reason vocabulary owned by this section.

For each category of failure, the canonical home for the code is per ENTITY-CORE-PROTOCOL.md §3.3 line 742's per-component scoping:

Failure categorycode source (canonical home)Examples
Handler returns non-2xx responseHandler's own appendix per ENTITY-CORE-PROTOCOL.md §3.3 line 742 (e.g., EXTENSION-TREE.md Appendix A for tree handler)capability_denied (ENTITY-CORE-PROTOCOL.md §3.3 line 736), not_found, handler-specific codes
Continuation engine internal failureEXTENSION-CONTINUATION Appendix A (this spec; see below)on_error_dispatch_failed, merge_value_not_map, transform_failed, chain_construction_invalid
Per-request transport failureENTITY-CORE-PROTOCOL.md §6.12recv_timeout, connection_broken, protocol_error
Connection / handshake failureENTITY-CORE-PROTOCOL.md §4.7incompatible_protocol, invalid_signature, etc. (rare in chain-dispatch context; usually surfaces before any chain step exists)

The marker entity body carries the same code value denormalized (per §3.10.6).

A handler originating a dispatch that belongs to a known chain MUST set bounds.chain_id to that chain's id [MUST]. A retry or timer-fired dispatch has no inbound context, so §3.6 step 6 mints it a fresh id — correct by the sub-chain model, but it scatters every failure of one long-lived relationship across unrelated nodes and leaves the id the operator actually has appearing nowhere. The handler already holds that id; supplying it is not a new mechanism. With it, every failure of one relationship lands under a single node and "which relationship is failing, and for how long?" is answered by the field that already works.

A constant sentinel is NOT a conformant coordinate [MUST]. Emitting a fixed {chain_id} or {step_index}internal, unknown, or any other constant — collapses every marker from every chain and every peer into one node. It satisfies the letter of the bind obligation while delivering none of its purpose. A MUST whose conformant implementation carries zero information is not a MUST, and this is pinned so a conformance probe can reject it.

Path-safety applies to EVERY interpolated coordinate, not only {reason} [MUST]. {chain_id} and {step_index} are wire-supplied, so a hostile peer ignores any producer-side constraint by definition — and these markers bind under component-owned authority (§3.10.7), which removed the accidental containment a caller-capability bind used to provide. Substrate authority plus an attacker-controlled path segment is a write-anywhere primitive. The rejected variant is the sharpest case: it binds precisely because a sender's capability check failed, so an unauthorized caller reaches it by construction.

Path-safety (normative). Codes used as {reason} path segments MUST conform to ENTITY-CORE-PROTOCOL.md §1.4 path-segment rules (UTF-8; no null bytes; no empty segments; no embedded /). A handler emitting a non-path-safe code SHOULD have it sentinel-substituted ({reason} = unspecified_error) by the dispatching engine with the raw code preserved in the marker body's code field per §3.10.6.

Fallback for missing code. Handlers SHOULD always include code on error responses per ENTITY-CORE-PROTOCOL.md §3.3. If a handler emits an error response with status >= 400 but no code field, the engine SHOULD bind under {reason} = protocol_error (ENTITY-CORE-PROTOCOL.md §6.12; the missing-code condition IS a protocol violation by the responding handler, surfaced at the transport-consumer boundary).

Sibling-path coexistence (v1.16 §3.4 property preserved; v1.20 extended). Distinct codes at the same (chain_id, step_index) coexist as distinct sibling {reason} paths. Under v1.20, each {reason} subtree then has zero-or-more {marker_hash} children — one per distinct observation (each occurrence of the same code at different wall-clock times produces a distinct {marker_hash} because the body's timestamp field varies; see §3.10.6). The tree IS the event log. Same-content re-binding (same body bytes → same content_hash → same path) is genuine tree:put no-op idempotency by construction; redelivery under the §3.10.6 timestamp-capture discipline dedupes naturally.

Deprecation (v1.19). The v1.13 reason "forward_dispatch_non2xx" is deprecated. Impls SHOULD migrate to the code-as-reason rule above. Pre-v1.19 markers under the deprecated reason remain readable as historical state. Subscription patterns matching the deprecated path should migrate to chain-errors/lost/*/*/* or to specific Category B canonical codes (e.g., .../capability_denied).

Cap-rejection: canonical 403 code is capability_denied (ENTITY-CORE-PROTOCOL.md §3.3 line 736 example; convergent across all three reference impls as of v1.19 ratification — entity-core-go and entity-core-rust pre-v1.19 conformant; entity-core-py migrates forbiddencapability_denied as part of the v1.19 single-commit migration).

v1.18 reserved-reasons table (DEPRECATED; kept for migration reference only)

The following v1.18 reason values are deprecated by v1.19 and MUST NOT be used in new binds:

Deprecated {reason}v1.19 replacementMigration
forward_dispatch_non2xx{reason} = result.data.code (handler's actual code)~5-10 LoC per impl; single-commit migration; existing markers remain readable
forward_dispatch_403{reason} = capability_denied (canonical 403 code)Zero — no impl wrote v1.18 reason
cap_denied{reason} = capability_denied (canonical 403 code)Zero — no impl wrote v1.18 reason
on_error_dispatch_failedSame identifier; canonical home moved to EXTENSION-CONTINUATION Appendix AZero — same string; sources from canonical home
merge_value_not_mapSame identifier; canonical home moved to EXTENSION-CONTINUATION Appendix AZero — same string; sources from canonical home

3.10.6 Marker entity body-fields registry (normative)

Marker entities (type system/runtime/chain-error-lost, used by both lost and rejected kinds — the kind distinction lives in the path) carry these reserved body field names. Impls MUST use these names with these types; impls MAY add additional fields for impl-specific context (consumers treat unknown fields as informational, no reactive behavior).

Reserved across both kinds:

FieldTypePurpose
reasontextthe {reason} value matching this marker's path segment (denormalized for in-body inspection without path parsing)
timestampintegerUnix milliseconds when the failure was observed (see timestamp-capture discipline below)
chain_idtextfor in-body inspection without path parsing
step_indextextfor in-body inspection without path parsing

Reserved on lost kind:

FieldTypePurpose
target_uritextURI the dispatch was aimed at
target_peer_idtextpeer ID the dispatch was aimed at
statusintegerdownstream status code (when applicable)

Reserved on rejected kind:

FieldTypePurpose
requesting_peer_idtextpeer ID that originated the rejected request
attempted_uritextURI the rejected request was aimed at

Reserved on lost kind WHEN the marker mirrors a peer's rejected marker (§3.10.4 mirror-pointer pattern):

FieldTypePurpose
rejected_marker_hashHashcontent hash of the receiver-side rejected marker. Stored in the lost marker's body so the cross-peer audit walker can follow the reference without inspecting wire metadata. Body-side companion to wire-side ErrorData.rejected_marker.

Rationale: marker entities are content-addressed. Cross-impl hash convergence on equivalent failures requires the body field names + types to match. Without this registry, three impls would name fields independently and equivalent markers would not cross-impl-hash equal — breaking any cross-peer audit that walks marker pairs by hash.

Timestamp-capture discipline (normative; v1.20). The timestamp field MUST be captured at failure-origination time (the moment the failure is observed in the engine, transport, or handler), NOT regenerated at the marker-bind site. This makes the v1.20 {marker_hash} terminal path segment (§3.10.1) behave correctly:

Without this discipline, redelivery would multiply markers as if each redelivery were a new occurrence — losing the redelivery-dedup property the v1.20 path scheme provides.

3.10.7 Binding authority is behavioral, not mechanism (normative)

Chain-error markers MUST bind under component-owned authority. The bind authority MUST NOT be derived from the caller's propagated capability.

Rationale: a chain dispatch may fail precisely because the caller's propagated cap doesn't authorize the writes the error path needs. If the marker bind used the propagated cap, the cap-rejection variant could not record itself. Component-owned authority guarantees the error surface is reachable regardless of caller cap shape. This generalizes the F11 visibility convention (already landed three-way pre-this-revision; the marker path scheme itself was always pinned by v1.16 §3.4).

This is a behavioral rule, not a mechanism. The spec does not mandate the mechanism by which a component realizes this authority. Conformant mechanisms include:

What the spec requires is the OUTCOME: cap-rejected variants can record themselves; observability bind failures don't depend on caller cap shape; markers are reachable regardless of the conditions being observed. The mechanism is impl-private; impls choose what fits their internal architecture.

For dispatcher-side binds (the rejected variant per §3.10.3), the canonical component-owned authority is named core/chain-errors — parallel to the per-extension internal_scopes that the subscription / continuation engines own. New cross-cutting core observability concerns SHOULD follow the same pattern (one named owner per concern).

3.10.8 Bind failure visibility (normative)

If a chain-error marker bind itself fails (system error, mis-configured authority, etc.), the failure MUST be surfaced (logged, returned via operational state, or propagated to monitoring) so operators can diagnose stalled chains. The bind MUST NOT silently claim success. F11 visibility convention generalized.

3.10.9 result.data.code and {reason} are the same value (v1.19; supersedes v1.18 informative)

For all marker kinds and categories, the {reason} path segment IS result.data.code (or its engine/transport equivalent per §3.10.5). Same vocabulary, same identifier, same value — recorded once on the wire (when applicable) and once in the path.

This was an amendment in v1.19. Prior v1.18 framing treated {reason} and ErrorData.code as potentially-divergent layers (code as HTTP-family analog, {reason} as specific cause within that family). That divergence was an artifact of v1.18's invented reserved-reasons registry — it required a separate vocabulary for the registry. With v1.19 collapsing §3.10.5 to the code-as-reason rule, the two are the same value by construction. See the Stage-4 transport-and-observability proposal §1.2b for the absorbed reasoning.

3.10.10 Implementation notes (informative)

3.10.11 Appendix A reference (v1.19)

See Appendix A — Continuation engine error codes at the end of this document for the canonical home of engine-emitted code values. Parallel to EXTENSION-TREE.md Appendix A's tree-handler codes. Per ENTITY-CORE-PROTOCOL.md §3.3 line 742 per-component scoping.


4. Dispatch Capability Model

4.1 Problem

A continuation at a path can target any handler path. The continuation handler dispatches the EXECUTE. Without scoped authorization, a caller could create a continuation that triggers operations beyond the caller's own grants — privilege escalation through continuation creation.

4.2 Solution

The dispatch_capability field on continuation entities carries the content hash of a capability token that authorizes the dispatch. The continuation handler uses this capability on the constructed EXECUTE.

When present: The continuation handler includes the dispatch capability token in the dispatched EXECUTE's envelope. The dispatch layer verifies it through standard capability verification (ENTITY-CORE-PROTOCOL.md §5.2). The capability MUST grant the continuation's target (handler), operation, and resource.

When absent: The advance operation returns an error (missing_dispatch_capability). Every dispatching continuation MUST carry explicit dispatch authority. The continuation handler's own grant authorizes managing continuation entities at system/continuation/* paths — it is not used for dispatching to target handlers.

The capabilities in a cross-peer continuation — an instance of the general three-slot model in ENTITY-CORE-PROTOCOL.md §5.2 ("Cross-peer capability provenance — the three slots"); mirrors EXTENSION-SUBSCRIPTION.md §1.2/§1.3. The dispatch_capability fills: root = B's conferred authority; grantee = the dispatching host peer (EXECUTE author); in-chain granter = the installer (re-attenuation leaf). Read the V7 note first if the root/grantee/granter distinction is unclear — its omission here is exactly what caused the v1.9→v1.11 corrections:

CapabilityCarried inAuthorizesRooted atInstaller's place (granter)Granted to (grantee)Validated by
Dispatch capabilitycontinuation dispatch_capability (+ full chain in envelope included)the deferred EXECUTE to B's handlerB's conferred authorityin-chain as the re-attenuation leaf granterthe dispatching host peer (= the EXECUTE author)install-time in-chain check (§3.1a, §3.2 step 4) + advance-time VerifyChain at B incl. grantee == author (ENTITY-CORE-PROTOCOL.md §5.2)
(if result returns) result deliver_tokenresult EXECUTE deliver_tokendelivery back to the installer's inboxthe installer(is the root)the result-delivering peer (B's engine)dispatch chain at the installer

The dispatch capability is the continuation analog of subscription's B-rooted caller capability; the result deliver_token is the analog of the A-rooted subscription deliver_token. There is no paradox to reconcile: the chain is B-rooted and the installer is in it as the leaf granter — §3.1a's "writer anywhere in the chain" is satisfied without rooting at the writer.

This ensures every continuation's dispatch authority is explicit, inspectable, scoped at creation time, and authorized — the writer must legitimately hold the cap they embed. There is no implicit fallback to handler authority (see ENTITY-CORE-PROTOCOL.md §6.8, no silent escalation).

4.3 Capability Lifecycle

The dispatch capability token is created by the caller when setting up the continuation. The caller MUST ensure the capability entity is in the local content store before or at the time the continuation is created. For cross-peer creation via EXECUTE, the capability entity MUST be included in the EXECUTE envelope's included map per the general rule in ENTITY-CORE-PROTOCOL.md §3.1 / §3.2 (all entities referenced by hash from the EXECUTE's data fields must be present in included) — the receiving peer ingests it during normal envelope processing. For local creation, the caller MUST content_store.put the capability entity explicitly. The token's TTL bounds the continuation's effective dispatch window — if the token expires, the continuation cannot dispatch and advancement fails.

For cross-peer dispatch to a remote target, envelope inclusion is not limited to the leaf dispatch_capability: the full authority chain up to the root the target peer recognizes MUST travel in the dispatched EXECUTE's included map — see §4.2 ("Chain transport"). The general ENTITY-CORE-PROTOCOL.md §3.1 / §3.2 rule places only the leaf cap (it alone is referenced from the EXECUTE data fields); the transitive parent/granter chain is referenced from within the cap entities and MUST be bundled explicitly by the dispatching peer.

Identity entities are part of that bundle [MUST] (normative; v1.22). The bundle MUST carry a system/peer identity entity for every granter and every grantee appearing in the transported chain — not only for those that happen to also be a granter, and not on a best-effort basis. A bundler that cannot resolve one of them locally MUST fail at bundle time with chain_unreachable (§8.1) rather than dispatch an incomplete bundle.

This completes §4.2's "Chain transport" rather than adding to it, and it closes a MUST-against-best-effort mismatch that was live in the corpus. ENTITY-CORE-PROTOCOL.md §5.5's step-2a requires every capability in the chain to have a grantee resolving to a system/peer present in included, failing 401 UnresolvableGrantee otherwise. §4.2's transport rule enumerated cap links and per-link signatures and stopped there, so implementations collected identities best-effort and silently omitted what they could not resolve. A MUST on the verifier paired with best-effort on the bundler is an interop bug by construction — the same shape as a MUST naming an undefined referent, one layer out. It is invisible for a self-rooted capability, where granter and grantee collapse onto peers both sides already hold, and it springs apart at exactly the §4.2 case-3 chain, where a third-party installer is neither the EXECUTE author nor the peer serving the request: whether B can authorize at all then depends on whether A's store happened to hold the installer's identity when A built the bundle. That is authorization varying with the sender's cache state.

Fail at bundle time, not at the verifier, because the sender is the only party that can fix it and a 401 at the receiver is indistinguishable from a genuine authorization failure — it misroutes the diagnosis to the peer that is behaving correctly. Over-inclusion stays free for the same reason §4.2 gives: content-addressing dedups anything the receiver already holds.


5. Suspended Continuation Lifecycle

5.1 Creation

Suspended continuations are created by:

5.2 Visibility

Suspended continuations are tree entities at system/continuation/suspended/*. They are:

5.3 Resume

The resume operation on the continuation handler (§3.6) reconstructs the EXECUTE from suspended state and dispatches with fresh bounds. The resolution field in the resume request allows the operator to provide additional context (e.g., conflict resolution choice for a merge conflict).

5.4 Abandon

The abandon operation deletes the suspended entity after verifying its type is system/continuation/suspended. The operation is lost. This is equivalent to killing a stopped process.

5.5 Automatic Cleanup

Implementations SHOULD periodically clean up suspended continuations that exceed a configurable age limit. Cleanup policy is implementation-defined.


6. Security Considerations

6.1 Privilege Escalation via Continuation

A caller with tree write access to a continuation path could create continuations targeting sensitive handlers. Mitigation: the dispatch_capability field is required and provides explicit, scoped authorization for the dispatch target. A continuation without dispatch_capability cannot dispatch (advance fails with missing_dispatch_capability). The continuation handler's own grant is restricted to managing continuation entities — it does not authorize dispatch to target handlers.

The escalation boundary is the install-time in-chain dispatch_capability check (§3.1a), not an advance-time caller check — see §3.1b. A reactive trigger cannot exceed the owner's pre-authorized dispatch_capability.

6.2 Resource Exhaustion via Chains

A continuation chain with dynamic construction could consume unbounded resources. The dispatch layer tracks chain_depth — carried in system/bounds (ENTITY-CORE-PROTOCOL §3.11) and inherited across peer boundaries — and suspends with reason: bounds_exceeded when the cohort-uniform maximum is exceeded (§3.9). The chain_id field correlates all operations in a chain. chain_depth is a monotonically increasing counter: it is inherited and +1'd per causal advancement, and the §5.9 TTL/budget decrement MUST leave it untouched — on a causal hop it only increments (it never decrements, and resets to 0 only on a fresh root, §3.9).

6.3 Orphaned Continuations

A standing continuation (remaining_executions: null) at a path with no active source delivering to it is an orphan — it will never advance. Orphaned continuations are not harmful but waste tree space. Operators SHOULD monitor for orphaned continuations using type queries on system/continuation entities.

6.5 Privacy + cross-peer observability

Per GUIDE-INSPECTABILITY.md v1.2 §9 #4: system/continuation/* entities (forward, join, suspended) and system/runtime/chain-errors/* markers are capability-controlled — bodies carry dispatch capability hash references, captured params (which may transitively reference sensitive entities), target + operation call shapes, and chain causality. The install-result operational echo is public.

Per §9 #7: local-namespace for both system/continuation/* and system/runtime/chain-errors/*. These path families are per-peer operational state and MUST NOT propagate via subscriptions or revision sync. Cross-peer observation requires explicit operator-grant on system/inspect/* (per GUIDE-INSPECTABILITY.md §8). Subscription handlers SHOULD refuse subscriptions on these prefixes unless the caller's scope explicitly enumerates a narrower path with operator-class authority.

This subsection is the canonical declaration that resolves the cross-peer propagation question for system/runtime/chain-errors/* markers raised in the inspectability cycle's closing memo. Other extensions inheriting the chain-error marker convention from §3.10 inherit this local-namespace declaration; see EXTENSION-SUBSCRIPTION.md §7.4, EXTENSION-INBOX.md §11, EXTENSION-REVISION.md §12 for the parallel extension-side declarations.

Per the privacy-and-cross-peer-observability audit §2.5.

6.4 Suspended Continuation Accumulation

Without cleanup, suspended continuations accumulate at system/continuation/suspended/*. Implementations SHOULD enforce a maximum count or age limit and clean up expired suspended entities.


7. Constants

7.1 Status Codes

CodeMeaning
409Conflict — hash_mismatch (join slot CAS) or slot_already_filled (duplicate slot delivery)
429Bounds exceeded (with optional suspended_at field)

7.2 Standard Operations

OperationHandlerDescription
advancesystem/continuationAdvance a continuation with a result
resumesystem/continuationResume a suspended continuation
abandonsystem/continuationDelete a suspended continuation

7.3 Continuation Types

TypePurposeCreated by
system/continuationForward dispatch on result arrivalCaller
system/continuation/joinFan-in accumulationCaller
system/continuation/suspendedSaved execution stateSystem (dispatch layer, continuation handler, or handler)
system/continuation/transformStructural navigation spec + dynamic EXECUTE field extraction + bounded field opsEmbedded in continuation
system/continuation/transform-opOne bounded field operation within a transform's transform_opsEmbedded in transform
system/continuation/advance-requestInput to advance operationCaller (inbox handler, direct user)
system/continuation/resume-requestInput to resume operationOperator
system/continuation/abandon-requestInput to abandon operationOperator

8. Conformance

Conformance timing — read before §8.1 (the cross-peer G2 MUST is gated, not a flag-day). v1.9 is not a core-protocol change: it adds no new core primitive. Every mechanism it uses — capability authority chains and check_creator_authority (ENTITY-CORE-PROTOCOL.md §5.5), VerifyChain (ENTITY-CORE-PROTOCOL.md §5.2), the envelope included map (ENTITY-CORE-PROTOCOL.md §3.1/§3.2), content-addressed dedup — is already normative and already shipped; v1.9 only specifies how a continuation composes them, mirroring the implemented EXTENSION-SUBSCRIPTION.md §1.2/§1.3 pattern.

The cross-peer / remote-target §8.1 requirement (§4.2 case 3 — B-rooted re-attenuated dispatch_capability, full chain bundled) is the required shape when an implementation performs cross-peer continuation dispatch. It is gated and sequenced, not a flag-day:

A conformance suite SHOULD assert the install-time and additive MUSTs (§8.1) now; it SHOULD not encode the cross-peer §8.1 G2 MUST as a present-tense check — doing so mis-flags every implementation that is correctly following the sequence above. (Arch resolution; see the continuation cross-peer-and-transform-ops proposal §8 + the V-1 sequencing.)

8.1 MUST Implement

8.2 SHOULD Implement

8.3 MAY Implement

8.4 Implementation-Defined


Appendix A — Continuation engine error codes (normative; v1.19)

The continuation engine emits system/protocol/error shapes (V7 §3.3) when chain-machinery-internal failures occur. Per V7 §3.3 line 742's per-component scoping, this appendix is the canonical home for engine-emitted code values — parallel to EXTENSION-TREE.md Appendix A for tree-handler codes.

These codes appear as:

A.1 Reserved codes

codestatusTrigger
on_error_dispatch_failed500An on_error deliver-target dispatch failed (transient or permanent). Per v1.9 §3.4 A.1.
merge_value_not_map400result_merge: true met a non-map post-transform value at chain assembly. Per v1.16 §3.4.
transform_failed400A continuation-transform vocabulary evaluation produced an error (e.g., extract against a non-map, transform_ops evaluation hit an unknown op). Per §2.2 transform contract.
chain_construction_invalid400The continuation entity at install time was malformed (missing required fields, type discrimination failed, dispatch_capability §3.1a in-chain check failed). Per §3.2.

Additional codes MAY be reserved by future spec amendments to this appendix. Impls MAY emit non-reserved codes for impl-specific engine failures; consumers MUST treat unknown codes as informational (no reactive behavior).

A.2 Path-safety

All codes in §A.1 conform to V7 §1.4 path-segment rules and are safe for use as {reason} path segments per §3.10.5. New codes reserved by future amendments MUST also be path-safe.

A.3 Cross-reference