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:
- A message the user submitted in the block that started their round invokes
/feedbackor/wrong-answer. A round drains every queued message, so the whole block counts, never an earlier round. Typing the skill is the request. That covers the slash command and the text the portal submits for a typed skill (Use the skill at '…/Skill/feedback' …). - The assistant asked first. The assistant response just before that block recorded a held
feedback create, and the user answered it. The hold counts only as the
createtool's own recorded answer. Another tool's result never counts, even when its text looks the same, because adelegate_to_agentresult is a summary the model wrote. Nothing has to be carried into the next turn. The hold licenses that one next turn and one feedback only, across every assistant and delegated agent of the mesh (FeedbackFilingClaims). A retry inside the round that was held is held again, so the model cannot confirm on the user's behalf.
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:
- The assistant's generic write tools (
create,update,patch,edit_content,move,copy) refuse aThreadorThreadMessagenode. The check uses the type the call declares and the type of the node already at the path. Only the engine writes the conversation, so the model cannot insert a hold into an earlier response or rewrite or queue a user message. - A message the model posts through
submit_messagecarries the engine'sSubmittedByAgentstamp (the posting round). It is not the user speaking: it can neither request feedback nor answer a hold. - Consent is read from the person's round only. A delegated sub-thread's "user" message is the task
the model wrote for
delegate_to_agent, so it never counts as the user asking. A delegated round is judged by the round that delegated it, and a hold a delegated agent's owncreatereceived counts as the question the user then answered. - The thread is read from a snapshot that lists the round. An older snapshot would judge the round by a previous round's message. If no snapshot lists the round in time, the create is held.
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
/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. Once submitted, Compose leads with a confirmation (✅ Thanks for your feedback — it has been submitted. We'll take care of it and get back to you.) above the read-only card — both on the turn Submit completes and on the submitted record the old address redirects to, which is where the submitter usually lands. Its words (banner, Submit button, confirmation) follow the viewer's language (FeedbackIssueTexts, English and German). Every card declaresheight: auto: the Fluent card's default is the height of its container, and an unrestricted card does not clip, so without it the card's context painted over the Submit button. - 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. 🚨 Global admins only (maintainer, 2026-11-01): the root declares
adminOnly: true, so the launcher shows the Feedback tile to a global admin and to nobody else, and the Inbox area renders the review list only whileIsGlobalAdminis true (a one-line notice otherwise; a failed check fails closed). Filing is NOT restricted — every user still sends feedback with/feedback, and agents and instance error digests still reach triage. - 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 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:
- 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.
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
- 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. - Privacy and review, in a browser (
src/MeshWeaver.Portal.E2E.Test/FeedbackReviewE2ETest, Plugins#4429): two users each file a draft in the/feedbackskill's shape under their own{User ID}/Feedback, neither can read the other's draft, each presses Submit on the draft's Compose area, the records move toFeedback/_Submissions/fb-…, neither submitter can read the other's submission there, and the reviewer sees both inFeedback/Inbox's Inbox area. It runs only against a live portal (E2E_BASE_URL/E2E_LAUNCH) that has the Feedback package installed (the bare dev Monolith answers the seed withNodeType 'Feedback/Feedback' is not registered), and the reviewer reads with its own grants, so that portal must give it Read onFeedback. Its deterministic half issrc/MeshWeaver.Hosting.Monolith.Test/FeedbackE2ESeedShapeTest: the browser test's own seed (FeedbackE2ESeed, linked into both projects) goes through the samecreateverb asPOST /api/mesh/create, is stored as a Draft, and after Submit lands at the address the browser test reads and in the Inbox query. The retired built-in shape (nodeType: "Feedback",text) is refused there.