A refused round settles — it is never re-claimed

Measured 2026-10-05 on memex.systemorph.com, filed by the thread supervisor as Plugins #2907 and 26 duplicates (every one an owner-* thread under Hosting/Triage/pull-request/<item>/_Thread/).

What it looked like

The supervisor's reports read "StartingExecution on cell (none) with no cross-hub write to the node … and no terminal state", "the thread carries no error text — nothing ran far enough to write one", and two relaunches returning to the same state. The node said nothing more.

The node was not quiet. Two reads of owner-systemorph-meshweaver-plugins-2879 4.8 s apart moved its version by 1,208 — about 250 writes a second — while status read StartingExecution both times. A Logs action on the control instance (Ops/Actions/logs-memex-20261005-thread-claim-loop) named the loop in one window:

AccessControlPipeline: Access denied: user 'pr-steward' lacks Thread permission on
  'Hosting/Triage/pull-request/…/owner-systemorph-meshweaver-plugins-2879'
  (triggered by message=CreateNodeRequest …)
[ThreadSubmission] Response cell creation failed for 7df9521a on …
[ExecRoundWatcher] DispatchAfterClaim failed for … — rolling Status back to Idle

The supervisor could not see it: its clock counts only CROSS-hub writes (LastModified), and a claim, a rollback and a re-claim are all own-stream writes (see the settle loop, defect D).

Two defects, composed

1 — a creator LABEL became the round's identity

PullRequestIntake opened each owning thread with StartPreparedThread(…, createdBy: "pr-steward") under ImpersonateAsSystem. createdBy is not decoration: it is written to MeshThread.CreatedBy, and DispatchRound resolves the round's principal from it whenever the live context is not a real user and the first message carries no submitter rider — which is always the case for a thread started as system (system is never captured as a submitter). So every round of every owning thread posted its response cell's CreateNodeRequest as pr-steward, an identity that holds no Thread permission anywhere. Not one round of an owning thread ever ran; the only cells they hold are the supervisor's Error cells, written as system.

Every other control-plane starter (TriageIntake, PrBabysitter, PlatformBuildInboxWatcher, the steward's own review threads) passes no createdBy, so MeshThread.CreatedBy stays empty and the round falls back to the NODE's creator — system-security, the identity the start already runs under. That is what the owning thread does now. The author is still shown: authorName says "PR steward".

Rule: a thread's CreatedBy is the identity its rounds run as. Never put a label there. A label belongs in authorName.

2 — the refusal took the transient arm

The response cell is the round's first write under its own identity. When the delivery gate refused it (DeliveryFailure with ErrorType.Unauthorized), DispatchRound invoked onFailure, which rolls the claim back to Idle — the arm built for faults of the moment. The input was still pending, so the submission watcher re-claimed it on the very next emission, the same identity asked the same pipeline, and the thread oscillated Idle ⇄ StartingExecution for as long as the hub lived.

A refusal is a verdict about the identity, not a fault of the moment: no retry can change it. So DispatchRound now classifies it (ThreadSubmissionServer.IsRefusal — Unauthorized or Forbidden; an UNDETERMINED check is Unavailable and stays transient; a create the handler refused in its response with RejectionReason: ValidationFailed is the same verdict) and settles the round instead:

  1. the planned input's user cells and ONE Error response cell at the round's deterministic id, written as system, explicitly — the round's own identity is precisely the one that may not write here, and recording that a round could not run is infrastructure, exactly as the supervisor's settle writes its Error cell;
  2. the same thread write as a round found already over (SettleAsAnsweredBy): the input ingested as answered, execution reset, the verdict — naming the refused identity — as Summary.

The thread then rests Idle with nothing pending, and the next message opens a new round. If even system cannot write the Error cell the store itself is failing, which IS transient, and the old rollback stands.

Pinned by

RefusedRoundSettlesTest (src/MeshWeaver.AI.Test), on a mesh WITH row-level security and WITHOUT the blanket public-admin grant, and with the fixture's process-wide DevLogin host identity cleared — it is what AccessService.CircuitContext falls back to, so with it present the round runs as an all-granted user and the refusal cannot occur:

Not covered