Feedback

A drop-in feedback capability for any MeshWeaver deployment. A user types /feedback <message> in any thread; the flow captures the message plus the context that makes it actionable, files it as a draft, and shows the user a polished preview with a Submit button — so they see exactly what will be sent before it goes. Only when they click Submit does it reach the shared Feedback space, where Systemorph reviews it.

What gets captured

Every submission is a Feedback/Feedback node whose FeedbackContent records:

Field Source Notes
message the user's words, verbatim the record itself
submittedBy / submittedByName # Current User context who
timestamp server, stamped on submit real ISO-8601 UTC; a client can't backdate
mainNodePath / mainNodeTitle / mainNodeGist # Current Application Context where they were (the main panel) + a gist
sidePanel the agent, if reachable related area open beside the main panel
extraContext the agent, opportunistically role, repro detail, related paths, …
category inferred, only if obvious bug | idea | praise | question — and answer, filed by /wrong-answer for a qualitative miss
status server Draft on capture (invisible to reviewers); flips to New when the user hits Submit; a reviewer then moves it to Triaged / Resolved / WontFix
issueUrl the control instance the GitHub issue the Systemorph triage agent filed for it, written back when the feedback was submitted here and turned out to be actionable; empty otherwise
issue the control instance the same issue, typed — repository, number, url — and its state: state (open | closed), stateReason (completed | not_planned | duplicate | reopened), stateChangedAt; kept current from the issue's close and re-open (see The issue and its state); on a portal of the same estate, delivered over the control lane (Feedback submitted on another portal)

Every context field is optional — the agent fills what it can reach and leaves the rest empty (shown as "not captured" in the review view). A submission never fails because a field couldn't be filled.

The pieces

After Submit: technical feedback is handed to the Systemorph triage agent

Maintainer, 2026-09-12: "the feedback skill must do exactly this ⇒ if it is a technical issue / bug report / feature request ⇒ hand over to systemorph-com triage agent."

When the user clicks Submit — and never before — the draft reads New. Its own hub moves the record from {User ID}/Feedback/{id} to a stable address under Feedback/_Submissions, then wakes that new address. The destination's hub hands over technical feedback (FeedbackHandover.HandOverOnActivation, Source/FeedbackHandover.cs) and stamps handedOverAt there. Publish merely flips Draft → New; it does not hand over inline. This is the ONLY hand-over path, and it is the RequestedX + owning-hub-watcher shape for a reason: a submission created New by anyone — the /feedback skill, an agent through MCP, the platform's thread supervisor filing an exhausted thread (Doc/Architecture/ThreadSupervision) — takes the same move and hand-over route when its hub activates, not only when a person pressed a button in this process. Creation alone activates nothing (the create is handled by the owner namespace's hub). So nobody has to read the node back: the Inbox wakes it (next section). The move is issued under System by the mesh's durable node-operation hub: the source hub cannot receive its own move response after its address is deleted. The destination is woken after the move because copying a node does not activate its hub. The old address becomes a Redirect node for navigation; reads and writes still address the new record literally.

🚨 The move waits until storage holds the submitted state (MeshWeaver#5670). The watcher sees Submit's Draft → New as the owning hub's IN-MEMORY commit. The hub stores that commit afterwards (the post-commit flush, with the write-behind sampler behind it), and a move copies its source FROM STORAGE. So a move issued the moment the watcher saw New copied the stored Draft with the pre-edit message. The destination's watcher skips a Draft, so the report never reached triage and nothing was logged. On memex.systemorph.com on 2026-09-28 every agent-filed report reached triage within 2 s of its move, while the portal Submit moved at 14:34:21Z produced nothing. An agent-filed report never raced: it is created New, so storage already holds New when its hub activates.

The watcher now relocates a version only once storage holds it. It seeds from the state the hub loaded at activation and raises that on every post-commit event IMeshInvalidationFeed publishes for the node, the ordering core's NodeTypeRebindWatcher relies on for the same hazard. A version still not stored after FeedbackHandover.DurableBudget is logged as an Error and not moved, because moving it would carry the wrong state. The regression test is src/MeshWeaver.Hosting.Monolith.Test/FeedbackSubmitRelocationTest. It compiles these sources, holds the submission's storage writes open, drives the real Publish against a live source hub, and asserts that nothing relocates the report before its submitted state is stored and that the Inbox copy is New with the edited message.

🚨 When the Inbox address is already taken, the move is not retried (MeshWeaver#5857). The Inbox address is derived from the source path. So if the old address is written again after its move, for example by an update aimed at the redirect that re-creates the record, the next hand-over finds its target occupied. Core resumes a half-landed move only when the occupant is the relocated source itself: the same creation stamps, and a source unchanged since the copy (MeshWeaver#6319). A re-created record has new stamps, so core refuses with TargetAlreadyExists. Before this fix, every activation of the old address retried that refused move: 1,191 Node already exists errors on one portal between 2026-09-28 and 2026-10-09.

The watcher now reads the occupant. If it holds the same report from the same submitter (FeedbackHandover.InboxAlreadyHolds), the hand-over already happened. The second copy is replaced by the redirect a completed move leaves, and the Inbox copy keeps its triage state. The regression test is src/MeshWeaver.Hosting.Monolith.Test/FeedbackOccupiedInboxTest.

Re-observing a relocated submission converges (MeshWeaver#6977). Three gaps remained after that fix:

handedOverAt on the submission is the proof it went; a technical New submission without it on a portal whose control inbox is configured is one whose hub never activated.

The Inbox wakes every submission still waiting (Plugins#2285)

Creating a node does not activate its hub, so a submission an agent filed with MCP create and nobody read back used to wait in the filer's space for ever: 124 such records sat in one user's Feedback folder on the control instance. The fix is a change-feed watcher, not a filing rule.

The regression test is src/MeshWeaver.Hosting.Monolith.Test/FeedbackBacklogWakeTest: a backlog of unread records gets exactly one hand-over each once the Inbox comes up, a record created while it watches gets exactly one, and a restarted watcher adds none. src/MeshWeaver.AI.Test/PackageAnchorTest proves the anchor activates a declared node that nothing else reads, and refuses a declared path outside the package.

A submission nothing can read is a dead letter, and it says so (Plugins#3042)

A record created with a content shape that does not bind to FeedbackContent fails both the owning hub's hand-over check and the watcher's IsWaiting. Examples are {"$type":"Feedback","status":…,"description":…}, where no type named Feedback exists, or the right $type with the text under description, which reads as New with an empty message. Measured on the control instance: three coordinator filings waited 1.5 h that way, and nothing logged or marked anything. FeedbackHandover.UnreadableSubmission now names them, and only them (not Drafts, praise, stamped records or the content-less Inbox node):

That Warning is the ONLY report, and it is never an Error (MeshWeaver#6232). The hub reads its record through FeedbackHandover.ReadSubmission, which converts the content WITHOUT a logger and then asks UnreadableSubmission. With a logger, ContentAs logged a typed content of another class — MarkdownContent on a note that was re-typed to Feedback/Feedback — at Error on every activation, before the classifier ever ran, and an error logged before its classifier can never go quiet. A typed foreign content is named by its CLR type when it serializes without a $type.

Nothing reinterprets such a record. There is no description → message mapping, because the write that stored the shape is the defect. Core refuses that write now (MeshWeaver ContentSchemaOnWrite, "A $type that names nothing"). Correct the content to the FeedbackContent shape and the record is handed over on its next activation.

A refused Inbox move is recorded on the record, never retried (MeshWeaver#6952)

A record can also READ fine and still be refused by the Inbox: measured on the control instance, legacy records stored as {"$type":"Feedback","title":…,"message":…} bind as a New FeedbackContent, so the watcher moved them — and core's content guard refused the move's copy leg, which judged the stored shape as new content. The watcher cleared its in-flight flag and issued the same move on every activation: hundreds of identical refusals at Error, and nothing on the records saying why.

Two changes close it:

The package requires mesh 3.0.0-ci.9125: that is the first sealed main image with the durable node-operation hub API needed by the move. There is no stable 3.0.0 image with that API yet; raise the package floor to the stable release once it ships.

What is handed over is unchanged:

The feedback What happens
category: bug or idea handed over, always
category: question, or no category handed over when the message reads as technical — it names a defect or a feature (FeedbackHandover.LooksTechnical)
category: praise, or a non-technical message stays in this instance's Inbox; nothing is sent
category: answer (a /wrong-answer report) stays in this instance's Inbox even when the message reads as technical — its addressee is the agent's owner, a space owner or a content owner here (Wrong answers); the one kind that is a platform defect is filed as bug by the skill and takes the first row

"Handed over" means ONE signed feedback event on the control instance's pooled inbox (https://control.example.com/api/hooks/Hosting/PlatformBuilds) — the same URL and HMAC every fleet repository's CI posts to. There it becomes a Hosting/TriageItem, a thread with the triage agent, and — for what is actionable — a GitHub issue, filed via systemorph-com in the repository that owns what the feedback names — the Space's GitSync repository, else the code it names, else the most likely owner (never the fleet's inbox). A finding labelled security is only ever filed in a private Systemorph repository; otherwise a person files it. The issue is written back onto the Feedback node (issueUrl, and issue with its state) — in place when the feedback was submitted on the control instance itself, and over the signed control lane when it came from another portal of the same estate (Feedback submitted on another portal, below). The mechanism end to end is Hosting's Triage page.

The issue and its state

Once an issue is filed, the card (the submitter's preview, Detail and every Inbox row) shows a 🎫 Issue row: a link reading owner/name#number and a status badge — 🟢 Open, ✅ Closed – completed, ⛔ Closed – not planned, 🔁 Closed – duplicate, ⚫ Closed for a reason GitHub did not give — with the instant it last changed (its meaning in the tooltip). The words follow the VIEWER's language (FeedbackIssueTexts, English and German, resolved from AccessContext.Locale); a state word outside the vocabulary reads State not known yet, never a guess. A record from before the issue was typed shows its bare issueUrl with the state unknown. The row is data-bound: Detail renders from the node's own stream and the Inbox from its live query, so a close or a re-open shows without a reload.

The state is written by the control instance, never by the submitter or a reviewer: the filing records it open, the organisation webhook's issue close / re-open moves it, and the triage inbox's thread-closure pass re-reads, through the GitHub App, a filed issue not known closed at most hourly — the backstop for a delivery that never arrived. A late delivery never moves the state backwards. The rules are Hosting's Triage → The state of a filed issue.

Feedback submitted on another portal

A portal of the same estate as the control instance learns the issue and its state too. The control instance sends a signed feedback-issue-status event over the control lane (core Doc/Architecture/ControlLane → Forwarded events) into this portal's Feedback/Inbox, and FeedbackIssueStatus, armed on the anchored Inbox hub, writes it onto the ONE Feedback node it names.

🚨 This is a deliberate, narrow exception to the lane's "trigger only" rule. A forwarded event is otherwise only a trigger, re-read by its consumer; a portal has no GitHub credential to re-read an issue, so it takes the control instance's statement of the state — and only for this:

Anything else is refused, named in a Warning ([Feedback] issue status … REFUSED — <why>), and nothing is written, and the statement is consumed. A statement this portal could not apply just now (a read or the write failed or timed out) is kept instead, tried again with backoff, and read again on the Inbox's next activation — the lane already acknowledged it, so the control instance will not send it again. An undated statement of an issue already shown with a dated state never clears that state.

What a portal needs (the operator enables it per deployment — Hosting's Triage page → Feedback submitted on another portal → Enabling it for a deployment):

Key Value
ControlLane:Key this deployment's own lane key (mounted from its controlLaneKeySecret)
Hosting:Deployment this deployment's id
ControlLane:EventTargets:<n> Feedback/Inbox

🚨 A portal NOT under our control instance gets nothing — no deployment record of the control instance names its host, and there is no other channel into its mesh. Neither does a portal whose lane is not enabled. Its feedback is still handed over and may become an issue; the triage item on the control instance carries the issue and its state, and the portal's card shows no 🎫 row.

Configuration — two keys, both required, defined once per portal:

Key Value
Hosting:ControlInbox:Url https://control.example.com/api/hooks/Hosting/PlatformBuilds
Hosting:ControlInbox:Secret the control inbox's HMAC secret — the same value as the control instance's Hosting:PlatformWebhookSecret (Key Vault), mapped e.g. as memexcloud-Hosting-ControlInbox-Secret

With either key absent the plugin logs one warning per technical submission naming both keys and keeps today's behaviour — the submission stays in the Inbox. The control instance itself needs neither: it lists Hosting/PlatformBuilds as a webhook target and holds the watcher's secret, so it delivers the same signed event into its own inbox. A hand-over that fails for any reason (network, a refused signature) is logged and never fails the submission.

Where submissions live

Drafts created by /feedback live under {User ID}/Feedback/{id} until Submit. Every submitted record, including one created directly as New by an agent, moves to Feedback/_Submissions/{stable-id} — an underscore container (kept out of catalogs). The stable id is derived from the full original path, so identical ids from different users cannot collide and a retry cannot choose a second destination. The Feedback space's anchored Inbox area lists them for reviewers. The move retains the record and its satellites; the old user-space address holds only a navigation redirect. Feedback is admin-reviewed: seed the space so only platform admins read it. Eval cases live beside them in Feedback/_Evals/{agentId}/{case}, with the threads a run started under each case, and the weekly digests in Feedback/_Digests/{week}. All three containers are protectedSegments of the package.

The Inbox's watches re-open after a fault

The Inbox's two standing watches — the issue-status inbox (FeedbackIssueStatus.Watch) and the backlog listing (FeedbackBacklog.Wake) — go through their own Reopening (core's StandingWatchRecovery.ReopenOnTransientFault). A transient fault (a QueryProviderStalledException from the query fan-in, a database connect/IO fault) is logged at Warning naming the source, and the source re-opens one cadence later (one minute), as often as it takes — paced, never a tight retry, and nothing re-opens while the source is healthy. Before this, each was subscribed unguarded, so ONE such fault ended the watch until the hub was re-activated, with a single log line as the only trace (core MeshWeaver#6912's shape). Pinned by FeedbackStandingWatchTests.

Tests