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.

Filed only when the user asks

A chat assistant acting for a user files feedback only when that user asked for it (MeshWeaver.Plugins#4426). Before this rule, an assistant filed a draft into a learner's space on its own initiative. The rule is enforced in the assistant's own create tool (MeshPlugin.Create, with the decision in FeedbackFilingConsent), not left to the agent's instructions. A Feedback/Feedback create from a round that acts for a person proceeds only in two cases:

Consent is read only from evidence the chat engine wrote. The model acts with the user's own rights, so those rights do not separate the two. Four rules keep the evidence out of the model's reach:

Otherwise the tool files nothing. It answers {success:false, held:true, heldFor, error}, and the error tells the assistant to ask the user one short question and stop.

An agent filing its own finding is not acting for a person, and it stays allowed (AGENTS.md, "file it and move on"). That covers a round running as the platform, a hub, a service or a virtual identity, and an agent session on the MCP surface (McpMeshPlugin), which does not pass through this tool. Tests: src/MeshWeaver.AI.Test/FeedbackFilingConsentTest.cs.

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 redirect leads its reader to a page that reader can open (MeshWeaver.Plugins#4506). It stands in the SUBMITTER's partition, so the one who follows it is the submitter. When #4506 was written, the Inbox copy was unreadable to them: pointing the redirect there made every shared link to one's own submission answer "access denied". That denial is historical. The submitter can now open the Inbox copy directly (next paragraph). The status node remains the preferred target because it shows where the feedback stands. So its target is the submitter's own status node, {submittedBy}/Feedback/<id> (below, "Where your feedback stands"), whenever the platform writes one: an authenticated submitter (FeedbackLifecycleRules.Recipient) and a record that is in the lifecycle or enters it at hand-over (New). A system filing and a historical record get no status node, so their redirect still names the Inbox address, the only page there is (FeedbackHandover.RedirectTargetFor). A redirect written before this change keeps its old target.

🚨 And the submission itself is readable by its submitter and by every global admin (policy global-admin-reads-feedback-submissions, core Doc/Architecture/AccessControl → "The two type-scoped exceptions"). A redirect that still names the Inbox address, whether it was written earlier, filed by an agent, or left when no status node exists, used to answer "lacks Read permission on 'Feedback/_Submissions/…'" to the person who filed the report, and get answered Not found. The Feedback/Feedback type declares FeedbackSubmissionAccessRule (MeshWeaver.Plugins MeshWeaver.AI; the Feedback package requires AI). The ordinary permission fold runs first. Only when it denies is Read granted on a record under Feedback/_Submissions/, and only to two kinds of user. The first is the real user that the stored record's submittedBy names. The second is a global admin (hub.IsGlobalAdmin(userId)). It grants Read only. Rewriting, re-filing and deleting stay on the fold. A draft in a person's home is never widened, and no other node type is affected. Pinned by FeedbackSubmissionAccessTest.

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

Keeping feedback on this instance

The hand-over sends the user's message and what the agent gathered about where they were — the main node's path, title and gist, and any extra context. On an instance whose node content is sensitive (health records, client data), a gist is a description of that content, and it would leave the instance for the control instance's triage, whose drafts can become public GitHub issues. Two deployment keys decide what leaves; both default to today's behaviour:

Key Default Effect
Feedback:HandOver:Enabled true false ⇒ nothing leaves for the control inbox — no feedback event, no weekly wrong-answer digest — on either route, whatever URL and secret are configured. Every submission stays in this instance's own Inbox; one Information line per technical submission names the key. The backlog wake and the age alarm treat the instance as having no route.
Feedback:HandOver:MessageOnly false true ⇒ the event still goes, but mainNodePath, mainNodeTitle, mainNodeGist and extraContext are sent empty (the intake parses a fixed shape, so they stay present). The user's own message, the category, the submitter and the link back are sent. The record on this instance keeps every field.

🚨 Before ANY hand-over leaves, the viewed node's [SensitiveData] values are scrubbed — no key, always on. The agent writes the gist by reading the node at mainNodePath, so a gist can quote that node's marked members (a diagnosis, a member number), and the attribute cannot see into prose. So FeedbackHandover.ScrubbedForHandOver reads that node's content (as System; the values are read to be removed, never shown), collects its marked values through core's one redaction point (SensitiveDataRedaction.SensitiveValues), and replaces each of them — case-insensitively, wherever it appears — in the agent-written context (title, gist, side panel, extra context) with ‹redacted›. It fails CLOSED and sends no node context at all, path included, as with MessageOnly, whenever what the gist was written from cannot be established: the node changed after the feedback record was created (the gist may quote a value it no longer holds), the node is gone or empty now, its content cannot be typed in the process, the snapshot is incomplete, or the read fails, completes empty or outlives 15 s. A marked value used in the node's path (a patient number as its id) drops the path rather than sending it or a broken redacted link. The record on this instance keeps every field.

What the user typed is theirs to send: neither the scrub nor MessageOnly rewrites the message (the control instance also correlates its replies with it). An instance that must not send even that sets Enabled=false. Pinned by FeedbackTests.HandOver_TurnedOff_SendsNothingOnAnyRoute, FeedbackTests.HandOver_MessageOnly_CarriesNoNodeContext and FeedbackTests.HandOver_ScrubsTheViewedNodesSensitiveValues_AndFailsClosed; the platform-wide rule is core's Sensitive Content in Logs (get Doc/Architecture/SensitiveContentInLogs) and, for red-log tickets, Sensitive content stays on the instance.

Where your feedback stands

Until an issue is filed, Submitted used to be the last thing a submitter ever saw — for twenty days in Plugins#4345, while the control instance held the intake and nobody noticed. A handed-over submission now carries a lifecycle (FeedbackContent.Lifecycle, an OPEN vocabulary of string constants, FeedbackLifecycle):

State Means Who moves it there
📤 Submitted submitted, and being (or already) handed to triage the submission's own hub, in the same activation that hands it over — BEFORE the hand-over leaves
📥 Received the control instance recorded the triage item the control instance (feedback-received)
⏸️ Held the item is recorded and HOLDS its triage thread (Dispatch not On, or the triage agent switched off) the control instance (feedback-held)
🔎 Triaged its triage thread started, or an issue was filed from it the control instance (feedback-triaged), or the submission's hub from the issue it shows
🛠️ Implementing someone works on the issue in the vocabulary; nothing states it yet (an open decision of Plugins#4348)
✅ Implemented the issue was closed as completed the submission's hub, from the issue it shows

Forward only, decided inside the owner's update. Every write goes through GetMeshNodeStream(path).Update(…) and re-decides there (FeedbackLifecycleRules.Advance): a state is never followed by an earlier one (a late Received never undoes Held), a word this portal does not know is left as it is and never moved, and a submission handed over before the lifecycle existed (no lifecycle) is never backfilled. A re-opened issue does not move Implemented back.

The control instance's statements. Received, Held and Triaged ride the SAME signed control-lane statement as the issue (Feedback submitted on another portal, above), with their own kind — feedback-received, feedback-held, feedback-triaged — and no issue. The portal verifies exactly what it verifies for the issue statement: the inner signature with ControlLane:Key, its own Hosting:Deployment, a path Feedback/_Submissions/<id>, a node it handed over, and the message digest; anything else is refused and named. A feedback submitted ON the control instance is moved forward in place.

A reply can overtake the hand-over stamp — it is kept, never refused. The hand-over writes Submitted and waits until STORAGE holds it before it sends; it writes handedOverAt only after the control instance acknowledged. So the control instance's Received can arrive before that stamp. A statement that passes every check except the missing stamp, on a submission that entered the lifecycle (FeedbackIssueStatus.AwaitsHandOverStamp), waits for storage to show the stamp and is then applied; if the stamp does not show within the step budget it is a hand-over-pending FAILURE — kept in the inbox and tried again — never a not-handed-over refusal that would delete the only statement of that state. A submission that never entered the lifecycle is still refused as not-handed-over.

The submitter sees it — on a node they can read. Feedback/_Submissions/<id> is not readable by its submitter, so the submission's own hub mirrors the lifecycle onto {submittedBy}/Feedback/<id> in the submitter's partition (beside their drafts and the redirect a moved submission leaves) — 🚨 only when submittedBy IS the submission's authenticated creator (MeshNode.CreatedBy, stamped by core from the delivery's principal and carried verbatim by the move into the Inbox; FeedbackLifecycleRules.Recipient). submittedBy is content anybody can type: a submission that names somebody else, or that the platform created, gets no status node and no notice, and a Warning names both identities — status fields only (mirrorOf, lifecycle, lifecycleChangedAt), never the message, never backwards. That node is a status node, not a submission: it is never moved, handed over or named a dead letter. Its view is a 🧭 Status row; the same row sits on every card (preview, Detail, Inbox) once the submission is in the lifecycle, in the viewer's language.

One notice. On the FIRST move after Submitted (whichever of Received, Held or Triaged comes first) the submitter's bell gets ONE notification through the platform's addressed notifications (NotificationService.CreateLocalizableNotification, keyed per submission, so a re-delivery refreshes the same row), pointing at their status node; submitterNotifiedAt records it. Later moves do not notify.

The age alarm. On a portal whose control inbox is configured, the anchored Inbox looks every Feedback:Lifecycle:StallCheckInterval (default 1 h) for a submission that has stayed Submitted or Held longer than Feedback:Lifecycle:StallAfter (default 1 day). Submitted is judged only where the control instance's Received can reach this portal (the control instance itself, or a portal that declared Feedback/Inbox as a lane event target). It files ONE submitted bug report into triage, Feedback/_Submissions/feedback-stall-<hash>, keyed on the oldest stalled submission and its state — a later pass of the same stall finds it by listing and files nothing — and logs a Warning beginning [Feedback] STALLED. Plugins#4345 would have been reported on its second day. A pass that could judge nothing (an incomplete or empty listing, a failure) logs a Warning naming why, every time; the next interval runs regardless.

Tests: Feedback/Feedback/Test/FeedbackLifecycleTests.cs (the rules, run by the Tests area) and src/MeshWeaver.Fleet.Control.Test/AFeedbacksLifecycleReachesItsSubmitterTest (two meshes: Held then Triaged reach the submission over the signed lane, a late Received leaves it Held, the status node follows, exactly one notice; a forged, a misdirected and a legacy statement are refused or unchanged; the alarm fires past the bound, once, and not within it; a reply that overtakes the hand-over stamp is kept and applied once the stamp is stored; a submission naming another person writes nothing into that person's partition or bell).

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