Guidance for AI agents working with this repository.
This file states the rules; the evidence lives in skills. Every section is an imperative you must
obey. Where one ends in Full reference: [/name], the skill under .claude/skills/<name>/ carries
the worked examples, commands, war stories and incident history — load it when the task calls for
it, not before.
The shared skill source is .claude/skills/. Codex discovers that same directory through the
tracked .agents/skills relative symlink. Edit the canonical files; never copy skills into a
second tree. New skills become available to both clients through the directory link.
| Skill | Load it when |
|---|---|
| /worktree | starting any change: branch, edit, build, push |
| /pullrequest | opening / reviewing / merging a PR |
| /ci | authoring a workflow, a gate, satellite-repo CI |
| /new-repo | standing up a node repo: files, the thin CI caller, secrets in BOTH stores, webhooks to EVERY portal, the portal-side sync source |
| /release | shipping, tagging, checking what actually published |
| /deployment | rolling a portal, patching an environment |
| /testing | writing or running a test, triaging a red run |
| /mesh-data | any node read/write, query, payload cast, partition schema |
| /async | any hub-reachable or Blazor-view call; IO; AccessContext |
| /gui | any layout area, control, editor, form, table |
| /i18n | any string a human reads on screen |
| /teams | anything about an agent SEEING, reading or posting to Teams: the shipped bot reads nothing; what the EA consent link needs; the guest-tenant wall |
| /debug · /storm · /sigsegv | a hang/timeout; a restart/502/log flood; a crashed host |
When the task's goal is reached, automatically follow the pullrequest skill — update the DOC PAGE the change belongs to (🚨 not a What's New file: those are written per RELEASE, not per change — policy whatsnew-cadence; see conserve-work-products below, which this clause used to contradict), commit, push, open the PR, wait for green CI, merge. "Goal reached" means implemented, verified, and the touched projects build clean with CI's flags; don't stop to ask permission at that point. Everything short of that stays manual: never commit or push half-done or unverified work, and never merge with CI red or pending.
🚨 Finish AUTONOMOUSLY — a task ends MERGED, DEPLOYED and VERIFIED, with no second "go" from anyone (maintainer, 2026-09-09 and 2026-10-18: "see that it is all being merged automatically", "clearly stated to finish autonomously"). A request to change something authorizes the whole delivery: worktree → change → local gates → push → pull request → every review thread answered → green → merged → deployed through the supported pipeline → the changed behaviour verified on the target instance → worktrees removed. Never hand back "shall I merge?", a bare pull-request link, or "green and waiting". Once the review is answered, arm auto-merge on a pull request into the DEFAULT branch (it lands when the required checks pass, never before — so never arm a stacked pull request, whose feature-branch base requires no checks) and leave ONE background REST watcher (a script polling at ≥ 60 s, never a model loop) that re-arms a pull request only when nothing deliberate disarmed it — never one a person disarmed, and after a merged-tree guard disarmed it only once the default branch is merged in and the checks are green again — lands the halves of a cross-repo change in the order the pair gates demand (a platform ADDITION before the dependent that uses it, a DELETING half only after the dependent adaptation has merged), and wakes you only for what a script cannot decide: a thread to answer, a red to fix at the root. A red the workflow itself calls infra — no runner, no verdict in time, a timeout — is waited out or re-run, never a reason to stop. Stop and ask ONLY when the next step is fatal (a required gate red on your own change would ship broken), system-changing beyond the normal deploy (branch protection, credentials, migrations, a hand-roll), or destructive (deleting data, rewriting a shared branch); then state the blocker, the last completed stage, and what production actually runs now — unchanged, or which deploy steps already landed. Autonomy never licenses bypassing a check, an access control or the CD pipeline.
🚨🚨🚨 ABSOLUTE: NEVER work on the primary checkout — it stays on main, untouched. EVERYONE creates a worktree.
The primary checkout (/Users/roland/code/MeshWeaver) is a READ-ONLY reference, not a workspace. It must stay parked on main and untouched — never edit, build, commit, checkout, switch, reset, or stash there, and never leave it on a feature branch. It is the base every session's worktree is cut from; mutating it can clobber every concurrent session's uncommitted WIP.
EVERY agent session — no exceptions — works in its OWN git worktree on a fresh branch, and does ALL edits, builds, commits, and pushes there (git worktree add -b feat/x /Users/roland/code/MW-x origin/main). About to touch a file under the primary? STOP and create a worktree first. Never git stash — the stash stack is repo-global and collides across worktrees; use git diff > patch + git apply. Parallel PR-building sub-agents must pass isolation: "worktree" as a tool PARAM (a prompt-only instruction does nothing).
🚨 Merge the latest default branch before EVERY push (maintainer, 2026-10-03). Before each push to a branch with an open or planned pull request, run git fetch origin && git merge origin/<default branch>, resolve any conflicts, re-run this repo's local gates, and only then push. A branch pushed on a stale base turns DIRTY, or conflicts semantically, the moment the default branch moves — and GitHub runs no checks on a DIRTY pull request, so it keeps showing its stale green while nothing drains it (measured 2026-10-03: nine conflicting pull requests at once). Merging first does not license extra pushes: never push onto an in-flight PR run, a push during an in-flight review makes that review STALE, and a branch that is merely behind needs no push of its own. Batch — ONE merge-and-push per round. CI covers the other half: every suite job tests the head merged onto the CURRENT default-branch tip and names that tip in the run (policy suites-test-fresh-merge, Fresh Merge Under Test), so a stale base never earns a stale green; a conflict with the tip is red there, and this rule is how you resolve it.
CI builds Release with warnings-as-errors; a plain local Debug build passes while CI fails. Build every touched project and its dependents with dotnet build -c Release -warnaserror, one project per invocation, and only push when that is clean. The bar is MERGEABLE, not merged: a branch merely behind main merges fine here (strict: false, TWO required checks — Consolidate test results and Automatic review answered), so do NOT push just to catch up — but every push you DO make merges main first (merge-latest-before-push above). 🚨 strict is PER-REPO and it FLIPS — MeshWeaver.Plugins was strict: true until 2026-08-29 and measured false on 2026-09-02; never trust a written value, measure it. 🚨 But branches/main/protection ALONE gives a FALSE NEGATIVE, including on THIS repo. Protection lives in one of two places and the fleet is split between them: MeshWeaver and Memex are protected by RULESETS, for which the classic endpoint answers Branch not protected (HTTP 404) — read as "unprotected" it is simply wrong — while MeshWeaver.Plugins uses classic protection (5 contexts). So check BOTH, and treat a 404 from the first as "look in the second", never as an answer:
gh api repos/Systemorph/<repo>/branches/main/protection --jq '.required_status_checks' # classic; 404 ⇒ not the answer
gh api repos/Systemorph/<repo>/rulesets --jq '.[]|"\(.id) \(.name) \(.enforcement)"' # rulesets
gh api repos/Systemorph/<repo>/rulesets/<id> --jq '.rules[]|select(.type=="required_status_checks")'Measured 2026-09-17: core is ruleset 2128472 (main pr protection, active) requiring TWO contexts — Consolidate test results and Automatic review answered. 🚨 That second one was advisory as recently as 2026-09-11, when this line said "required context Consolidate test results" in the singular, and the stale singular cost a session ~45 minutes: three PRs sat green, armed and silently outside the merge queue, with REST reporting mergeable_state: blocked — indistinguishable from "waiting its turn" — because an unanswered Copilot thread now BLOCKS. It is the same lesson as strict flipping: measure it, never read it here.
🚨 The review check's log can say GREEN while the pull request stays BLOCKED, and REST cannot see the difference. Answering a thread fires BOTH a pull_request_review and a pull_request_review_comment event, and branch protection reads only the first kind: measured on #4649 (head 94fc73b4fdaa), the rollup carried 3 of the 8 check-runs — the pull_request and pull_request_review ones, all failures — and NEITHER success, because both successes came from pull_request_review_comment runs. Meanwhile the read-eligible runs that arrived after the replies were evicted in the shared pending slot. So the newest check-run reads success, the log says "GREEN — 5 of 5 answered", and the rollup correctly cites a real failure in a suite nobody reads; re-arming auto-merge does nothing, and mergeable_state: blocked is also what a PR waiting its turn reports. The check now refreshes itself: after a GREEN verdict on any other event, the lane's refresh job re-runs the newest pull_request run for the head, once per (head, answered state). Re-run it by hand only for a fork pull request or when refresh is red. This is legitimate, not "re-run and see": the check reads live state and that state genuinely changed, and a thread that is really unanswered is red on the re-run too. Also: a PR-level comment answers nothing; the reply must be ON the thread. Full account: ReviewFindingsAnswered.md.
Memex is ruleset 21038115 (main pr protection + Copilot review, active) requiring THREE contexts — config-key-coverage, Build (Release) and Documentation links resolve, all three published on its PRs — with required_approving_review_count: 0 and require_extra_approval_for_unattributed_changes: true. So Memex's Build (Release) now GATES the merge. On 2026-09-06 the same ruleset required config-key-coverage alone, the build was advisory, and auto-merge landed PRs seconds after arming with the build still running — the value flipped inside a week, which is the reason to measure it rather than read it here.
Demand a positive, specific success signal — 0 Error(s), a .trx that exists, an elapsed time that makes sense — never "the command returned". Each of these has produced a false pass here: timeout/gtimeout (no such binary on this macOS host — the wrapped command never runs), --no-build/--no-restore on a project this worktree never built (exit 0, no output, no .trx), several project args to one dotnet build (MSB1008), piping a build into tail (the exit code becomes the pager's), a 2-second "Build succeeded" (up-to-date no-op), --no-build after editing an embedded doc asset, and reading a background task's output right after launching it. Cap a local run by backgrounding it and holding your own deadline against date -u. Over budget means stuck — find what is not completing, never raise the bound. Full reference: /worktree.
🚨🚨🚨 ABSOLUTE: Green CI does NOT mean the mesh compiles — in-mesh source is invisible to dotnet build
Every .cs stored in a mesh node — NodeType Source/*.cs, Scripts, layout areas — compiles at RUNTIME in the portal, NEVER in CI, and a NodeType's configuration lambda is C# inside a JSON string, invisible to grep --include='*.cs' as well.
- Deleting or renaming ANY public framework surface is a breaking change to code the compiler cannot see. Before deleting one, search the node trees (
samples/*/Data, every node repo's content) and the node JSON, and search the live mesh (search_chunks) — it may hold callers the repo has already dropped. 🚨 Asearch_chunksanswer carrying"searched": falseis a FAILED sweep, not a clean one (#2741): the deployment has no embedding provider, nothing was searched, and the envelope deliberately carries nocountso an absent field cannot be read as "no callers". Sweep on another deployment or stop. Port or delete callers in the SAME change; a clean-warnaserrorbuild proves nothing here. 🚨 A symbol published on acellSurface: trueNodeType can NEVER reach "no callers left" — installing a course or app COPIES its cells into the installer's own space, and nobody can edit those; #975 deleted one on a clean sweep and broke every prompt cell in AgenticEngineering (#1258). Retire it as an[Obsolete]forwarder on the same surface, never a delete. - Before prod, sweep every NodeType green. 🚨 The instrument is
search 'nodeType:NodeType content.compilationStatus:Error partitions:all' limit:200— ONE non-truncated call, DECLARED withpartitions:all, and it reads the SAME field the readiness gate reads (ClassifyDetailedbranches ondefinition.CompilationStatus == CompilationStatus.Error), so the sweep agrees with the gate on any row it SEES. 🚨 The bare form — nopartitions:all, nonamespace:— is REFUSED (#4274), not answered:searchsaysError: Query is not sufficiently specified …, because on a Postgres portal that shape was SERVED from whichever partitions the fan-out happened to enumerate — never the system partitions (Admin,Auth, …), never one you hold no grant on — and reported at Error only in the portal's OWN log, so the caller got a cleancount: 0that read as "none exist" (measured 2026-09-14 on the control instance:nodeType:LogIncident→ 0,namespace:Admin scope:descendants nodeType:LogIncident→ truncated at any limit, same credential, seconds apart). A note that still spells the sweep withoutpartitions:allpredates that change; the refusal names the fix. 🚨 But it does NOT see the same rows, so "by construction" holds for the verdict and never for the denominator. The sweep runs as YOU and the gate runs as the SYSTEM:search/get/get_diagnosticsare RLS-filtered, so a NodeType parked atErrorin a partition you hold no grant on is silently NOT COUNTED — the sweep returns a smaller number, never an error — andgetanswersNot foundfor it, the SAME string an absent node gets. Measured 2026-09-10 on the control instance: the one-call sweep returned 0 whileBinaryClickerV2/BinaryTogglefailed its compile on every pod boot; #1391 was CLOSED in August on exactly thatNot found, read as "the type is gone", and the identicalCS1929was re-filed unchanged as #3883 four weeks later. So state the DENOMINATOR with the result ("0 of N over M readable partitions"), and both numbers come from the same instrument: N is thecountofsearch 'nodeType:NodeType partitions:all' limit:200(a floor if that call is truncated — raise the limit), M is the length of the sweep envelope'scoverage.partitions— the list the store reports having READ FROM, after the registry's exclusions and your read grants, i.e. the actual denominator.coverage.partitions: nullmeans the image serving you does not report what it read (its provider half is not on that image yet); then say "M unknown" and the sweep does NOT pass on that zero alone. 🚨 A LISTED partition can still be read only IN PART (#6564) — a partition enterscoverage.partitionsonce you hold ANY grant in it, and the row filter then hides every sub-path your grants do not reach (measured 2026-10-12: the sweep returned 0 withmeshweaverlisted whileMeshWeaver/samples/Graph/Data/Type/articlesat atErrorunder a sub-tree this caller held no grant on). So M counts only partitions read in FULL: subtract and name every entry ofcoverage.partial, andcoverage.partial: null(not assessed by that image) fails the sweep's zero exactly as anulldenominator does. Full reference: SearchCoverageAndRefusal.md. 🚨 DENIED and DELETED can no longer be separated by anything YOU can run, and that is now true by construction — #3890 closed theautocompletebypass that used to name whatgetrefuses you (the drill-down stamps the viewer and runs the sameValidateReadasget), because a control that works by disclosing other people's node titles is a disclosure surface wearing an instrument's colours.get @Admin/Partition/<Ns>still says whether the PARTITION was torn down (the record outlives the data), and a partition you CAN read, asked the same way, is still the negative control on a broken instrument — but neither answers about a NODE, so aNot foundfrom outside your denominator closes NOTHING; that is exactly how #1391 was closed and re-filed as #3883. Where the deploy spans partitions you cannot read, gate on the pod's ownnodetype_bakehealth payload — the ONE instrument that enumerates as system, so it names every non-Oktype on the replica whoever may read it, and files a pruned one underRetired/Removedrather than as a failure — but it lives in the HOST (Memex.Portal.Distributed, MeshWeaver.Plugins, not core'ssrc/) and is registered onlyif (gateBake), so confirm it is REGISTERED and ARMED before reading its green as an answer. 🚨 Neither of the two sees a PER-REPLICA compile, which is exactly what a roll produces (#4320).compilationStatusis ONE shared field over a compile every replica does as it boots, so a type that fails on the new replica can readOk— andbake-report'spreviouslybrokenreads that same field (NodeTypeBakeStatus), so it is the sweep with a system denominator, not an independent per-replica verdict. What stops a bad roll is the NEW replica's readiness gate, WHEN it is registered and armed (above):nodetype_bakerefuses readiness for a type that REGRESSED on its image and deliberately passes one already atError(NodeTypeBakeEntry.WasHealthy, so one abandoned type cannot freeze every deploy) — an already-broken type therefore neither stalls a roll nor is named by it, and naming those is the sweep's job. The per-replica signals that ARE independent are/health'scontent-types,bake-report'sBaked/BytesMissingsplit (the only states decided by probing this replica's own store —FrameworkStale,NeverBuiltandPreviouslyBrokenare decided by the shared record alone) andbake-report'sLIVE RECORD CENSUS(#4632 — the shared record compared to THIS replica's framework identity, refolded on every catalog emission rather than at boot, with records re-keyed AFTER this replica booted counted apart);Samplewould carry every replica's, and whether it does flips with kube-state-metrics (#4218) —Ops/Status/<id>readnotScraped: true,replicas: []on 2026-09-15 12:57Z andnotScraped: falsewith a row per pod on 2026-09-17 18:18Z — so readnotScrapedon the node before treating it as an instrument or dismissing it. 🚨 Thecontent.prefix is load-bearing — never drop it.compilationStatusis not aMeshNodefield, it lives onNodeTypeDefinitioninsideContent, and the query language has TWO executors that disagreed about the bare form for two releases (#3511): measured on a live mesh 2026-09-07,compilationStatus:Errorreturned 5 on Postgres and matched NOTHING in the in-memory evaluator, so a rehearsal of the sweep on a Monolith or a CI mesh was green by construction. Core now gives the evaluator the same content fallback SQL always had, but the sweep runs against whatever image the portal is ALREADY running — the dotted form is the established production idiom (nodeType:User content.email:…), reaches the field on every backend and every deployed image, and needs no reader to know which executor is serving. Full reference: QueryProviderParity.md. Do NOT decide whether the sweep passed fromget_diagnosticsorLspDiagnosticsForNode: measured 2026-09-06 on two live portals (#3396),get_diagnosticsserved a STALE cached verdict —Errorfor a node whose owncompilationStatuswasOk, with a byte-identical 403,625-byte payload nine minutes apart — and LSP was wrong in 3 of 5 contested cases; both err in BOTH directions, and the direction that matters is passing a broken mesh, which is the one thing the sweep exists to prevent. Reliability order: the node's owncompilationStatus+ a matchingReleasenode ≫get_diagnostics≫ LSP. Use the per-node tools to READ a failure the sweep has already named, never to decide the verdict. 🚨ok:falsewith astatusother thanCompiled(Absent/NotCompilable/Unavailable) is a sweep FAILURE, not a pass — that entry was never checked. Warnings count:stayed an untyped JsonElementmeans a view renders empty. A recompile wave can be live while you sweep, so every reading is a snapshot of a moving target — re-run the one-call sweep immediately before the deploy it gates.
🚨 In-mesh C# is now held to a WARNING standard, and it is a GATE policy — never a compiler option. The bake collected each NodeType compile's warnings and dropped them, so compile=Ok was reported over source that would not build in src/ (the maintainer, 2026-09-16: "does NOT surface warnings as errors ⇒ we have lots of missing xml comments"). mw-plugin-test compile --warning-baseline <file> arms TWO shrink-only ratchets over lines of <NodeType path> <diagnostic id>: one over the REAL warnings (CS0219, CS1574/CS1584, CS1570, CS1572 — latent bugs, or doc comments that EXIST and are WRONG; not CS1573, its opposite — a MISSING <param> tag is doc COMPLETENESS, sits in CompileWarning.NotReported, so EmitPipeline.Collect filters it out before the inventory and it can never be a NEW warning against this ratchet) and a SEPARATE one over CS1591 alone (a missing doc comment must never block an unrelated PR). An unlisted (type, code) pair fails; a listed pair whose type compiled CLEAN is STALE and fails until the line goes; a listed type that did NOT compile is unverifiable, warned and never failed. Core's two trees are baselined in .github/{doc,samples}-gate-warnings.allow and bake-then-gate.sh ASSERTS both ENFORCED lines, because the tester's default is observe-only and exits 0 — a dropped argument would leave in-mesh C# unguarded behind a green run. Satellites adopt with warning-baseline-file: on BOTH node-repo-gate.yml and node-repo-publish-bake.yml (naming one holds a PR and its merge to different standards); until then they are OBSERVE-ONLY and say so by name in every run. 🚨 Do NOT put a GeneralDiagnosticOption in EmitPipeline.CreateCompilationOptions() — the portal's runtime compile shares that one factory, so promoting a warning there parks every NodeType with a missing doc comment, refuses the new replica's readiness and stalls the rollout. 🚨 And before baselining a code, ask WHO EMITS IT: three of the five codes measured were the platform writing warnings into content it does not own (the skeleton generator's undeduplicated usings, its own undocumented public attribute, and string? in a tree with no #nullable context) and were fixed at the root — 850 raw occurrences over 27 of 27 sample types down to 375 over 18. Full reference: InMeshWarningStandard.md.
Full reference: /ci · NodeTypeCompilation.md.
"The grain keeps serving old state until we send a dispose request" (maintainer, 2026-09-17). A per-node hub — on Orleans, a grain activation — binds its configuration ONCE while it is activating and is then pinned by address: routing short-circuits on an already-hosted address and never resolves the path again, so nothing re-reads it while it lives. Every change a portal absorbs while it keeps running — a merge that syncs in, a package installed or updated, a NodeType recompiled in place, a new build published — changes what the NEXT activation would load and reaches a live one through nothing at all. It goes on answering from what it holds, with no error, no log line and nothing to grep, so a fix you cannot see in the running mesh is as likely to be an un-recycled activation as a bad fix. (A pod restart or a roll DOES end the activations on the pods it replaces — that is not the gap. The gap is the running portal, and what a fresh activation then CHOOSES.)
What stays live: every write that goes THROUGH the mesh — GetMeshNodeStream(path).Update(...) and node CRUD route to the owning activation, whose own-node subscription picks them up, which is why editing and every data-bound view need no recycle. What is bound ONCE and never re-read: the NodeType binding the hub was born with — and with it the compiled assembly, its collectible load context and everything that configuration's closure captured — plus anything read from outside the mesh (the store modules are pinned at process start). This bites tests too: a shared fixture (an Orleans TestCluster per collection) keeps activations between tests, which is half of what TestStateIsolation is about.
Tearing the activation down is the ONLY thing that makes it re-read, and the next access re-creates the hub from its node. Two surfaces, and they do DIFFERENT amounts of work. hub.RecycleNode(path, reason: "…") is the ONE framework surface and is dispose-only: cold (it posts on Subscribe, so an unsubscribed chain tears nothing down), the wait is the framework's own READ rather than a poll or a sleep, and it defers while a package install holds that root. The recycle verb — the MCP tool, a node's Recycle menu entry, mw recycle <path> — checks Update permission and, only when the target is a NodeType node, also stamps RequestedReleaseAt + RequestedReleaseForce before the dispose; a FORCED release deliberately skips on-demand prebuilt adoption and compiles the LIVE SOURCE (#2818), which is what makes it the operator's "rebuild from what is there now" remedy rather than a re-bind. Recycling an ordinary node stamps nothing and recompiles nothing, and the Compile button writes that same forced stamp while posting NO DisposeRequest. 🚨 The caller's hub must OUTLIVE the target: a dying hub cannot deliver its own last frame, so never recycle from the hub being recycled — and a ROOT-hub caller is excluded for a second reason, that its dispose and its read leave different off-router hubs, so the read can overtake the teardown and be answered by the still-live activation. 🚨 Issue it off the router (hub.NodeOperationIssuingHub() — #4463), never off the DI-injected root mesh hub, which HandleDispose refuses outright because disposing it over the bus wedged every node operation until the process restarted. 🚨 ALWAYS carry a Reason — an omitted one prints as "reason not stated by the caller", and that blank cost #3510 six occurrences and four bake seals.
🚨 After a dispose the NEXT activation binds the NEWEST build — and until the dispose the old one keeps serving (maintainer, 2026-10-03: "after disposerequest, new version must be loaded" / "old version must continue working"). The re-activation resolves the build the NodeType's record NAMES — newest published, installed and loadable for THIS framework identity, a compiled type and an adopted prebuilt bundle alike — by IDENTITY (its content-hashed LatestAssemblyPath and its LatestAssemblyMvid), never by whichever file answers for the store key; the assembly store is content-addressed, so a same-key byte change is a new build with a new path, and the old file stays for the hub still holding it. Until 2026-10-03 the store was first-write-wins per key, which is why a recycle "re-bound the same local copy" (#2471, six recycles on memex). When nothing newer can be loaded yet the old build keeps serving and the record says why — IsDirty (not released), CompilationStatus: Error + CompilationError (failed compile, last good build still named), an adoption refusal naming both framework identities — never a park. So a recycle is still never itself a fix and a second one proves nothing the first did not: if the address answers the old way after one dispose, the newest build the record names IS the old one — read the record, do not recycle again. A compiled MODULE assembly (content.module) is pinned to the PROCESS, not the activation, so a dispose cannot swap it. A recycle also clears no data (reactivation re-loads and re-saves what the store holds, so it is not a delete), touches ONE address and not the process, the other replicas or any process-wide cache, and cannot fix a package that never reached the pod — read the [ModuleLoad] … (written=…) line before recycling anything. The update behaviour is PROVEN in core's test/MeshWeaver.Updates.Test — one test per scenario (N → N+1, same-key bundle, failed and unpublished N+1, foreign identity, both content-shape directions, mixed replicas, rollback, data survival, no-dispose-no-change); change update behaviour there first.
This is the last step of a change set, not an afterthought. A pull request that changes something a per-node hub serves — a NodeType's source, node content shipped in an image or a bake, a layout area compiled in the mesh — is not proven live by the merge, the seal, the image tag or the roll: exercise the feature against the RUNNING address, recycle that address if it still answers the old way, and exercise it again. If it still answers the old way after that, the activation was never the problem and a third recycle will not find it. Name the addresses a deploy has to recycle in the PR body — a recycle nobody knew to run is indistinguishable from a fix that did not work. 🚨 And read the EFFECTIVE Modules:AutoRecycleOnStaleBuild before assuming either way: the code default is OFF (the stale-build watcher only OFFERS a banner, so instances keep the older assembly until a viewer clicks), while the AKS chart sets it true fleet-wide, where an instance self-recycles on an assembly-path advance — which a same-key byte change now is, because the path carries the content hash.
Full reference: StaleStateUntilRecycle.md · /deployment · /mesh-data.
Recycle the MAIN BIT and the mesh does the rest (core-only note, 2026-09-18; the fleet block above is mirrored and changes in three steps): a DisposeRequest on a NodeType definition cascades to its dependency network — the NodeTypes sharing its sources, transitively, and every instance of each — and only the activations that EXIST are torn down; a cold address is a no-op at the router and instantiates nothing (RecycleCascade, NodeTypeRecycleCascade, HubDisposalModel → "A recycle of the main bit"). Do not recycle instances one by one, and never pin an image because a page still answers the old way — recycle the type, then follow the mesh skill Skill/recycle-after-deploy.
Recycle a TYPE's live activations on every silo: ActivationRecycle (core-only note, 2026-10-04). ActivationRecycle.Request(hub, …) writes Admin/_Recycle/{id} as System; every process hears that commit on its invalidation feed, disposes the per-node hubs IT hosts whose bound NodeType matches (HostedHubsCollection + NodeTypePathHolder — no index of stored instances, so cold addresses are never touched), in paced batches off the router with the request's reason, and writes {request}/{process} as its audit. Reason and at least one NodeType required (addresses and a path only narrow); unknown types, NodeType and the feature's own types refused; each process acts only on requests System wrote. A global admin gains no rights from it: on the control instance the request is issued by the build queue's recycle job as System, and an admin may only ENQUEUE that job (a platform action; MCP enqueue_recycle). Direct recycle still requires Update. Doc/Architecture/StaleStateUntilRecycle → "Recycling a type".
The user is LITERALLY NEVER interested in a band-aid, workaround, mitigation, or symptom-suppression. When something hangs, deadlocks, flakes, or errors, find the EXACT defect and fix THAT. These are band-aids, and proposing one as "the fix" is forbidden:
- Increasing a bound to make it pass — pool size, timeout, retry count, buffer size,
maxParallelThreads. The question is never "how do I get more headroom", it is "why is the slot/thread/budget not released, or why is it erroring". - A watchdog / timer / poller that resubscribes or retries to recover from a state that "shouldn't happen". If the initial state never arrives, find why it is dropped or erroring.
catch {}/ swallow-and-continue /.Catch(Observable.Empty)that hides a fault instead of surfacing or fixing it.- Revert-and-move-on when the revert just hides a defect that is still live underneath.
- A
Clear()for test isolation, a widened.Timeout(...), a sleep — each is the tell of an unfixed root cause.
If active bleeding genuinely needs a stopgap first, say so EXPLICITLY ("this is a temporary stopgap; the root cause is X; I will fix X") — then fix X. Default to a deterministic repro that pins the true cause before changing code. Full reference: memory feedback_no_bandaids.
Anything the platform cannot run without — language models and providers, agents, queues, policies, credential references — is DECLARED (deployment-record config or committed content), and the declaration is the source of truth. A boot-time importer or reconciler never deletes a declared item because a module, plugin or registration did not contribute it on this replica: an absent contributor is NOT a removal. Pruning removes only what the declaration itself dropped. A change that makes a critical catalogue depend on module wiring alone is a defect — and so is a critical catalogue that exists only as nodes derived at boot: declare it. Measured once already: every EU language model existed only as boot-derived nodes, a module-container change hid the contributing registration, the import pruned them although the deployment record declared them, and the fleet had no reviewer model for about twelve hours. Policy declared-not-derived.
🚨🚨🚨 ABSOLUTE: No hand-woven async/concurrency primitives — the actor model does NOT tolerate SemaphoreSlim
A SemaphoreSlim — or any hand-rolled async gate, lock-for-async, or signal (TaskCompletionSource as a gate, a Task.Delay timeout race, ManualResetEventSlim, lock-around-await) — anywhere in src/ or test/ is FORBIDDEN, outside the one place sealed inside IoPool. It parks the single-threaded action block (or grain turn), so the message you are waiting on can never be processed → deadlock. Serialization channels through the hub (a Subject<T> + .Select(Run).Concat().Subscribe(...), or GetMeshNodeStream(path).Update(...)), and concurrency bounding / one-shot init channels through IIoPool — pool.Run(...) held in an instance PromiseCache/PromiseSlot, never a SemaphoreSlim(1,1) and never a bare ConcurrentDictionary<key, IObservable<T>> (a ReplaySubject latches OnError and replays one transient fault forever, #1369).
🚨 test/ reached ZERO on 2026-08-30 and the allow file is DELETED — every root is now scanned with no escape hatch. In a test the two shapes are: a producer→test signal is an AsyncSubject<Unit> the producer completes, awaited through the assertion helpers (await x.Should().Within(...).Emit(because) / .NotEmit(within)) — never .Wait() on it, which only trips BlockingBridgeInTestRatchetGuard instead; and a release INTO a worker the test deliberately parks (the park being the subject) is a volatile int polled under a bounded SpinWait.SpinUntil, written in a finally so a failing assertion cannot strand it.
Full reference: /async · RemovingHandWovenGates.md · ControlledIoPooling.md · memory feedback_no_semaphoreslim.
A "UI feature" means wiring up the framework's EXISTING pieces, never reinventing them. Before writing ANY UI/binding/persistence code, FIND the existing area/control/macro/extension and use it; if you are reaching for GetDataStream/Subscribe/Update/CombineLatest/a new wrapper for a UI feature, STOP.
- Editing a node's content → bind the GUI DIRECTLY to the node stream (
MeshNodeContentEditorControl.ForType,MarkdownEditorControl.WithAutoSave,MeshNodePickerControl). 🚨 NEVER replicate the node into a layout-area/data/{id}copy plus a save subscription — any*AutoSavehelper, or a "Save" button that reads/dataand writes the node, is the forbidden replicate-then-save antipattern (two stores drift; the save loop clobbers unedited fields). - Tabular / structured data → a framework CONTROL, NEVER hand-built HTML.
Controls.DataGrid+PropertyColumnControl<T>, composed withControls.Stack/LayoutGrid/Title/Markdown. FORBIDDEN:StringBuilder/$"<table>…", anyRenderHtml-shaped helper, orControls.Html(handBuiltMarkup)for structured data. - A page is composed from the platform's layout controls — never your own UI framework (maintainer, 2026-09-15).
Controls.Stack/LayoutGrid/Splitter/Title/Markdown,Controls.DataGrid+PropertyColumnControl<T>for rows,CodeEditorControl(read-only) for code,Controls.Progress,Controls.Button. FORBIDDEN: a CSS system or inline-style framework of your own, andControls.Htmlmarkup for layout or code. If the platform lacks a control, add it to the platform — never work around it in a node. - Form controls → the
Editmacro +[UiControl<T>]/[Description]/[Editable(false)]; no hand-built selects/checkboxes/textareas + a data section. Submitting a chat message → the existinghub.StartThread(...)/hub.SubmitMessage(...)extensions; no wrapper class, no path→id resolution. - Never
.Take(1)on a stream feeding a live data-bound view — it freezes the binding.
Full reference: /gui · GUI/DataBinding.md · memory feedback_no_handrolling.
Editing LogInformation ↔ LogDebug ↔ LogTrace (or appsettings.json under src/) to dial verbosity for a debugging session is FORBIDDEN — log levels reflect the production cost model, and Information lines ship to Loki. To turn the volume up, edit the appsettings.json in the test's bin/Debug/net10.0/ (reloadOnChange: true flips it mid-run). The src-tree appsettings.json and every Log* call in src/ is committed contract; a genuinely mis-levelled call gets a real commit explaining the cost/value trade-off.
A CI gate must never carry continue-on-error: on the step that fetches its input, nor an if: that asks whether a secret/variable is set. GitHub paints a skipped job the same colour as a passed one, so "the gate never ran" and "the gate passed" become indistinguishable. Instead: one preflight job asserts every external input and fails RED naming what to provision; gates needs: it and run unconditionally; the fork-PR exemption is expressed once, on the event, never as "the secret is empty"; the required check carries preflight in needs plus an explicit fail step. The same applies to guard TESTS — a guard whose subject moved and whose roots did not passes having checked nothing.
🚨 A Dependabot run reads a SECOND secret store, and a preflight red there is a PROVISIONING task, never a workflow bug. Secret source: Dependabot in the log means secrets.X resolved against Settings → Secrets → Dependabot, so a secret sitting on the Actions tab reads EMPTY — and the preflight's own "set it under → Actions" line sends the reader to the wrong store. A secret any pull_request lane requires is provisioned in BOTH stores (gh secret set <NAME> --app dependabot --repo …: values are unreadable through the API, so the value comes from the authoritative SOURCE — Key Vault, the running pod, a public id — and is PROVED to be the right one before it is written); adding a missing+= line is a two-store act. 🚨 And some credentials are stored NOWHERE, so "mirror it" is impossible for everybody: an mwi_ registry instance key is hash-only (InstanceKeys persists Hash(raw) alone) and an ACR token password is write-only (credential generate REPLACES the slot; meshweaver's admin user is disabled). For those the Dependabot lane gets its OWN issued credential — additive, separately revocable, verified against the live service before it is stored, never a rotation of the Actions one. And nothing in CI can see the second store (GITHUB_TOKEN has no secrets/dependabot-secrets permission key, and neither org App holds one — meshweaver-cloud has contents/metadata/pull_requests, fleet-reader the same read-only). 🚨 Only secrets. is doubled — there is NO Dependabot variables store (dependabot/variables 404s), so vars.X resolves fine in the very run whose secrets.X is empty, which is what makes it read as a workflow bug. 🚨 And the preflight list is NOT the denominator — a secret can be CONSUMED by a PR job that no preflight asked about, and then the empty value surfaces late wearing a message that names no secret (compose-sealed-modules.sh: --registry-url needs --registry-key = an absent MW_REGISTRY_KEY, Reinsurance#128, preflight GREEN). check-pr-secret-preflight.py now enforces the other half — every secrets.NAME a PR-reachable job consumes is asserted by a preflight — statically, in core and in every satellite through node-repo-validate.yml; exemptions are one reasoned line in .github/pr-secret-preflight-allow.txt, and a stale one reds. 🚨 Do NOT reach for github.actor != 'dependabot[bot]'. Core carries it on shared-rules/cross-repo-pair for ONE reason — those gates also run on merge_group, where the Actions store IS available, so the exemption moves WHERE the gate runs, never WHETHER it runs. Measured 2026-09-06: only core has a merge_queue rule; every satellite (and Memex) has none, and several carry a merge_group: trigger whose event never fires. Copying that if: into a satellite skips the gate outright — invisibly, because its required contexts ARE the gate jobs themselves (measured 2026-09-08: all five of Reinsurance, SocialMedia, Manufacturing, Crm and Education require validate / Validate node repos BY NAME, and all but Education also require compile-check / Compile every NodeType (vs core)) and a SKIPPED required context counts as SATISFIED. 🚨 The operative word is SKIPPED, not absent: a job whose if: is false still PUBLISHES a check-run, conclusion skipped, and that is accepted under BOTH protection mechanisms — so this trap is fleet-wide and does not depend on the classic/ruleset split. (An absent context is the opposite reading and blocks under classic; see the 🚦 rule below.) Full reference: DependabotSecretStore.md.
🚨 A transitional allow entry (scripts/record-signatures.allow, scripts/type-forwards.allow) is written as <key> #<THIS pull request> — <reason> and is in force ONLY in the diff that adds it. It expires the instant that merges — the gate resolves the pull request, so an entry naming a MERGED/closed one, an ISSUE, or nothing at all is RED, and an entry the merge base already holds is INERT (permits nothing, fails nothing, safe to leave). Never write "delete this line later" into one: that instruction was ignored once and reddened every C#-touching PR in the fleet for ~40 minutes (#3422). Full reference: TransitionalAllowEntries.md.
And never hand-roll (or copy-paste) a node repo's CI. The shared jobs live here as workflow_call workflows (.github/workflows/node-repo-{validate,compile-check,gate,tag-modules,publish-bake}.yml); MeshWeaver.Plugins / .Education / .Reinsurance / .SocialMedia call them and keep only repo-specific policy. Adopting one renames that repo's required-status-check contexts to <caller job> / <name> — do it in the same change. Full reference: /ci.
🚨 A change that spans this repo and a plugin repo lands DELETING-HALF-LAST, and CI now holds you to it. A pull request whose diff removes a public top-level type from src/ — a departure, a move, or a whole assembly leaving — or a public MEMBER of a type that stays (#3137 deleted two fields and reddened every Plugins PR for three hours) must declare its counterpart in the PR BODY: Pairs-with: Systemorph/MeshWeaver.Plugins#904, or Pairs-with: none — <reason> when nothing outside core referenced it (a reason citing a live-mesh sweep must quote searched: true; searched: false is refused). The Cross-repo pair (public surface) gate resolves it and stays red while the counterpart is open, draft, or merged into anything but its repo's default branch. Ordinary work never meets it (measured: main~25 → main removes ZERO public types and two members, both #3137). 🚨 A change that ADDS a member to a public interface has the OPPOSITE obligation, and its own gate (#3465). A forwarder rescues a CALLER; it cannot rescue an IMPLEMENTER — #3446 added three members to IEaGraphAuth with forwarders, every caller kept working, the pair gate was green, and the satellite's pin move then hit CS0535: 'FakeEaGraphAuth' does not implement interface member … ×3, deadlocking the release wave (its main was dark for hours: the pin move could not compile without the adaptation and the adaptation could not compile without the pin). Interface additions (implementers declared) fires on a member added to a public interface with no default implementation (or an abstract member added to a public abstract class) and wants Implementers: IFoo.Bar — <what you checked and what you found> in the PR BODY. 🚨 The ordering is INVERTED: the core half lands FIRST, so it asks for the coupling to be PREDICTED, never for a merged counterpart — demanding one would recreate the deadlock. Giving the member a default implementation silences it, and that is the intended fix. It needs no credential, so it runs on forks too. Ordinary work never meets it (measured: 1 of the last 40 merges — #3446 itself).
🚨 Between them the gates see FOUR of the TEN break shapes. Covered: a public type leaving src/; a public member leaving a type that stays; a PackageVersion removal a satellite consumes versionless (Satellite-pins:); and an implementer-obliging interface addition (Implementers:). They do NOT see: an added overload making a dependent's <see cref> ambiguous (CS0419 under -warnaserror — this bit MeshWeaver.SocialMedia on 2026-09-04 when it moved its core pin), a JSON envelope's field names changing, a comment another repo parses as data, an i18n VALUE change, a method's BEHAVIOUR changing behind an unchanged signature (#3276, shape 7 — which no surface detector can see by construction, and which is now MEASURED for MeshWeaver.Plugins below), or a rendered-UI string a satellite's e2e asserts on (#3401, shape 9). 🚨 A core PR PROVES it does not break MeshWeaver.Plugins before it merges — Dependent suites (MeshWeaver.Plugins) is REQUIRED (policy dependent-suites-affected-gate, which superseded core-merge-never-blocked; #2689): every non-fork, non-Dependabot PR asks Plugins to build and run, against the candidate (the PR's fresh merge with main), exactly the suites and in-mesh Tests areas the diff can reach — selected PER REALM from the compiled and import graph, each with the edge that pulled it in, every unreached realm named "not selected" — and Consolidate test results fails unless that verdict is green. So shape 7 IS now seen, for every Plugins suite the diff reaches. Only what is affected runs: a selection Plugins cannot compute is RED naming the path, never a full run (the once-a-day run stays the only full run); a docs/CI-only diff selects 0 and says so. A MEASURED break merges only when DECLARED on one line of the PR body — Breaks-plugins: <realm>[, <realm>…] — <what breaks> — counterpart Systemorph/MeshWeaver.Plugins#<n>; semver: <major|minor|ceiling> — with the counterpart's own run green against the same candidate and the semver consequence IN the diff (PlatformVersion's major/minor, or the compatibility epoch); a declaration never excuses missing evidence. Tests areas are selected but NOT yet run against an unpublished candidate (no candidate portal image) — the verdict counts them as unmeasured, never as a pass. Fork and Dependabot PRs are an EVENT exemption printed as "NOT MEASURED" (the Dependabot store lacks DEPENDENT_DISPATCH_APP_*). It covers MeshWeaver.Plugins only — the other satellites still find a break in their own daily run. The fleet's ARMING is still the promotion verdict (one-promotion-gate). Full reference: OnePromotionGate.md · PairedChangeSets.md · CrossRepoPairGate.md · /ci.
🚨 Every CI job is HARD-CUT at 45 minutes (maintainer, 2026-09-02: "hard cut ci runs after 45min — we pay all this"): every job in every workflow of every repo carries a LITERAL timeout-minutes ≤ 45 — GitHub's default is 360, and one uncapped job (the module-pack pack job, hung on a test host that never started a test) held a runner six hours per run across 19 concurrent Plugins runs and blocked every required check behind it. check-workflow-timeouts.py refuses a missing, oversized or expression-valued cap (core runs it on itself; the node-repo-validate lane runs it on every satellite). A job that needs more than 45 minutes is STUCK, not slow — find what is not completing, never raise the bound.
Every bug carries a severity, and two of the four BLOCK a release. sev:B (blocking) and sev:H
(a primary path broken, an intermittent user-visible failure, or silently WRONG results anywhere) must
both be zero to cut a release; sev:M and sev:L are a priority conversation and never gate one.
That is policy release-blocker-gate,
which carries when it took effect and who set it — this page carries only the rule. Every open issue
carries exactly one severity, an enhancement, documentation or chore included — a non-bug takes
sev:M or sev:L, and sev:B/sev:H are reserved for defects (policy every-issue-has-a-severity;
scripts/check-issue-taxonomy.py reds on either breach).
So work the queue in that order — sev:B, then sev:H, and treat sev:M/sev:L as backlog
unless asked. Before starting on an issue, check it is actually classified: an unlabelled issue has not
been triaged, and working it ahead of a labelled sev:H is choosing by accident.
It is enforced, not remembered: Governance/Standards/release.cut (MeshWeaver.Plugins) carries
NoOpenIssues for both labels across the seven gated repositories — 14 gates. 🚨 Two traps, both
measured: a zero from search/* can mean truncated-or-rate-limited rather than none (read a count
against its coverage, and prefer the REST issues endpoint), and an unknown label folds the gate to
GREEN — labels=sev:DOESNOTEXIST answers 0, byte-identical to a real label with nothing open. 🚨
Verify each label exists with the SINGLE-label endpoint repos/{o}/{r}/labels/sev:H, which 404s when
it is absent — never by listing labels, which is the trap one level down: core holds 13 pages of
them and its sev:* four sit on page 2, so a one-page existence check reports them missing from the
repository that currently carries the most open sev:H.
Full reference: Issue Taxonomy and the Release Readiness Gate · /release → the READINESS gate.
🚨 Finishing a change set means MERGED — merge it yourself on green, don't ask for permission. A PR left open with a link handed back is unfinished work; the safety IS the gate (green CI plus the automatic Copilot review, which you never hand-request and never withdraw). Stop only when CI is red for a reason you cannot fix, when a review asks for a decision that changes what the change set IS, or when the work needs a scope call the user has not made. A change set spanning repos is finished when every part is merged in dependency order: platform first, then what depends on it.
🚨 YOU babysit your own PR through to MERGED — nobody else will. The reviewer is GitHub Copilot (policy copilot-code-review). The control plane's model-driven PR steward is being switched off (its own off-switch is still owed on the copilot-code-review register): its owner and fixer rounds re-sent a whole PR's diff and logs on every call and every pass, and that burned a $1,500 daily model budget in one night. The author of a PR follows it until it merges: Copilot's threads answered ON the thread, reds fixed at the root, a held stage re-run once the review has landed and been answered, DIRTY updated from main, merged on green. 🚨 The WAITING is a script, never a model loop. Poll with a background shell loop (REST, ≥ 60 s) that merges, updates branches and re-runs mechanically. Call the model only for the RESULT the script cannot decide: a thread to answer, a red to fix. Do it once, on the condensed evidence (the failing assertion, the diff hunk, the finding), then stop. A subagent that "waits for CI" or "watches for the re-review" is that loop, paid per turn: subagents push, report, and STOP. Push only for a real defect — every push starts a new review round with new threads, so a nit or a question is answered on the thread, not with a commit. Full reference: ReviewFindingsAnswered.md.
🚨 PR capability is CREDENTIAL × REPO — measure it for the repo you are in, never remember it (gh api repos/Systemorph/<repo> --jq '.permissions'; gh auth status). The same repo answers differently to two sessions on the same day. Never reach for --admin — a refusal is information about the gate, not an obstacle to route around; on FORBIDDEN, re-authenticate with ! gh auth login.
🚨 Poll the MeshWeaver Build and Test workflow by name over REST and merge only on completed/success — gh api "repos/Systemorph/MeshWeaver/actions/runs?head_sha=<sha>", filtered on .name. Never wait for all check suites (an installed App that posts no runs leaves its suite queued forever — Azure Boards does exactly this here), and never poll with gh run watch / gh pr checks --watch: both issue GraphQL, and exhausting the SECONDARY limit takes GitHub access away from every concurrent agent while /rate_limit still reads 5000/5000. Consolidate test results is the required check for TESTS — but it is no longer the only required context (see above: Automatic review answered is required too), so a green build is necessary and not sufficient. 🚨 A red Automatic review answered therefore BLOCKS, and it is answered by replying ON each review thread (POST /repos/{o}/{r}/pulls/{n}/comments/{id}/replies) — a PR-level issue comment does NOT count, and neither does resolving the thread. The check's own log says so and names each unanswered thread. 🚨 A watcher that waits on the suite alone therefore calls a PR ready while the review gate still blocks it.
🚨 SUBSCRIBE to a PR — one persistent Monitor over EVERY event you would act on, armed when you open it: suite green, suite any other terminal conclusion, a new unresolved review thread, mergeStateStatus = DIRTY, and MERGED/CLOSED. A success-only watch is the same defect as a gate that skips on missing input — silence looks identical to "still running".
🚨 Merging is a shared action. A merge supersedes the run QUEUED behind the one in flight, including one another session is waiting on. Before merging, check whether main has a run someone is gating a deploy on; if so, hold and say so — and push that hold to your subagents explicitly, because their default is merge-on-green.
🚦 The MERGE QUEUE on core main is OFF (measured 2026-09-27: enqueuePullRequest → "No merge queue found for branch 'main'"; the ruleset main pr protection carries no merge_queue rule). main merges on its required checks plus auto-merge: gh pr merge --auto ARMS auto-merge (the control plane's PR steward, MeshWeaver.Plugins PrArming, arms a non-draft PR once its CURRENT head is reviewed, every finding answered and the required checks green; auto-arm.yml only DISARMS on a push; draft is the opt-out), and GitHub merges the moment the required set is green. merge-queue-steward.yml acts only on dequeued events and is dormant. Re-enabling the queue is a ruleset change, and a maintainer's — and the one cross-repository wait it may carry is the affected-only dependent-suites verdict (policy dependent-suites-affected-gate). Full reference: MergeQueue.md · OnePromotionGate.md.
🚨 A CLOSING KEYWORD IN A PR BODY READS NOTHING BUT THE NUMBER AFTER IT — not the negation in front of it, not the possessive behind it, not the sentence it sits in. Measured on 2026-09-22, three times in one day: **Does not close #5057** closed #5057 two seconds after the merge (#5201); Closes #2299's classification half closed the whole of #2299 against its own body (#5174); and a documentation PR narrating that misfire (… closed #2299 …) closed it AGAIN, 57 minutes after a deliberate reopen (#5190). To mention an issue without closing it, write Refs #N or see #N — a disclaimer is not a way of being careful, it is a way of closing the issue while believing you have not. 🚨 And a sev:B/sev:H issue may not be closed by a merge AT ALL: a merge puts the fix on main, while what the label gates is whether the defect is gone from the RUNNING portal, so those close on post-roll production verification (policy severity-closes-on-verification). Closing keywords (no accidental close) refuses all three shapes and hangs off the required check; when the verification has already happened, declare it per issue in the PR BODY as Verified-closing: #N — <what was verified, and where>, which releases the severity check and nothing else. It needs no credential, so it runs on forks too. It meets about one merge in fourteen (measured over the last 99 merges: 16 carry a closing keyword, 7 would be red, and 6 of those 7 closed a sev:H). Full reference: ClosingKeywordsAndIssueState.md.
🚨 No client name in anything you PUBLISH — the tree AND the text around it (policy issue-text-name-scan). This repository is public: an issue title or body, an issue or pull-request comment, a pull-request title or description, a review comment, a review body and a commit message are readable by anyone, and GitHub keeps the edit history of every one of them, so rewording afterwards removes nothing — only DELETING the item does, and that is a maintainer's act. Never write a client's name, a client's staff name or a client infrastructure identifier (tenant, subscription, vault, registry, host) there: write a role and a neutral sample (an enterprise client, acme, portal.example.com), and cite a check's finding by its masked form, client#n, never by the term. Never paste a tool result, a log line or a mesh read into public text without reading it for names first. The daily issue-text-names.yml lane scans every author's issue and pull-request titles and bodies, issue comments, review comments and review bodies against the CRM's ground truth on the instance that owns the CRM and files findings privately there — commit messages are NOT scanned by it, so for them this rule is the only control; it returns no verdict, edits nothing, and a run that could not check keeps the standing issue labelled issue-text-names-sentinel open — so a green lane is no licence to be careless, and a red one is a provisioning fact, never a reason to skip it. Full reference: MeshWeaver.Plugins Governance/ClientNameGate → Issue and pull-request text.
Full reference: /pullrequest · delivery, batching and image verification: /release · workflow/gate authoring: /ci.
public.mesh_nodes is empty by design. Data lives in per-partition schemas (acme.mesh_nodes, rbuergi.mesh_nodes, …), satellites routed by path segment (_Access → access, _Thread → threads, _Activity → activities, _Comment/_Approval/_Tracking → annotations, Source/Test → code). namespace keeps the partition prefix — never strip it (rbuergi/ApiToken, not ApiToken). Never run raw psql UPDATE on a live portal — it bypasses the workspace cache; use MoveNodeRequest or a Repair vN migration. 🚨 Provisioning a partition schema is REACTIVE + POOLED: EnsurePartitionProvisioned(namespace).SelectMany(_ => write…) — never declare a PartitionDefinition node to force a schema (it provisions the name verbatim while writes hit the lowercased one → 42P01), and never lowercase by hand.
Full reference: /mesh-data · PostgresSchemaArchitecture.md.
Merging a package to MeshWeaver.Plugins main publishes it. The plugin registry on the public
instance lists every top-level folder whose index.json root is a Space or Store/Plugin
(NodeRepoPackageSource), and every instance the registry serves installs a newly listed package
on its next install-defaults pass (InstanceAutoRegistrationService.InstallDefaults). No flag
holds a package back: Store unlisted hides only the catalog card. So a merged package is never
"not yet published" or "still waiting to be installed on an instance" — never tell the owner it
is. Measured 2026-09-29: Parties (MeshWeaver.Plugins#2555) was auto-merged at 10:03 UTC and was
live at 10:23 on the company instance and at 10:36 on the public one.
Experimental or unapproved NodeTypes live in the author's own user home ({user}/…) until the
owner decides to publish. A NEW package enters MeshWeaver.Plugins only on the owner's explicit
go, and the pull request quotes it.
Published means listed in the App Store, and nothing more: NO ONE gets access by default. The cover and guide pages are the storefront. The content is reached ONLY through a subscription and its tier, the free tier included, and only once the user acquires it. Never grant a plugin to users in bulk, and never notify anyone that an app became available. Grants the system issues never notify (MeshWeaver#5901), and each person decides per app what reaches them (Settings → Notifications → Apps, MeshWeaver#5902). Maintainer directive, 2026-09-30.
"Global/platform admin" has ONE meaning: Permission.All at scope Admin — an AccessAssignment granting the Admin role in the Admin/_Access namespace. This is a platform admin, NOT a data superuser: it does NOT grant access to spaces or user partitions, and emergency cross-partition data change requires explicit elevation (break-glass), never standing access. A root _Access grant is the data-superuser shape and is deliberately NOT how platform admins are provisioned. The one predicate is hub.IsGlobalAdmin() / hub.IsGlobalAdmin(userId) — never an ad-hoc role-name or root-scope check — and the grant lives in Admin/_Access, never root _Access: a writer/reader split silently locks admins out of every admin tab.
🚨 ONE standing, named exception (maintainer, 2026-10-04: "make a standing rule for global admin for access"): a global admin OBSERVES AND STEERS system agent threads. It is a rule the Thread / ThreadMessage node types DECLARE (INodeTypeAccessRule + INodeTypePermissionRule, the AI engine's ThreadAccessRule in MeshWeaver.Plugins), never a grant row and never a fold short-circuit: read the conversation, post into it, stop or redirect its round — Read, Update of the thread node, Comment, Thread. It does NOT reach a thread in another person's home partition, the data the thread is about, any non-thread node, deleting a thread, or editing a message someone else wrote. A node type that needs a non-CRUD permission declared this way implements INodeTypePermissionRule; every seam (RLS validator, delivery gate, stream-cache read gate) consults it through NodeTypeAccessRuleGate. Nothing else widens for a global admin.
Full reference: AccessControl.md → "The Admin partition".
All docs are embedded in src/MeshWeaver.Documentation/ and served under Doc/ at runtime (Data/Architecture/, Data/DataMesh/, Data/GUI/, Data/AI/). Agent and Skill node definitions ship with the AI engine, which lives in MeshWeaver.Plugins (#2276) — not in this repo.
🚨 Read GitHub through REST, not GraphQL. GraphQL exhausts first, and the limit that bites is the SECONDARY one — gh api /rate_limit keeps reporting 5000/5000 remaining, 0 used while every GraphQL call is refused with "API rate limit already exceeded". The primary quota is therefore NOT the signal; that pairing IS the secondary limit. gh pr view --json …, gh pr checks and gh run watch all issue GraphQL, so a watcher built on them takes the whole session's GitHub access down with it, for every concurrent agent. REST does everything a PR watcher needs: gh api "repos/{o}/{r}/actions/runs?head_sha={sha}" filtered on .name for one workflow's status/conclusion; .../commits/{sha}/check-runs and .../commits/{sha}/check-suites for the checks; .../pulls/{n} for state, merged and mergeable_state. Reserve GraphQL for what REST cannot express — merge-queue entries and dequeuePullRequest — and never put one in a loop. 🚨 Fast retries EXTEND a secondary limit: on a refusal STOP for at least 5 minutes, do not retry, and do not switch to a different GraphQL query hoping it is cheaper. Poll no faster than 60 s and run ONE watcher, not several. Two REST caveats, both measured: pulls/{n} answers mergeable: null / mergeable_state: "unknown" until GitHub recomputes after a push — re-read, never read that as a verdict; and a check SUITE an installed App never posts runs to stays queued forever, so wait on the ONE workflow you care about by name, never on all suites.
🚨 The defect you did not come for: FILE AN ISSUE AND MOVE ON. Debugging one thing exposes
another — an error swallowed, a gate never armed, a log line that lies. Ask in order: does it
block the task in hand (then fix it, and say so); is it actively harmful right now (then stop
and tell a person); otherwise it is incidental — file an issue and return to what you were doing.
You are not blocked by it, and turning aside costs twice: the task is abandoned mid-air and the
defect is investigated without the context of whoever owns it. The issue is worth only the evidence
in it — you will never be as close to this again — so it carries the instrument readings with exact
identifiers, how you got there, and what you did NOT establish; an issue that overstates its
certainty sends the next person down a branch you had already excluded. Never file one you have not
checked is real against the DEPLOYED artifact: a stale checkout or a truncated query produces a
confident finding about a defect that does not exist, and someone else pays to disprove it.
🚨 An agent does NOT open a plain ticket — it files into BUG TRIAGE, fleet-wide: a node of
TYPE Feedback/Feedback on the control instance (memex.systemorph.com — the MCP server named
systemorph, not memex), which reaches the triage agent and may become a GitHub issue from
there. Triage decides the repo, the priority, and whether it becomes a ticket at all — you do not.
A ticket you open yourself still reaches the pool (the organisation's Issues webhook feeds every new
issue into the same inbox, Hosting/Triage in MeshWeaver.Plugins), but only AFTER you have picked its
repo and made it public — the two calls triage exists to make, and on the public core repository a
ticket cannot be unpublished. The pooling is the
whole point: ONE inbox per portal, never one queue per repo. 🚨 The NAMESPACE is your OWN
partition: create with namespace: "{user}/Feedback" (the node lands at
{user}/Feedback/<slug>, e.g. rbuergi/Feedback/<slug>). Feedback/<id>,
Feedback/_Submissions/<id> and Feedback/Inbox/<id> answer Access denied: Create permission required by design — that refusal names the wrong namespace, never a closed route, so it is no
reason to drop the finding. File it SUBMITTED — OMIT status (absent means New), never a
Draft — the draft-and-preview stage exists so a person can vet words written in their name, and
an agent reporting its own finding has nothing to preview and usually no chat to preview it in.
Write node paths in the message without an @ prefix, then get the node back by its exact path:
that read is the verdict AND the wake its hand-over waits on. Full rule, the two failure modes,
triage and where the finding goes: Incidental Findings (get Doc/Architecture/IncidentalFindings).
🗂️ ALWAYS conserve work products — a design, an architecture decision, an investigation finding, a manual produced while working gets COMMITTED to this repo in the same change set, every time. The durable form is a doc page under src/MeshWeaver.Documentation/Data/ (Architecture for platform designs; follow AuthoringDocumentation.md). 🚨 A What's New entry is written per RELEASE, not per change (policy whatsnew-cadence) — a merge updates its DOC PAGE and mints no dated file; the release announces what completed and links to that page. The old per-merge rule took the folder to 1,292 entries, ~20/day, which is a commit log rather than a changelog — see Release Process §6 — issue comments, PR bodies, chat replies and rendered artifact pages are pointers to the committed page, never a substitute for it. A finding that lives only in an issue thread or a terminal is invisible to the next session and to the portal; the doc tree ships with the platform. Maintainer directive, 2026-08-30. This rule holds in EVERY repo of the fleet — satellites commit theirs to their own doc home.
🚨 POLICY NOT PROSE — never hard-code a decision's DATE or its AUTHOR into committed source, a comment, an XML doc comment or doc prose. State the rule and cite the policy id; the register in Policy Not Prose carries the date and the name, once. A sentence restated in an AGENTS.md, two docs and a skill is four things to change and three that will be missed — and a name in a comment outlives the role it described. This does NOT cover dates that are EVIDENCE (a measurement, an incident, a historical record): the test is would this date change if the decision changed? — yes means policy marker, no means evidence and it stays. Forward-only: this governs what is written from now on. Do NOT sweep the existing corpus; a reviewer checks whether a diff ADDS one, never whether an old page still has one. On review, an added hard-coded policy date or person's name is a finding, and the fix is a register entry plus a link — never deleting the information.
Writing/editing a doc page: follow AuthoringDocumentation.md. Links resolve against the page's FULL node path at render time — sibling links need ../Sibling, absolute links start /Doc/…; xref: and .md suffixes never resolve. DocumentationLinkIntegrityTest fails on any broken internal link — run it after doc edits (it travels with the Agent/Skill partitions it also validates, so it moves to MeshWeaver.Plugins with the AI engine).
Node FILE formats (every Data/ tree and every node repo): the extension is a convention, not a free choice. Agents and skills are .md — front matter (nodeType: Agent/Skill) carries the configuration, the body IS the instructions; never JSON with an escaped instructions string. C# source nodes are .cs with the // <meshweaver> heading block (// Id:, // DisplayName:, optional // NodeType:); never JSON with the code in an escaped string. .json remains for typed nodes and executable code cells — the .cs header carries ONLY those three keys, so a cell with isExecutable/activityParentPath (or node-level description/order) authored as .cs silently loses its Run button on import.
Hub-handler test hangs or a message disappears: read DebuggingMessageFlow.md first — never rerun a hung test "to see". type 'X' is not registered in this hub's TypeRegistry: the fix is WithType(typeof(X), nameof(X)) on the receiving hub. Use hub.Observe(...), not RegisterCallback/AwaitResponse — those overloads are [Obsolete] and deadlock.
🏗️ THE UNIFIED BUILD PROCESS is Module Build Architecture (get Doc/Architecture/ModuleBuildArchitecture) — one shape, every repo: the platform image is the compiler and the reference set, everything shared stages ONCE per run in the blob-backed actions cache, one Roslyn workspace builds the graph fail-fast, outputs are content-addressed (unchanged ⇒ no compile), gates compile against implementation frameworks, CI logs warn/error + verdicts only, and scripts are centralized (the lane fetches the platform's copy at the pin; repos keep only allow-files). Never hand-roll a repo's build; a deviating repo is behind, not different. WHEN a node repo runs that shape changed on 2026-09-12 (maintainer: "assume upstream dependencies are backwards compatible; do not recompile and run all tests on every platform build; the full run happens once a day"): a node repo builds and tests against the newest SEALED platform set on its own pushes and once a day — its daily schedule IS the full run, and the daily runs are what validates a platform build; the per-build meshweaver-framework-released wave is off by default (Hosting:PlatformBuilds:BroadcastFrameworkReleases, Plugins#1707) and reserved for a MAJOR bump, and no satellite lists the event any more. A repo that does not rebuild per platform build is the RECOMMENDATION, not a deviation. Full reference, incl. the known gap in the compatibility assumption (#4083): Hosting/BuildAndReleaseProcess (MeshWeaver.Plugins; get Hosting/BuildAndReleaseProcess on the memex MCP).
📘 The full manual — what you author, what the build derives, the src/ blind spot, and the closure boundary — is Module Versioning (get Doc/Architecture/ModuleVersioning). Read it before bumping anything.
🚦 Before trusting a green wall or debugging a red: Reading CI Signals (get Doc/Architecture/ReadingCiSignals) — a required context that REPORTED SKIPPED counts as SATISFIED, a red on a non-required check does not block, and the i18n mirror reds every Plugins PR until it lands. A required context counts only when it reads literally =SUCCESS. 🚨 But SKIPPED and NEVER-REPORTED are opposite readings, and only the first is a green-wall trap. A required context with no check-run at all blocks forever under CLASSIC protection ("Expected — Waiting for status to be reported"), while a ruleset treats it as satisfied — so the same absence means "silently unguarded" in core and "structurally stuck" in a satellite. Measured 2026-09-08: MeshWeaver.Plugins#1453 is success on all 106 jobs and still blocked, because adopting the shared lane renamed Validate node repos to validate / Validate node repos and the bare name is never published again. Adopting a reusable lane IS this event — move the contexts in the same change. 🚨 The route that leaves NO unguarded moment is a SHIM JOB carrying the legacy name (name: Validate node repos, needs: [<lane job>], job-level if: ${{ !cancelled() }}, the verdict asserted INSIDE the job so a failed lane goes RED instead of the accepted skipped): the old context keeps being published, so nothing has to be co-ordinated with the other open PRs. The protection-edit route — drop the old name, merge the adopting PR, then add the new name — also blocks nobody, but its middle step leaves the lane required by nobody, so use it only when a shim is impossible and finish step three the same day. Adding the new name FIRST is not a safe pre-step on classic protection: it requires an absent context, which blocks every PR in the repo including the adopting one. Full reference: RenamingARequiredCheck.md.
Anything that touches a user's mailbox goes through the Executive Assistant and that user's own
delegated credential — never a token an agent mints for itself. Email:AgentSend defaults to
DraftOnly, and in that mode the send tools are never handed to the model at all: the agent
has DraftMail/DraftReply, writes into the person's Drafts, and the person presses Send — so
never promise "I'll send it", say what the draft will contain. No mail tool attaches a file (a
message carrying one goes through Share ⇒ as email / SendDocumentDispatch.ExportAndSend with
DocumentDelivery.Attachment, which sends as the user off the same EaCredential). A wrong draft
is AMENDED, never re-drafted — GetDraft → UpdateDraft (a PATCH; the body REPLACES), or
DiscardDraft; two drafts hand the reviewer a choice they should not have to make. 🚨 Both
writers re-read isDraft from Graph INSIDE the write and refuse when it is no longer one: the
person may have pressed Send since the agent read it, and patching or deleting then would mutate
sent mail. A tool answering "I don't have access to your mailbox and calendar yet" is the just-in-time
consent step, not a missing capability: hand the user {BaseUrl}/auth/ea/connect and wait.
Full reference: ExecutiveAssistant.md
(get Doc/AI/ExecutiveAssistant). The plugin itself lives in MeshWeaver.Plugins.
Before you write ANY text a user will read, stop and ask: "how does this render for a German viewer?" This is part of writing the feature, not a follow-up ticket. The portal ships English + German; a hard-coded UI string is a bug — buttons, labels, tooltips, aria-labels, placeholders, page titles, empty states, validation messages, toasts, dialog copy, menu entries, settings tabs, notification text and errors alike.
- On a declaration →
[Translation("de", "…")]beside the existing[Description]. Everywhere else → a key in bothsrc/MeshWeaver.Messaging.Hub/Localization/strings.{en,de}.json, read viaAccess.Localize("key")(Blazor) orhost.Localize("key")(layout areas).LocalizationTestfails if a language is missing any English key. - Prefer text that needs no translation — a language-neutral glyph (➕ ✏️ 🔖 ➡️ 📋 🗑️) plus a translated tooltip beats a translated label.
- 🚨 OWNERSHIP decides the language, and inside authored content the glyph preference is BINDING. Clause 1: platform-/module-owned text (a catalog key, a module text table, a
[Translation]) follows the VIEWER; authored content renders AS AUTHORED; a bare literal compiled into a view is unowned — a bug, never a third category. Clause 2: a platform/module control rendered inside an author's flow (code-cell toolbars, the fenced block's copy affordance, the kernel placeholders,Edu/**) carries NO translated visible label where a glyph, a number or a symbol says the same thing — the localized text moves to the tooltip and the accessible name, which must stay localized (dropping a label without one trades a language bug for an accessibility bug). Never make in-content chrome follow the CONTENT's language: that was shipped, measured and reverted — it hands an English reader of a German course a page they cannot operate. Full reference: ChromeAndContentLanguage.md. - 🚨 A new key has a SECOND home in another repo:
MeshWeaver.Plugins/clients/react/src/i18n/. Deleted and relocated look identical from one repo — assume relocated until you have looked. Its drift guard DOES run (the plugins repo'sRN app + web clients (typecheck + test)job) and it compares VALUES, not just keys — so it catches the divergenceLocalizationTestcannot see. Core is the source of truth: the core catalog change merges FIRST; never "fix" a mirror red by reverting the mirror. 🚨 But the guard compares against a PINNED core commit (clients/react/src/i18n/catalog-source.json), NOT core'smain— so adding a key in core reddens nothing and leaves the mirror silently stale (measured 2026-09-04: pin 70 keys behind, both repos green; recurred as #3596 — 27 keys behind on 2026-09-07 with zero value drift, which is exactly why nothing was red). A core PR that adds a key — or re-words an existing value — must therefore HAND THE SYNC OVER in the PR body, andi18n catalog (mirror sync handed over)now holds you to it:Mirror-sync: <statement>, where the statement names the sync (npm run sync:i18n -- --ref <merged core sha>), names the issue or PR it is tracked on, or waives it (none — <reason>). LikeImplementers:the ordering is INVERTED — the core half lands FIRST — so it asks for a statement, never a merged counterpart; it reads no sibling repo, needs no credential and runs on forks. Ordinary work meets it about one merge in eight (measured over the last 60: 8 change the catalog, all 8 by adding keys) — and all 8 of those left the mirror stale, which IS the debt. Full reference: Localization.md. - 🚨 Never resolve from
CultureInfo.CurrentUICulture/CurrentCulture— this covers date/number FORMATTING too. Resolution is always explicit offAccessContext.Locale(ViewerLocale()). - 🚨 Do NOT translate: LLM tool-parameter
[Description]s (model-facing), wire identifiers (nodeType:Thread,RequestAction("New"), Fluent icon names), or the glossary terms kept English on purpose (Thread, Mesh, Node, Agent, Skill, Harness, Provider, Namespace, Partition, Store).
Full reference: /i18n · Localization.md.
🚨 Operations and diagnostics on a deployment go through the control instance's Hosting API — never the cluster (maintainer, 2026-09-08: "all the operations through the memex api", "no direct access of aks"). An instance is a Deployments/<name> record on the control instance; a roll, restart, suspend, audit or reconcile is a Hosting/InstanceAction node the in-cluster operator executes and reports on. 🚨 The public instance's MCP server is NOT the control instance — the public portal / plugin registry and the control instance are separate meshes, each with its own MCP server. BOTH hold nodes at Deployments/<name>, and only the control instance's copy is authoritative. The public portal's copy is a SECOND sync of the same folder, not the record: the public instance carries its own Deployments/_GitSync for Systemorph/Memex mesh/Deployments (two-way, since 2026-08-30), and measured 2026-09-11 it reports lastSyncOutcome: Imported while its lastSyncCommitSha stays on a commit synced 2026-09-09 and its lastAttemptedCommitSha names a different one, attempted 2026-09-11 with lastAttemptWasFinal: true — a SETTLED hold, not a stall: MayAdvanceBaseline holds the baseline when a two-way import preserves server-newer nodes, and lastAttemptWasFinal is what tells settled (true) from transient (false), so that copy is synced and still disagrees with the record by design; on 2026-09-10 its Deployments/<public-instance> record was version 9 against the control instance's 60. That copy is scheduled for retirement with the GitHub App split. Reading the wrong one and concluding "the sync is frozen" (or "the sync is fine") cost two sessions an hour on 2026-09-10 — confirm the portal (/api/version, or the MCP server's URL) before drawing any conclusion from a read. az aks command invoke / kubectl / Loki-by-curl / a hand helm-release.yml dispatch are break-glass, and a break-glass write is half an operation (the Hosting manual's "Break glass" section lists the other half). Two of the three reads that once had no portal answer NOW DO, and reaching for kubectl for them is no longer break-glass but a mistake: per-replica image/restarts is { "requestedAction": "Sample" } (writes Ops/Status/<id> with replicas[] — image, ready, restarts, started, phase, terminating, generation — plus generations/converged), and a log at time T is { "requestedAction": "Logs", "query": "…", "sinceMinutes": N } (lands a Loki window as Hosting/LogEntry nodes under Ops/Logs with the exact logQl, the count, and whether it was CUT). Both read Prometheus and Loki from the control instance's own pod, where they are credential-free. 🚨 Hosting/LogEntry is therefore the OUTPUT OF ONE Logs QUERY, never a feed — an empty or monotonous Ops/Logs says what was last ASKED there, not what the portal logged; read content.selector for the query that produced the rows you have before concluding anything is missing. The remaining read — "can THIS replica load NodeType X" — is now MOSTLY answered and its instrument is /health, a PUBLIC, unauthenticated, system-side census with its own denominator: it is composed by the process, so it is past RLS by construction and sees what an RLS-filtered search sweep cannot. content-types names every type this replica could not TYPE; bake-report and source-discovery carry the bake and source-discovery verdicts (#3703/#3704). 🚨 Only non-Healthy entries print, so an ABSENT entry is not a clean one — it is equally "the check is not registered in this host" (nodetype_bake is behind if (gateBake)); the two census-tagged entries are the exception and print their reading whatever the status, so "I measured nothing" and "I measured, and it was clean" are two different printed sentences. 🚨 Repeated calls sample DIFFERENT replicas — measured 2026-09-11 on the public instance, 10 calls returned 2 disjoint bodies (3 vs 7 untypable NodeTypes, 6 vs 3 pending modules) — so one call answers about ONE replica you did not choose; the per-replica form with no guesswork is Sample, which carries each pod's whole /health body. 🚨 Only while kube-state-metrics scrapes the namespace, and that FLIPS — measure it, never remember it (#4218): Ops/Status/<id> read notScraped: true with replicas: [] on 2026-09-15 12:57Z and notScraped: false with a row per pod (pod, image, generation, startedAt) on 2026-09-17 18:18Z. While it is true a Sample carries the public host's ONE /health body (healthDetail) and no per-pod roster, so read notScraped on the node before drawing either conclusion. 🚨 A type nobody has opened on this replica is NOT in content-types (it records a degradation only when a read degrades) and the rest of bake-report is a BOOT-TIME reading — the instrument for it is bake-report's LIVE RECORD CENSUS sentence (#4632), refolded from the NodeType catalog on every emission: it names, by partition and framework identity, every record keyed to a framework this replica does not run, and counts APART the ones stamped after this replica booted (a replica on another image re-keyed the type mid-roll — the only count that degrades the entry; the previous image's records are the ordinary state). "NONE taken on this replica" is its own printed sentence, never a clean one. What is still taken read-only and named as break-glass, never written up as the procedure, is anything beyond that. Full reference: OperatingFromThePortal.md.
Two deploy routes, different targets — neither deprecated. Pick by target, don't mix. AKS (a portal on the shared cluster): a code update is the record's image pin + a Roll action (bootstrap form: build image → set image → restart, run by the operator); the cluster is PRIVATE, so whatever still has to reach it does so through az aks command invoke, break-glass only; an env's deploy.sh is first-time ENV SETUP, never a code-update path, and env folders live in the estate's private configuration repository (Instances.md, "Where an estate's instances are declared"). Azure Container Apps (the Aspire test/prod modes, via tools/deploy.sh prod|test): never point these at the AKS cluster.
🚨 Before any AKS deploy, read DeploymentAKS.md end-to-end. Nothing bakes at BUILD time on any machine — BakeMeshLocalFeed was removed (#395) and a legacy #r "nuget:…" hard-fails on a deployed image.
🚨 The database migration is a run-once Job that helm upgrade runs — never a Deployment to roll. The chart defines no such Deployment; a command aimed at one either errors or keeps a cluster-only orphan alive that re-runs the migration forever (#1788). A crash-looping migration pod is a FAILURE, not noise. Before declaring a deploy successful, confirm the Job logged Database migration completed. Version: N AND the portal serves HTTP 200; DbVersionGate stops the app with a LogCritical when the schema is behind, so a portal rolled ahead of its database refuses to serve rather than serving a half-migrated one.
🚨 Verify the IMAGE, never the green tick — read the RUNNING tag: the Fleet Console (/Hosting/Console) shows it per instance; a kubectl get deploy … -o jsonpath read is the break-glass form of the same question.
Full reference: /deployment · Deployment.md (index) · delivery/batching: /release.
Stay at the root of your worktree — never the primary checkout, never a hard-coded path. Avoid chained commands (&&, ||), for loops, and cd — they all require user confirmation. Avoid piping a build or test through tail/head: the pipeline's exit code becomes the pager's, hiding Build FAILED.
dotnet build # Solution (ONE project arg max — several is an MSB1008 no-op)
dotnet build test/MeshWeaver.Data.Test/MeshWeaver.Data.Test.csproj # One project — required before --no-build
dotnet test test/MeshWeaver.Data.Test --no-build # One test project (unbuilt = silent exit 0)
dotnet run --project ../MeshWeaver.Plugins/src/Memex.Portal.Monolith # Monolith → https://localhost:7122
dotnet run --project ../MeshWeaver.Plugins/src/Memex.AppHost # Aspire (Docker) → https://localhost:7202
aspire run --project ../MeshWeaver.Plugins/src/Memex.AppHost # Same, via the CLI (registers with `aspire mcp`)
aspire start --no-build --project ../MeshWeaver.Plugins/src/Memex.AppHost # Background, no rebuild; `aspire ps` / `aspire stop`Changed code in Memex.Portal.Distributed or a project it references? Don't kill the whole AppHost — dotnet watch restarts only the affected resource; the dashboard's Resources → ⋯ → Restart is the fallback; a process kill is the last resort. Full reference: LocalDevWorkflow.md.
Every mesh-node mutation goes through workspace.GetMeshNodeStream(path).Update(current => modified). There is no other mutation surface — do NOT invent one: no SubmitMessageRequest-style wire messages, no completion callbacks via hub.Set<Action<...>>, no bespoke IRequest/IResponse pairs for state changes. Migrate any straggler you touch to stream.Update. The same API works for a node this hub does not own — the write routes to the owning per-node hub as an RFC 7396 merge patch, which the owner serialises, so concurrent writers never clobber each other's fields.
- Writes:
stream.Update(current => current with { … }). State-machine semantics? Set aRequestedXfield and let the owning hub's watcher react. - Reads:
GetMeshNodeStream(path)— server-side AND Blazor, via the process-wideIMeshNodeStreamCache.GetRemoteStream<MeshNode, …>is framework plumbing; never use it for a node by path. - Delete the request type. Writing
class XxxRequestto mutate a thread / message / NodeType? Stop. Add aRequestedXxxfield to the node's content and watch it from the owning hub.
Observing completion: subscribe to GetMeshNodeStream(path) and wait for the state on the node's Content — the GUI databinds the same way, and a test that posts a verb-shaped request and waits for a *Response is testing a deprecated API. Thread and activity operations have canonical IMessageHub extension surfaces (hub.StartThread/SubmitMessage/ResubmitMessage/DeleteFromMessage/MarkThreadDone/RecordSubmissionFailure, with the AI engine in MeshWeaver.Plugins; hub.CancelActivity/RequestActivityStatus in src/MeshWeaver.Mesh.Contract) — every one writes through stream.Update, and there is no other entry point. Per-user work at logon is a LogonAction, never a SQL backfill looping partition schemas; it runs as the USER and lands its effect plus ledger entry in ONE patch. Sanctioned exceptions (NOT state mutations): CreateNodeRequest/DeleteNodeRequest/MoveNodeRequest (lifecycle — they route, they don't mutate content) and transient queries that belong on no node.
Full reference: /mesh-data (which links RequestViaStreamUpdate, MeshNodeStreamCache, ActivityControlPlane, ThreadOperations, LogonActions).
Every framework write primitive (meshService.CreateNode/UpdateNode/DeleteNode/CopyNode, MeshNodeStreamHandle.Update, IMeshNodeStreamCache.Update) automatically carries the caller's AccessContext through .Subscribe() boundaries — keep writing the natural .Subscribe(...) shape. A write that must run as system/hub (legitimate infrastructure only — cache hydration, SyncStream heartbeats) says so explicitly: using (accessService.ImpersonateAsSystem()) { … } or ImpersonateAsHub(hub) / o.ImpersonateAsHub(hub.Address). PostPipeline fails closed when no context is set, and the "silently stamp hub-self as principal" fallback was deleted because it masked a prod bug: application code that writes MUST have a real user identity on AccessService.Context. Full reference: /async · AccessContextPropagation.md.
An instance action (and its approval re-plan), a governance pass, a sync, an operator step, a watcher acting on a request node: check the caller's access EXPLICITLY, then run as System. (1) Validate the WRITE of the request node — its create/update is refused by name, naming the missing permission, so an unauthorized request is never parked. (2) Check AGAIN immediately before executing, for EVERY identity the authority rests on — the requester's right to request AND the recorded approver's right to approve (hub.CheckPermissionOutcome(path, userId, permission); Undetermined fails closed and is reported as could not be checked, never as a denial) — either may have lost the right. (3) Execute under System — composed via access.RunAsSystem(() => …) or, carrying the governed activity and the user served, access.RunAsSystemFor(governedBy, onBehalfOf, () => …), never Observable.Using — for reading the records it acts on, rendering plans and writing results. Never depend on the caller's RLS reach mid-execution: a system-owned record (a GitSynced Deployments/<name>) the caller cannot read must not fail an authorized operation. Impersonation is NEVER a substitute for the check, and this does not widen the rule above — application writes on a user's behalf still carry the user's identity. Policy authorize-as-caller-execute-as-system. Full reference: AuthorizeAsCallerExecuteAsSystem.md.
The user is LITERALLY NEVER OK with async/await/Task<T>/Task.Run/.ToTask()/TaskCompletionSource/.Result/.Wait() in any hub-reachable OR Blazor-view/component code. It runs continuations on the wrong scheduler, deadlocks the single-threaded action block, and NotFound-storms a partition hub until the whole portal wedges. Everything is IObservable<T> end-to-end — compose with .Select/.SelectMany/.Where/.Timeout and .Subscribe(onNext, onError), never await. Handlers, services and layout areas return IObservable<T> (or void), never Task<T>; click actions are WithClickAction(ctx => { …; return Task.CompletedTask; }), never async ctx =>. 🚨 .ToTask() is FORBIDDEN EVERYWHERE, tests included (maintainer, 2026-08-30): a Task completed inside an Rx pipeline resumes its awaiter inline on the signalling thread, still inside Rx's trampoline, and the continuation inherits that — so a "test-only" bridge changes what the test measures. 🚨 And await someObservable is NOT the cure — it is the same defect with nothing to grep for: Rx's own awaiter is an AsyncSubject<T> that completes its continuation from inside OnCompleted, so it resumes inline exactly as .ToTask() does (InlineResumptionMechanismTest measures both). This sentence used to say "await the observable directly with a .Timeout(...)", and that is where 228 sites and one shipped defect came from — MeshTestContext.First carried await source.Take(1).Timeout(Deadline) under a comment offering "Rx's own awaiter — no task bridge" as the safety property. Stay reactive and .Subscribe, assert through MeshWeaver.Reactive.Assertions, or wait through the ONE bridge — .Await(ct) / .ObserveCompletion(reportLateFault, ct), which complete with RunContinuationsAsynchronously. ObservableToTaskBridgeGuard.NoProductionCodeAwaitsAnObservableDirectly holds src/ at zero. The one place a bridge may work is inside an ACTIVITY, and even there avoid it.
🚨 Observable.FromAsync is NEVER tolerated anywhere in src/ — no exceptions, no "storage is the hot path". It runs the prologue on the subscribing thread with no concurrency bound. Every async / blocking / IO edge goes through IIoPool (pool.Invoke / InvokeBlocking / InvokeStream, or pool.Run* for the promise-cached one-shot), resolved from the mesh-scoped IoPoolRegistry. Public surfaces return IObservable<T>, never Task<T>.
🚨 Cold observables: Subscribe is mandatory. Every write returns a cold IObservable<T> — the side effect runs on Subscribe, not on call, so a composed write you never subscribe to silently does nothing (the chat-doesn't-work root cause). Update(...) returns a RequireSubscribeObservable that logs a warning at GC if never subscribed — search the MeshWeaver.Mesh.RequireSubscribe log channel after every CI run.
// ❌ fire-and-forget — cold, so the write never happens
workspace.GetMeshNodeStream().Update(node => node with { … });
// ✅ subscribe with explicit error propagation
workspace.GetMeshNodeStream().Update(node => node with { … })
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "Update failed for {Path}", path));Full reference: /async · AsynchronousCalls.md · ControlledIoPooling.md.
node.Content is MyType / payload as MyType is a TRAP-DOOR. It is correct only when the value already happens to be your CLR type, and yields a silent null in the three cases that actually happen in a running mesh: untyped JSON (the polymorphic converter degrades an unresolvable $type to a raw JsonElement), the as-written JsonObject DOM before the materialization pipeline re-types it, and a same-named type from another collectible assembly (every NodeType recompile mints one). All three look identical from outside — the value reads as absent, the view renders empty, a reactive wait never completes; no exception, no log, nothing to grep. Use node.ContentAs<T>(hub.JsonSerializerOptions) / payload.As<T>(hub.JsonSerializerOptions, logger) instead.
🚨 FIRST, though: deserialize as close as possible to where the type IS registered — a payload read on a hub that never registered the type is untyped by construction, and .As<T>() would be papering over a routing mistake. The durable fix is to move the work to the owning hub, or register the type where the read happens. Full reference: src/MeshWeaver.Mesh.Contract/ObjectAsExtensions.cs · /mesh-data.
Query/ObserveQuery are eventually consistent — stale after writes. Read a specific node from workspace.GetMeshNodeStream(path), which is authoritative and live. Valid query uses: listing children, searching by predicate, autocomplete — anywhere a stale negative is harmless. Wrong: reading content by exact path, reading state before a write, polling for job completion, deciding create-or-skip for a known path. 🚨 Existence of a SPECIFIC path is NOT a valid query use — a query's negative can be minutes old, and where the target id is minted per attempt a stale negative produces DUPLICATE DATA (#2229).
🚨 A node that may NOT EXIST YET needs BOTH halves — a scope:children listing for EXISTENCE, then GetMeshNodeStream(target) for CONTENT. A point read of an absent node is a framework defect, not merely slow: the owner answers a routing NotFound that terminates the stream AND opens the storm-breaker on that path — and the breaker fast-fails WRITES too, so the read suppresses the write it is waiting for. The index trails the store, so "the index has seen it" implies "the store has it". Creating the node anyway? Skip the check and use CreateOrUpdateNodeRequest — 🚨 unless the write is a FOLD (a counter bump, or any value computed off the node's current one), which that verb cannot express: full-instance mode takes Content wholesale and its Patch mode is declared but REFUSED by the handler. There is no correct shape for create-if-missing + fold today (#4928), so name the gap rather than falling back to deciding create-vs-update from the query — that fallback is #1174. How a write is EXPRESSED (C# lambda / JSON Patch + anchored text splice / full entity) is its own decision: ExpressingAWrite.md. This is about ONE known path you are GATING on — summing a SET for display off a scope:children query, content and all, is correct and cheaper than N point reads.
Free-floating words → vector search. A query with bare text tokens (laptop nodeType:Story) on a PG backend with an IEmbeddingProvider routes through the HNSW cosine index automatically; structured-only queries stay on the SQL path.
Full reference: /mesh-data · CqrsAndContentAccess.md · VectorSearch.md.
{baseUrl}/{meshpath} — no /node/ segment, no URL-encoding of separators. Prod: the instance's public origin (e.g. https://portal.example.com); dev Aspire https://localhost:7202 (fallback http://localhost:5202), Monolith https://localhost:7122 (fallback http://localhost:5022).
@/path is a Unified Content Reference for markdown links ([text](@/Path)), autocomplete, and agent tool args — never in href="" attributes or HTTP URLs. Markdig strips @ in native markdown syntax but NOT inside <a href>.
A static field that is a collection or cache is FORBIDDEN anywhere in src/ or test/: no static ConcurrentDictionary, static Dictionary, static HashSet, static List, static ConcurrentBag/Queue, static MemoryCache/IMemoryCache, [ThreadStatic], or static Lazy<…> of mutable data. Process-wide static state survives mesh disposal, so it bleeds across tests — the moment you add a Clear() "for test isolation", that method is the proof of the bug — and across users/partitions in prod.
Every cache and every repository is an instance owned by the mesh. Register it in MeshBuilder (ConfigureServices/WithServices) as a singleton so its lifetime IS the mesh's; hold the backing store as an instance field on that singleton; resolve via hub.ServiceProvider.GetRequiredService<T>(). Allowed static readonly: immutable, read-only constant lookups initialized once and never written at runtime (media-type maps, reserved-word sets, role tables) — the instant something writes to one at runtime it must become a mesh-scoped instance singleton. Full reference: NoStaticState.md.
NEVER use mutable collections. Always System.Collections.Immutable: List<T> → ImmutableList<T>, Dictionary<K,V> → ImmutableDictionary<K,V>, HashSet<T> → ImmutableHashSet<T>, Queue<T> → ImmutableQueue<T>. Exception: ConcurrentDictionary for concurrent mutation — as an instance field on a mesh-scoped singleton, never static.
A set of named values that is persisted, serialised, or extended by a module is a static class of const string, named EXACTLY as the enum would have been — never a C# enum. Keeping the name and the member spellings makes converting a shipped enum a drop-in: TransportKind.Email still reads the same at every call site, and only the declaration and the field type change. Widening a public enum instead breaks every exhaustive switch under -warnaserror — in src/ and in in-mesh NodeType sources that no dotnet build ever type-checks, where it is invisible until a portal compiles it at runtime — and an enum deserialising an unknown value yields the zero member, which is almost always a real, meaningful value (InApp, Person, Running): a silent wrong answer with nothing logged.
🚨 The set stays OPEN. The platform's constants are a starting set, never the permitted set: any module, satellite, deployment or agent declares its own constants class and uses its own values in the same field, with no registration and no change to core. So never validate a value against the platform's own constants (that re-closes it), and never let an unknown value take a default branch that means something (that is the zero-member bug again) — unknown stays unknown, named and logged.
🚨 Resolution is therefore a DURABLE CHAIN OF RULES, never a switch. The rules are mesh nodes — queryable, versioned, editable without a redeploy, and never a static list — each claiming its own values, ordered by an explicit Order field (never registration or load order), with the platform's own handlers as ordinary entries holding no privileged position. The chain is read LIVE so a new rule needs no recycle, and it ends in a loud terminal: an unclaimed value is a reportable fact, never a silent drop. A rule's Handler may be a Code node the backend compiles and caches, so a new value can ship with no deployment at all — which also means its source is invisible to CI and shows up only as compilationStatus on its own node. An enum remains right only for a vocabulary that is never persisted, never serialised, closed by nature, and exhaustively handled in one assembly.
Full reference: OpenVocabulariesAsStringConstants.md · CommunicationHub.md.
Actor-model message hub (MeshWeaver.Messaging.Hub) with address-based partitioning; UI is reactive Layout Areas. The AI engine (agents, threads, skills, the AI plugin surface) is a module hosted in MeshWeaver.Plugins (#2276), not part of this repo. Layout: src/ core framework (50+ projects) · samples/Graph/Data/ sample data nodes · memex/aspire/ Aspire microservices · ../MeshWeaver.Plugins/src/Memex.Portal.Monolith/ the dev portal.
Request-Response: hub.Observe<TResponse>(request, o => o.WithTarget(address)).Subscribe(resp => …, ex => …); the response is sent as hub.Post(responseMessage, o => o.ResponseFor(request)). Fire-and-Forget: hub.Post(message, o => o.WithTarget(address)). Layout area route: @{address}/{areaName}/{areaId}.
Never use IMeshStorage or IMeshCatalog directly — internal infrastructure only.
| Operation | API |
|---|---|
| Read (query) | IMeshService.Query<T>(request) — reactive. 🚨 There is no QueryAsync on the production interface; it survives only as a test-only bridge in MeshWeaver.Fixture |
| Read (single node) | workspace.GetMeshNodeStream(path) |
| Create/Delete | meshService.CreateNode(node).Subscribe(...) / meshService.DeleteNode(path).Subscribe(...) |
| Update | workspace.GetMeshNodeStream(path).Update(current => current with { … }) |
| Move | hub.Observe(new MoveNodeRequest(src, dst)).Subscribe(...) |
Always GetRequiredService<T>() — never GetService<T>() + null check for required services. Full reference: DataAccessPatterns.md.
The memex mesh is reachable through the meshweaver MCP server — wired automatically for agents working on this repo AND for the co-hosted Claude Code / GitHub Copilot harnesses, authenticated as the calling user. 🚨 An MCP server named after a deployment (e.g. a client portal) connects to THAT portal, not necessarily this user's memex — verify which mesh a tool talks to before any mutation. The mesh — NOT a local file tree — is the workspace: use the MCP tools rather than guessing (get/search to read; create/update/patch/move/copy/delete to mutate; execute_script, render_area, navigate_to, upload).
For every MCP mutation, show a diff: get @path before (cache the JSON) → mutate → get @path after → render a ```diff block of the changed region in your response. Read-only tools skip this: get, search, recycle, get_diagnostics, navigate_to, execute_script.
Detailed patterns with code examples: UserInterface.md + GUI docs (layout areas, controls) · MessageBasedCommunication.md · AI docs · ActivityControlPlane.md · AsynchronousCalls.md.
Static handlers for one-shot pipelines — don't extract IFooService for DI cleanliness when there's no state; resolve deps via hub.ServiceProvider.GetRequiredService<T>() inside the static handler. Operations with inputs + progress + output (export, import, compile, mirror) → Code MeshNode template + form-bound inputs + RequestedStatus = Running trigger, not a bespoke XxxRequest/XxxResponse handler.
Key dependencies: .NET 10.0 · Orleans · Blazor Server · Microsoft.Extensions.AI · xUnit v3 · FluentAssertions · Markdig · Chart.js · Azure SDKs.
No mocking. Use MonolithMeshTestBase / HubTestBase and the real Orleans cluster machinery in MeshWeaver.Hosting.Orleans.TestBase — never mock IMessageHub, IMeshService, or core interfaces. Always run_in_background: true for test runs; never --verbosity minimal when tests may fail — it hides stack traces.
Never Task.Delay to wait for propagation. Wait on the actual condition via stream.Where(...).FirstAsync().Timeout(...); for a request/response source wrap the re-query in Observable.Interval(50.Milliseconds()).StartWith(0L).SelectMany(...).Where(predicate).FirstAsync().Timeout(...). Hand-rolled while + Task.Delay(50) poll loops are forbidden. Sanctioned uses: forcing distinct timestamps for sort assertions, and negative "nothing happened" tests with no positive signal to filter for. Never assert "exactly N change events" on a stream backed by pg_notify or any change feed that can race the initial snapshot — filter on the emission shape instead.
🚨 NEVER re-run a test unless code under test has changed. Re-running to "see if it was a flake" hides the bug — flakes are real races. Only exceptions: the harness itself crashed, or the user killed the previous run.
When CI fails, do NOT run entire test projects — read the failed test names from the run, build the one project, then iterate with --filter "FullyQualifiedName~<TestName>" --no-build. No skipping: CI-only failures catch real timing/state bugs. Build first, every time — --no-build/--no-restore against a project this worktree never built exits 0 having run nothing.
🚨 xUnit's methodTimeout (30 s, test/xunit.runner.json) only bounds time spent INSIDE a test method — a wedge in fixture construction, class init or teardown runs unbounded, so always give a local run its own wall-clock cap (background it and hold the deadline yourself; there is no timeout binary here). If a run hits a cap it is stuck — do not raise the bound.
Full reference: /testing · WritingTests.md.
Framework code in src/, tests in test/, samples in samples/. Main branch: main. Solution file: MeshWeaver.slnx (50+ projects). Package management: Directory.Packages.props — update this, not individual .csproj files.