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
/feedbackskill (Feedback/Skill/feedback) — the entry point. Instructs the thread agent to capture the message + context, extract more if it can, file a draft, and show the user the preview to review and submit. Prefers the Feedback Agent when present./wrong-answerskill (Feedback/Skill/wrong-answer) — the entry point for a qualitative miss: the chat made something up, said "I don't know" about something in the mesh, answered from the wrong source, or changed something unasked. Reads the offendingThreadMessage(agent, model, tool calls), checks whether the node the user meant is readable, classifies the failure and files the sameFeedback/Feedbackdraft withcategory: answer— which is never handed over per report: once submitted, the card offers to turn it into an eval case. Runs in-thread (never hands off — the evidence is in the thread). Design, kinds and addressees: Wrong answers.EvalCasetype (Feedback/EvalCase, compiled live fromSource/) — a/wrong-answerreport lifted into a repeatable test of the agent (Plugins#2288): the prompt, the node a correct answer must read, the value it must not invent, the value it should state. Created underFeedback/_Evals/{agentId}/{case}with only its source; its own hub lifts the rest on activation. Its Eval area runs the prompt N times against the agent and scores every reply as rates — looked, did not invent, correct. Wrong answers has the checks.Digesttype (Feedback/Digest, compiled live fromSource/) — the weekly wrong-answer digest (Plugins#2288): once the last completed ISO week is due, the schedule on this type's hubs (the shippedFeedback/Digestsnode and every digest) folds everycategory: answerreport intoFeedback/_Digests/{week}— per agent by kind, model and trend, plus the eval cases lifted and their last run — and posts one signeddigestevent per agent to the control inbox, from which the triage agent keeps one standing issue per agent. Its Digest area shows the week; onFeedback/Digestsit lists them all.Feedback Agent (
Feedback/Agent/feedback-agent) — the "feedback agent": does the capture-draft-preview. Callable directly (@agent/Feedback/Agent/feedback-agent) or by the skill.FeedbackContenttype (Feedback/Feedback, compiled live fromSource/) — the record and its views (the card is drawn once and reused, so the submitter's preview is byte-for-byte the reviewer's view):- Compose — the preview-and-submit surface (
Feedback/Compose/{id}or@@("/{User ID}/Feedback/{id}/area/Compose")). Loads the captured draft (as System) and shows it as a card whose message is an editable, data-bound field (host.Edit), with a Submit button. Nothing reaches the reviewers until the user clicks it — this is what lets them SEE and refine their feedback before it's sent. - Detail — the reviewer's full view of one submission (message + all context, present-or-not).
- Inbox — the admin review list on the space root: every submitted submission (drafts excluded), newest first.
- Submit — the space-root "give feedback via chat" call to action (points the user to
/feedback). Feedback is captured in chat, previewed, edited and submitted — there's no separate in-page form.
Every view is built from framework controls styled with CSS — no hand-rolled HTML.
- Compose — the preview-and-submit surface (
FeedbackSubmitter(Source/FeedbackSubmitter.cs) — the submit-as-System core. The production/feedbackskill creates a draft in the user's space;Publish(the Submit button) reads and updates it as System, flips it toNewand stamps the real submit time. The owning hub then moves it into the shared Inbox under System and leaves a navigation redirect at the old address, so preview and issue links still open the submission. The programmaticCreateDraftandSubmithelpers still create directly in the shared namespace but have no production caller. Both routes end with one record in the Inbox; no user-space feedback copy remains after the move.
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:
- The watcher looks before it moves. Moving onto an occupied address is refused by
construction, but core logs the refused copy leg at Error first, so every re-observation still
produced an Error (the incident reached 2,197 occurrences). The watcher therefore lists the
Inbox's children first, narrowed to the one id and projected to its path
(
FeedbackHandover.OccupantQuery), so the check costs one row however large the Inbox is. Existence comes from that listing, and content comes from the occupant's own stream (CQRS). If the address is occupied by anything other than the relocated source itself, the watcher settles without issuing a move. "The relocated source itself" means the same node type and the same creation stamps (FeedbackHandover.IsRelocationOf), the case where core resumes a half-landed move. A stale listing, or a look that fails or overrunsOccupantReadBudget, falls back to the move, and core's guard decides as before. - A follow-up is folded in, not deleted. A copy written again at the old address can carry
the submitter's follow-up, for example a note that the problem has since been resolved.
FeedbackHandover.FoldFollowUpappends a newextraContextto the Inbox copy's own, and fills the context fields the Inbox copy lacks. The report, the submitter and every triage-owned member stay as the Inbox holds them. The fold is written before the old address is deleted, and it is idempotent. - Neither half acts on a stale decision. The fold decides inside the Inbox node's update,
against the live node (
FeedbackHandover.FoldOnto). If the occupant no longer holds this submission, or no longer reads, the settle is aborted and nothing is deleted. Then the Inbox record is read back from its owner (FeedbackHandover.FoldCommitted). The source is deleted only if that committed record holds the submission and every follow-up block. Anything less keeps the source, and the settle is decided again, at most once per version and otherwise on the next observation. The old address is deleted withDeleteNodeRequest.ExpectedVersionset to the version the watcher captured, and the source's owning hub checks that version in its commit turn. A revision written while the settle was pending is refused deletion (Conflict), and the watcher then decides again on the newest state, which folds the revision in. The regression isFeedbackOccupiedInboxTest.ARevisionWrittenWhileTheSettleIsPending_IsNeverDeleted_ItIsFoldedIn. It holds the delete at a validator and revises the source while the delete is held. - A genuine collision is a verdict. If the Inbox address holds a different report, or a record
that does not read as
FeedbackContent, every retry meets the same occupant. The collision is recorded on the record ashandOverRefused, as described in "A refused Inbox move is recorded on the record" below, and the record is no longer moved on every activation. Nothing is moved or deleted.
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.
- Where it runs. The shipped
Feedback/Inboxnode is itself aFeedback/Feedbacknode, and its hub, and only its hub, armsFeedbackBacklog.WakeWaitingSubmissionsFromInbox(Source/FeedbackBacklog.cs). The package declares"anchors": ["Feedback/Inbox"]in itsindex.json, and the AI module'sPackageAnchor(pre-installed on every portal) holds every declared anchor activated for the life of the process. So the watcher runs from boot to shutdown, with no page open. The declaration is honoured because Feedback is an INSTALLED package: its install recordPlugins/Feedbacktargets theFeedbackroot. A node somebody typesStore/Pluginin a space of their own declares nothing. - What it does. One synced query,
nodeType:Feedback/Feedback partitions:all, run as System. Its first frame is the backlog: every record that existed before the watcher started, which no later event would ever report. Its laterAdded/Updatedevents are new submissions and Draft →Newflips. A listed record that still waits (FeedbackBacklog.IsWaiting: submitted and outside the Inbox, or in the Inbox, technical, unstamped, and this portal has a route to triage) is woken by opening its stream, which activates its hub. The record's own hub then moves it and hands it over exactly as above. The query only nominates; the record's hub decides from its own stream. - Bounded. One record at a time: the watcher holds a record until it stops waiting (a
Redirectstands at its old path, or it is stamped), or forFeedbackBacklog.SettleBudget, then takes the next. A backlog of hundreds drains as consecutive moves, never as a burst. - Idempotent. A moved record is a
Redirectand no longer matches; a stamped record no longer waits. Waking a record that is already done changes nothing, so a restart, a second replica or a stale listing never hands anything over twice. A record that still waits after the budget is logged with its path and left for the Inbox's next activation; it is not retried in a loop.
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):
- the record's own hub logs a Warning once per version, with the path and the reason, and does not move or hand it over;
- the Inbox's watcher logs each one once as
dead letter #N: <path> … <reason>.
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:
- Core admits the relocation (MeshWeaver
ContentSchemaOnWrite, rule 5; #6951): a move carries what the source holds on disk, so such a record now reaches the Inbox like any other, and its hand-over stamp rewrites it in theFeedbackContentshape there. - A refusal is a verdict, not a failure. A move core refuses with
ValidationFailed(FeedbackHandover.IsDeterministicRefusal) is not retried: the record's own hub writes the refusal tohandOverRefused— that one member, in the record's existing shape — logs it once at Warning, andUnreadableSubmissionnames the record as a dead letter from then on, so neither its hub nor the Inbox's backlog moves it again. Any other failure (unavailable, stalled) is still retried on the next activation. Correct the record and clearhandOverRefused, and it is handed over on its next activation.
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:
- the statement's own signature verifies with this portal's
ControlLane:Key(the inbox the lane stores into verifies nothing itself); - it names this portal's
Hosting:Deploymentand a pathFeedback/_Submissions/<id>; - the node there is a
Feedback/Feedbackthat this portal handed over (handedOverAtset), and its message is the one the control instance received (messageSha256); - it moves
issueandissueUrlonly, never backwards.
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
- Unit (
Feedback/Feedback/Test/FeedbackTests.cs, run by theTestslayout area and asserted by CI): content-type defaults, the pure submission-building core (timestamp stamping,New/Draftpinning, the Draft→New publish transition, the draft/Inbox filter, id/summary/description shapes), the shared card renderer (message shown, empty context hidden in the preview, HTML escaped, main node linked) and the message-only graceful-degradation contract. - Eval cases (
Feedback/EvalCase/Test/EvalCaseTests.cs, run by that type'sTestsarea): the lift from a report's labelled block, the case path, the three checks and the run summary. - Digest (
Feedback/Digest/Test/DigestTests.cs, run by that type'sTestsarea): the window, the counts by agent, kind and model, the trend, the cases lifted, a quiet week posting nothing, the rendering and the one-event-per-agent shape. - e2e (
e2e/feedback-skill.spec.ts,npm run feedback-skill): drives the memex portal —/feedback <marker>in a thread → agent confirms → the node lands in the Inbox with the message, submitter and main-node context.