Ratings & reviews — derived from real reviews, written only by those who installed

Every Store/Plugin package — course, app or plugin — carries a rating and a list of reviews without any per-package work: the surfaces are registered on the package ROOT's type, the storage is a satellite of the package's own partition, and the rules live in one NodeType, Store/Review (Store/Review/Source).

Where a review lives — two nodes, two structurally unique paths

node path written by holds
draft {viewer}/_ReviewDrafts/{package} the author, through the node-bound form rating, text
published review (public) {package}/_Reviews/{viewer} only the Store (ReviewControlPlane, as System) rating, text, authorName, reviewedAt — a public PROJECTION, never a moderation field
moderation flag (private) {package}/_ReviewModeration/{viewer} only the Store, after the admin gate hidden, hiddenBy, hide time — never the text

Who may review — the install record, enforced where the write happens

Eligible = signed in AND the viewer's install record lists the package (ReviewRules.Eligibility). The fact is TRI-STATE plus a fault (InstallCheck): until the record has answered the form says Checking your install…, and a read that faulted says so and asks for a reload — neither is ever rendered as "Install to leave a review", which would tell an installed learner something false. The install record — {viewer}/_Install/{repo-slug} items with an installedAt, written LAST by the one install engine (Installing.md) — is the Store's authoritative "this viewer installed this package": the cover's Get/Repair step and the catalog cards read the same record (InstallRecords.InstalledPackages, read by shape across every manifest, so a record stranded under a former repo slug still counts). An entitlement alone is deliberately NOT enough — a buyer who never installed has not used the package — and a pre-installed package needs no entitlement yet has install records.

The server-side rules, all in ReviewControlPlane.Publish and all re-checked on every publication:

  1. Author. The draft's framework-stamped last author (lastModifiedBy, else createdBy — never a content field) must be the partition owner. System is NOT an author: the Store creates the empty draft as System on Rate this, and only the viewer's own edit can turn it into a review. So nobody — an administrator included — publishes in someone else's name (ReviewRules.AuthorizationFailure).
  2. Install. The author's install records are re-read (synced query, System scope, by path) and must list the package.
  3. Validity. Rating 1–5, comment at most 1000 characters (ReviewRules.Problem). The comment is plain text: control characters are dropped, markup is kept literally, and every surface renders it — and the author's name — through Controls.Label, never markdown or HTML.

The click handlers are server-side too: the viewer is captured synchronously from the authenticated context at the click (never a value the client names); Rate this re-reads the install before it creates anything; Delete my review only ever addresses the clicking viewer's two paths (draft first, so its watcher cannot re-publish, then the published review); the form's own eligibility gate is the courtesy, these are the security.

🚨 No read-then-replace, anywhere. An existing public review is changed only through its owning stream's Update mapper, which computes the projection from the node as its OWNER holds it at that moment; a missing one is created with the create verb, which can never overwrite (a lost create race falls back to the mapper). Moderation never rewrites a review at all (below). The watcher's key is everything the decision reads off the draft — rating, cleaned text AND the framework-stamped author and the author's explicit submittedAt (ReviewControlPlane.DecisionKey).

🚨 A re-save of identical text is not an event — so a retry is the Publish button. Measured on the gate (Plugins #2341, run 35966679071): after a refused edit by someone other than the owner, the owner re-saving the byte-identical rating and text was never published. The mesh's write path posts nothing when the new node equals the one it holds (IsRecordNoOp / an empty merge diff), so the draft's lastModifiedBy stays the other editor's and its stream never emits: keying the watcher on the author cannot see an event that never happened. The form therefore shows Publish whenever the draft is valid but not what is published; it stamps submittedAt on the author's own draft, AS the author (the authenticated context captured at the click and re-entered around the write, the Edu AsCaller shape) — a real change, stamped with their identity, which is a new decision key. It is also the retry after a publication that failed. submittedAt is draft-only and never published.

🚨 The publisher never writes the draft. A reconcile that writes the node it watches feeds itself, and here it would also restamp lastModifiedBy to System, which the author rule reads. The form's status line — pick a rating, publishing…, published — is DERIVED by the page from the draft and the published review (ReviewAreas.FormStatus). Nothing the Store writes elsewhere can re-trigger it.

A consequence worth knowing: a draft edited while the author is NOT eligible is not published, and is not retried by itself when they later install — their next edit publishes it. An uninstall does not remove a review already published.

The cold-start rule — never fake data

Install count — deliberately not shown

The honest source of "installed by N" would be the install records, and they live in each viewer's PRIVATE home ({viewer}/_Install/*). Counting them means a System-identity fan-out across every user partition, driven from an anonymous public surface, on every catalog render. The partition the package owns holds only _Entitlements — which counts acquisitions, not installs, is empty for pre-installed packages, and is capped private precisely because its existence leaked (EntitlementPrivacy.md). Neither is an honest, cheap, privacy-safe count, so none is shown. The accepted way to add one is a Store-owned, System-maintained per-package tally that the install engine writes alongside the install record — a decision for the Store's owner, not a side effect of this feature.

Moderation — minimal, and private

_Reviews is publicly readable by design (see above), so a hidden review must not live there in any form — a flag on the public node would only be a UI filter, and anyone querying the container would still read the hidden text and the moderator's id. So hiding MOVES the review out of public view instead of marking it:

Where it shows

surface what
Catalog card (/Store?category=…) ★ 4.3 (12), or 🆕 New below three ratings
Cover (/{package}) the Reviews area embedded at the foot: summary, the viewer's form or the one-line reason ("Install to leave a review." / "Sign in to leave a review."), every visible review newest first — stars, display name, date in the VIEWER's time zone and chrome language (ChromeLocale.LongDate)
/{package}/Reviews the same section on its own — the reachable URL for an entitled viewer, whom the cover redirects straight into the package (CoverContract.CoverRedirect)
/{package}/RateThis the compact prompt: summary + form, no list

An authored cover body that already embeds @@("area/Reviews") does not get a second one appended. Every string is Store chrome in StoreTexts (English and German — the de table serves de-CH, du-form).

Asking for a review — embed RateThis

A package asks where it wants to ask, by embedding its own root's RateThis area. A course ends its last lesson (or its completion page) with:

@@("/AgenticPrimer/area/RateThis")

The embed is ABSOLUTE on purpose: the area lives on the package root and binds to the VIEWER's draft, so the same prompt works on the central lesson and inside a learner's installed copy (the relative-embed rule for exercise answer boxes is about binding to the learner's copy, which this does not). No Edu change is needed; an Edu completion hook can later render the same embed.

Where it lives

Store/Review.json (the NodeType), Store/Review/Source/PackageReviewContent.cs (the content — named so because Collaboration/Review already claims ReviewContent), ReviewRules.cs (paths, eligibility, validation, publication, moderation, the derived summary — all pure), ReviewControlPlane.cs (the publisher), ReviewAreas.cs (Reviews, RateThis, the card badge), registered on Store/Plugin (AddReviewAreas, which also registers the content type) and read by Store/Catalog. Pinned by Store/Review/Test/ReviewTests.cs through the type's Tests area.

Pinned live (Store/Review/Test/ReviewLiveTests.cs, rendered by the type's Tests area on the gate's mesh): the owner's edit publishes a System-authored public node; a viewer with no install record publishes nothing — until the record exists and their next edit publishes; an edit by someone other than the owner publishes nothing — and the owner's Publish of the SAME text afterwards publishes. Each refusal case carries its own positive control, so a dead watcher cannot pass as a refusal. Not yet pinned live: Hide / Show again.