Feature: Collection kinds - #115
Merged
Merged
Conversation
Substrate for templates that organise content into containers — channels,
boards, playlists — without any new content model. Three changes that fit
together, plus a cleanup the investigation turned up.
**Modes, not a kind ontology.** `CollectionBlock.kind` stays a free label
saying what a collection is *for*, invented by whichever template needs it
and registered nowhere. The new `mode` field carries the one fact a consumer
must know: who owns the children. `document` means one agent authored the
whole artifact; `feed` means many agents append independently;
`collaborative` is declared and unimplemented so a third regime never has to
migrate out of a two-value assumption.
A kind→mode registry was built first and thrown away. It is wrong in a
peer-to-peer system: the answer would depend on which modules the *reading*
client installed, so of two agents in one channel the one missing the module
is unprotected. Written on the record, the fact travels with the data.
**The reconcile guard.** `reconcileBlocks` assumes the incoming tree is the
whole truth — true of a re-saved artifact, false of a container many agents
append to, where its orphan pass deletes everything the editing agent's tree
omits. It now refuses any mode that is not `document`, checked before the
transaction opens so a refusal touches nothing. An absent mode passes: every
post predating the field has none.
**Anchoring.** `createBlocks` takes `{ kind, mode, anchor }`; the anchor is a
raw `{ id, predicate }` applied to the root only. One composer path now
serves a post (unanchored), a channel message (`we://children`) and a reply
(`we://comment`) — `we://comment` being an edge that has existed on WeNode
since the beginning with no consumers. `createPost` grows the matching
options, and stays the only store change.
**Mentions.** `WeNode.mentions` + extraction from `mention` inline nodes, so
"posts mentioning me" is a graph query rather than a substring scan of
textContent — handles are mutable and not unique, so a text scan matches the
wrong agent and misses renamed ones. Reconciled by diff on edit, which is
the one relation here where read-modify-write is correct: the author owns
the text, so there is no second writer to race.
**Cleanup: six dead properties off CollectionBlock.** `display`, `columns`,
`gap`, `direction`, `format` and `indent` looked like a collection's layout
and were not. BlockRenderer renders purely from the editorState blob, so the
authored layout has always lived there and these were a redundant projection
nothing read. Two had never held a value at all: extractBlockData copies
serialized-node properties by *name*, and the Lexical node calls them
`layout` and `columnCount`. No migration — orphan links for the three that
were written stay inert, and nothing reads them.
What remains is two groups and a rule: container fields (children, kind,
mode, title, description, textContent) true of every collection, and
document fields (editorState, type) true only of a composition. Nothing new
joins the second, and kind-specific config waits for content-model shapes.
Tests: 38 in block-shared covering the guard (including that a refusal
leaves children intact), anchor placement, mode defaulting, and mention
extraction/dedup/reconciliation. app-shell 260 and transcribe 33 still green.
Plan: notes/we/August-2026/collection-kinds-plan.md, stages A–C and E.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-agent-private-state gap, scoped to its two forcing cases. Both are
tier-1 infra records in the root dataset — the `SpacePreference` rule, and
for the same reason: written into a space they would sync one agent's read
positions, and their mute list, to every member of that community. The
second is worse than a leak, since it would tell the muted person.
**ReadMarker** { nodeId, spaceUuid, lastReadAt }. One record per node read,
keyed by node rather than a per-space map, so two tabs switching channels
cannot lose each other's write — a map is one property rewritten every time,
which is the read-modify-write race `WeNode.participants` documents.
`lastReadAt` is ISO-8601 UTC because the comparison happens as a string
(`createdAt gt marker` pushes down as a scalar compare), and that is the
format where lexicographic order is chronological. A local-offset variant
sorts wrongly and does it quietly.
Framing worth keeping past this stage: a notification in a local-first
system is a standing query whose new results I have not seen — (query,
marker, delta). This is the marker. An unread dot and "3 unread mentions"
are the same shape with different queries, so the notification engine later
generalises this rather than being invented beside it.
**MutedAgent** { did, description }. Safety rather than social graph, which
is why it does not wait for follows or cross-space feeds — it needs neither,
being a filter over whatever a feed already returns. Landing it now is
deliberate: a feed either carries the filter from its first fragment or none
of them do. Global rather than per-space, because the case a per-space list
would fail is exactly the common one — someone unpleasant in one community
turning up in another. One record per mute, so mute and unmute are an
independent create and delete.
Not moderation and not enforcement: this hides content on one screen. An
AD4M neighbourhood is writable by every member, and community-level removal
needs governance plus the validation layer under it.
Store: `markRead` (silent on failure — a lost marker is a stale dot, not
worth interrupting what the user opened the channel to do), `setAgentMuted`
(reports failure — it was asked for deliberately, and believing you have
muted someone when you have not is worse than knowing it failed), plus
`readMarkers` keyed by node id for indexing inside `$each`, and `mutedDids`
for the feed filter.
Known limit, deferred not overlooked: the root dataset is this device's, so
read positions do not follow the agent across machines. Promoting them is
the AgentSettings decision, and a marker that syncs needs conflict rules —
"latest wins" is only obviously right until someone marks a channel unread
on purpose.
Plan: notes/we/August-2026/collection-kinds-plan.md stage H.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The arrangement layer over the collection substrate. Six fragments, plus the
one operator gap and one store action they turned out to need.
**collectionFeed** — the shape every container surface is: the collections of
some kind, inside a parent or loose in the space, with something to say when
there are none. Carries three things a hand-written `$each` keeps getting
wrong: the `scope` drill-down form (`children` is untyped, so `include` is
unavailable and this is the only shape that works), the loaded gate, and the
mute filter.
The mute filter is unconditional and in the *query*, not over the rendered
rows. Filtering after the fact makes a page of twenty show fifteen when five
are muted, because the limit was already applied — a hole in the page rather
than a shorter list.
**commentThread** — replies over `we://comment`, recursive. Bounded at
authoring time because a schema is a finite tree; past the limit it shows a
count, since a thread that simply stops looks finished to someone whose reply
is below the cut.
**channelRail** — channels, optionally grouped by category, with unread dots
from the read markers. Marking read is wired to the *navigation*, not the
feed's mount: a feed that marks on mount also marks on every re-render the
router does, clearing dots nobody saw.
Fixed a bug while writing it, which also corrected the store: the dot needs
this row's marker, and `{ $store: 'spaceStore.readMarkers.<context ref>' }`
cannot express that — `$store` resolves a *static* dot path, so the ref is
taken literally and resolves to nothing, reading as "never opened" and
leaving every channel permanently dotted. `readMarkers` is now an array read
with `$find`, and the docstring claiming a schema could index a keyed map is
gone; it could not.
**kanbanBoard** — board → columns → cards, all containment. Records the
decision that **containment expresses status**: `TaskBlock.status` and a
board's columns say the same thing, this picks columns, and a template using
both will get the disagreement two sources of truth always produce.
**mediaGrid** — the same posts a feed renders as a timeline, drawn as a grid,
which is the triptych demo in one fragment. Cover images come from a
`$firstImage` single-item projection, so one query yields posts and images
without a fetch per tile.
**loadMore** — paging by raising a `$local` limit. Honest about the cost: each
page refetches everything before it, which is fine for hundreds and wrong for
tens of thousands. Cursor paging (`Page.after` already exists in the IR) plus
windowing is the real fix.
**`$setLocal: { by: n }`** — the schema layer had *no arithmetic at all*, so
"show 20 more" was inexpressible and a paginated list could not advance past
its first page. Added as a form of `$setLocal` rather than a general `$add`
operator: the need is bumping a counter, and general arithmetic in schemas
invites computing layout values, which is what tokens and DS props are for.
Non-numeric current value counts as 0 — NaN would reach a query limit and
empty the list with nothing to point at.
**`spaceStore.moveChild`** — a card between columns as a relink of two
`we://children` edges. Add before remove deliberately: both are separate
round trips, so a failure between them leaves the card in two columns rather
than none — visible and fixable, where the other order loses it somewhere no
view lists.
Plan: notes/we/August-2026/collection-kinds-plan.md stages D, F, G.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… bundled
Six applications over one universal container, plus the distribution
mechanism that lets a deployment choose which of them it ships.
## The templates
Discord-shaped channels, a Timeline/Photos/Videos triptych, kanban boards,
and events. **No new content model for any of them.** Every container is a
`CollectionBlock` with a `kind` label the showcase package invented and a
`mode` saying who owns its children, so a template dropped on a space that
has been collecting posts for a year works retroactively — no SDNA change,
no module install, no migration.
The triptych is the argument: Timeline, Photos and Videos read *the same
records*. Switching between them turns a feed into a photo grid into a video
library over one space, which is data/interface separation demonstrated
rather than asserted.
Kanban is in the set deliberately — five of six render conversation, and
without one that renders work the showcase accidentally argues that this is
a social-media engine. Events earns its place by needing nothing new:
`participants`, `EventBlock`, `Calendar` and `LocationBlock` were all built
and undemonstrated.
Each is honest about a limit rather than faking past it: no private channels
(a neighbourhood is writable by every member, so a collection is not a
boundary — the empty state says so where a lock icon would lie), no video
uploads, no cross-space feed, no manual ordering.
## Distribution
`templateRegistry` was a hardcoded object with one live entry and a
commented-out second one — which is what happens when the only way to add a
template is to impose it on every deployment. The seed could already select
`modules`; templates, the highest-volume contribution type in the system,
were the missing half.
Now: sources in-repo (typechecked, walked by the schema validator, so a
renamed prop breaks the build), `WeSeedFile.templates` selecting them, and
`generate-templates` writing the registry from that list at **build time**,
so an unselected template leaves the import graph rather than being filtered
at boot. Runtime filtering would hide a template while still shipping it.
`publish-templates` derives a marketplace bundle from the validated source.
It writes JSON and stops there rather than talking to a backend: publishing
is an authored act by a specific agent into a specific space, and a script
that could overwrite a shared space's templates from CI is a bad idea
however careful it is.
## Three bugs the templates surfaced
**`limit` rejected tokens.** Typed and validated as `number`, while the
renderer deep-resolves query params — so `limit: { $local: 'pageSize' }`
worked at runtime and failed validation, making every paginated list
unvalidatable. `scope.anchorId` already carried the allowance.
**`$plural` was missing from the validator's token union.** Documented,
resolvable, and rejected in any schema that used one. It survived because
the only fragment emitting one had no validated caller — this union is a
second hand-maintained list of the dispatcher's operators, and it drifts
exactly this way.
**`$setLocal: { by }` needed its zod branch** to match the resolver added
with the kit fragments.
Also: `spaceStore.setAttending` for RSVPs, writing only this agent's own
entry — the add-only rule from `WeNode.participants`, which is what keeps a
roster conflict-free without coordination.
Verified: `pnpm lint`, `lint:css`, `-r typecheck`, `-r test` and all 27
schemas validate clean.
Plan: notes/we/August-2026/collection-kinds-plan.md stages I and J.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator emitted unformatted output, so `lint --fix` rewrote the committed file and the next regeneration then differed from it — CI's generated-file diff check would fail on a file nobody had edited. Formats before writing, following `@we/ai-context`'s generator. The repo's other convention is to put generated sources under `src/generated/**` and lint-ignore them; formatting is the better half of that trade for a file this small, since it stays readable in review. Verified idempotent: regenerating the manifest, the template registry and the ai-context outputs now leaves a clean tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… revert Dead leftovers. The kind registry was replaced by mode-on-record, which removed `ModuleDefinition.kinds` from the contract — but the `kinds: [...]` blocks in notes and transcribe stayed, so both modules declared a field that no longer exists. Their collections already write `mode: 'feed'` directly, which is the whole mechanism now; nothing was lost with the declarations. Missed because neither package has a `typecheck` script, so their types are only checked by tsup's dts step during `pnpm build` — and the earlier verification ran lint, per-package typecheck, tests and schema validation but not a full build. `pnpm build` now passes across all 30 packages including the three app targets, and is worth running before calling this kind of change green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… scope Two bugs from actually using the Discord template, plus the validator gap that let the second one ship. **Posting a message failed** with `Cannot read properties of null (reading 'type')` from inside `persistNode`. `BlockComposer.onSave` does not fire when the user types or when the modal closes — it fires when somebody calls the composer's own `save()`, which it hands out once through `onReady`. The showcase composer skipped that handshake and read a `draft` local nothing ever wrote, so every post was `createPost(null)`. Now wired the way the default template's composer already does it: `onReady` stores `save` in a function-typed local, the button calls it with `$callLocal`, and the action runs inside `onSave` with the serialized tree as `$arg`. Worth noting how invisible the broken version was — it typechecked, validated, rendered, and failed only on submit, several frames from the cause. **Signal controls silently never appeared**, with only a `field "signalTypes" not declared` console line. `buildRoutes` renders each route through its own `RenderSchema` call, so a route subtree inherits *no* context: `$queries` and `$localState` on the template root are invisible below a `$routes` outlet. Four templates hoisted `signalTypes` onto the root; Timeline's feed genuinely needed it, and the other three declarations were unread. Declarations now sit in the routes that use them. **The validator agreed with the mistake**, which is why it shipped: it carried the parent scope across the route boundary, so it approved reads that resolve to nothing — failing in the one direction a validator must not. It now resets scope at each route, using an *empty* set rather than `null`, because those mean different things here: `null` is "unknown", which is how a standalone fragment is judged so that reading a local its eventual parent declares is not an error, while below a route nothing is unknown. Confirmed against the regression: reintroducing the Timeline bug now produces three errors pointing exactly at the runtime warnings, and all 27 existing schemas still validate clean, so the stricter rule finds no false positives. Four tests cover it, with the reads in `props` because that is where the real ones were — and because a token sitting directly in a `children` array is not walked at all, a separate gap noted but not addressed here. Verified: `pnpm build`, `lint`, `lint:css`, `-r typecheck`, `-r test`, 27 schemas. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composer handshake was right but undiscoverable, and two checks that
should have caught the mistake were themselves broken. Fixing the ergonomics
without fixing the checks would just move the next instance somewhere else.
**One composerModal, in the kit.** There were two hand-written copies of the
save handshake — the default template's `postComposerModal` and the
showcase's — which is the drifting-twins pattern the kit's CONVENTIONS name
as an extraction trigger, well past the three-use threshold. Earlier I argued
it belonged in the showcase because it "encodes these templates' decisions";
that was wrong. The thing making it showcase-specific was hardcoding
`createPost`, which is exactly the parameter to lift out.
The action is now a `saveAction` with `'$arg'` marking where the serialized
tree goes — a placeholder the caller positions rather than an argument
appended for them, because the tree is not always last: `updatePost(id, json)`
takes it second, `createPost(json, options)` first. An implicit append would
silently serve one and corrupt the other.
The fragment carries its own `$if`, so it mounts only while open — which is
what resets the composer between uses. Both call sites dropped their wrapper.
**The validator refuses `onSave` without `onReady`.** The composer is
pull-based, so that pairing is a handler nothing will ever call — and since
`onReady` is optional, the composer falls back to rendering a floppy-disk
button of its own, leaving two buttons of which only the unexpected one
works. A hardcoded component rule, because the constraint is a relationship
between two optional props and no manifest can express it.
**Tokens in `children` arrays are checked at last.** `children` legitimately
accepts tokens — a `$plural` count-noun label is written that way — but a
token has no `type`, so walking it as a node dropped it into the
grouping-node branch, which looks for routes and children and finds neither.
Every store path, action name and `$local` reference inside a token in a
children array went unexamined: move the same expression from a prop into
children and the validator stopped having an opinion. Found while writing the
route-boundary tests, when the first attempt passed against known-buggy code.
**Lifecycle actions resolve with `noMemo`.** `onSuccess`/`onError`/`onFinally`
run from a promise callback, and event-handler arrays run from the click —
both outside any reactive owner, so passing Solid's `createMemo` created
computations outside a root that are never disposed. A lifecycle array is
evaluated once, at a moment that has already happened; there is nothing for a
memo to be for.
That also fixes a quieter bug: a memoized argument arrives as an accessor,
and `$action`'s relative-path branch tests `typeof resolvedArgs[0] ===
'string'` before `deepUnwrap` runs — so
`onSuccess: [{ $action: 'routeStore.navigate', args: [{ $concat: … }] }]`
skipped relative-path resolution entirely. Absolute paths survived; relative
ones did not.
**Documented** in `ai-context/src/fragments/patterns.ts`, so the handshake
reaches CLAUDE.md as a house pattern rather than being discovered from a
stack trace — including what the wrong spelling looks like and why it fails
so far from its cause.
14 new tests. Verified: `pnpm build`, `lint`, `lint:css`, `-r typecheck`,
`-r test` (1,400+), 27 schemas, and no drift from regenerating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A showcase template that renders in the wrong palette undersells itself, so a template can now name a theme in `meta.themeId` and be seen in it. **Resolved, never written.** The suggestion is a rung in the theme chain, not a value applied on switch. That is the whole design: switching template changes the look without overwriting anyone's stored choice, switching back restores what was there by recomputation, and the opt-out is one boolean in the resolver rather than a setting that has to unwind writes. Applying on switch would have meant A→B→A silently keeping B's theme, and a user who had deliberately picked one getting it replaced. **Precedence turns on who chose the template, not on layer** — the correction that came out of review, and the case the first ordering got wrong. A space's default theme was chosen *alongside its default template*; they are a pair. Ranking the space's theme above the suggestion unconditionally meant any space that had set one would never show a template's theme at all: overriding to Channels left you in the palette somebody picked for the Cards layout, so the feature did nothing in precisely the spaces that had configured themselves. Now: - an explicit pin always wins — the only way to say "this theme here, whatever else changes", and therefore also what stops template switching moving the theme in that space - `AGENT_DEFAULT` is explicit too, so a template cannot reinterpret it - the space chose the template → space theme, then suggestion, then global - the agent chose it → suggestion first, since the space's theme is a leftover from an interface they just replaced; the space's theme still backs it up, so a community's look survives a template with no opinion No new state — `SpacePreference.templateId` already records which happened. The rule lives in `resolveSpaceTheme`, a pure function, because it is the feature and everything around it is plumbing. 14 tests, including the reversibility property and the case the first design broke. `meta.themeId` rather than only `Template.themeId` (which existed, was written by `saveTemplate`, and was read by nothing): built-in templates have no model record, and in `meta` the suggestion travels with a fork, a publish and a `?template=` link. A suggestion naming a theme the agent lacks is reported once and ignored, matching how `?theme=` in a share link degrades. `useTemplateTheme` defaults on — read as `!== false` so an agent who has never decided gets the useful behaviour rather than the absent one — with a switch in Settings beside the theme-scope one. Themes: the Timeline/Photos/Videos triptych deliberately share `dark`, since those three exist to show one space rendered three ways and the theme must not be a second variable moving at the same time. Channels takes `dark`, Boards `light`, Events `retro`. Verified: `pnpm build`, `lint`, `lint:css`, `-r typecheck`, `-r test`, 27 schemas, no drift from regenerating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ring Applying a template did not change the theme. Two paths change the template and they write different places: `spaceStore.setSpaceTemplateOverride` writes `SpacePreference.templateId`, while the template *switcher* calls `templateStore.switchTemplate`, which writes `AgentSettings.currentTemplateId` and never touches the preference. The resolver keyed on the preference, so on the switcher path — the one people actually use — it reported "the space chose this template" and computed the suggestion from the space's *default* template rather than the one just applied. With the Default template suggesting nothing, that resolved to empty and the theme stayed put. Now both the suggestion and the pairing come from `templateStore.currentTemplate`, which is what is on screen however it got there. `templateIsSpaceDefault` replaces the raw override string in `ThemeResolutionInput`, and it is a better statement of the rule anyway: the question was never "which field was written", it was "is the template I am looking at the one this space's theme was chosen alongside". `currentTemplate` is a `createStore` proxy, so reading `.id` and `.meta.themeId` inside the theme effect tracks them and a switch re-runs it. It is a single global, so `templateThemeFor` returns nothing for any space that is not on screen — right rather than merely safe, since the only caller passing another uuid is `setSpaceThemeOverride`, which is writing a pin that outranks the suggestion regardless. For that case the pairing falls back to the preference field, which is the best available answer off-screen. Two tests added for the switcher path specifically; 16 in total. Verified: `pnpm build`, `lint`, `lint:css`, `-r typecheck`, `-r test`, 27 schemas. Theme ids checked against `themeRegistry` — light/dark/retro all resolve, so none of the six suggestions will silently fall through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prop dispatcher and `zPropToken` are two hand-maintained lists of the
same thing, and nothing compared them. They drift in one direction: an
operator the dispatcher resolves but the union rejects means a schema that
renders correctly fails to validate — or gets written around a validation
error that was never real.
Both had already happened. `$plural` was documented, resolvable and absent
from the union, so any count-noun label was an error; the only fragment
emitting one had no validated caller, so it went unnoticed for months.
Token-valued `limit` was the same shape — the renderer deep-resolves query
params, but the schema said `number`, making every paginated list
unvalidatable. This branch hit both.
Reads the source deliberately: the drift is textual, and neither side
exposes its set at runtime — the dispatcher is an if-chain and the union is
a zod type. Comparing behaviour instead would need a valid instance of every
token, which is a third hand-maintained list and the same problem again.
Checks both directions, plus that `ZOD_ONLY` (currently just `$query`, which
the *renderer* resolves rather than `resolveProp`) cannot rot into a stale
excuse, plus that the extraction found a plausible set — the real failure
mode of a source-reading test is a regex matching nothing and passing for
the wrong reason.
Which it duly did on the first attempt: stripping comments turned out to be
load-bearing rather than tidy, because the doc comment above `zPluralToken`
contains `{ $plural: … }` as an example, so deleting the token from the
union still left `$plural` in the extracted set. Verified by injecting both
drifts and watching the right test fail with the right message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Collection kinds, showcase templates, and seed-selected distribution
Summary
Six showcase templates — a Discord-shaped Channels space, a
Timeline / Photos / Videos triptych over one set of posts, a kanban Boards template and an
Events template — built to demonstrate the range of the template system. No new content model
was added for any of them. Every container is a
CollectionBlockcarrying a free-textkindanda
mode, so a template dropped onto a space that has been collecting posts for a year worksretroactively: no SDNA change, no module install, no store registration, nothing to migrate.
Getting there needed a small amount of substrate — composer anchoring, a reconcile guard, mention
edges, per-agent read/mute state, a set of kit fragments — plus the distribution mechanism that lets
a deployment choose which templates it ships. The templates then acted as the first real load on all
of it and surfaced several pre-existing holes, which are fixed here too.
This is stage 0 of
notes/we/August-2026/content-models-plan.md: the bet that composition overthe existing block vocabulary covers real applications before the constraint machinery exists. The
plan doc (
notes/we/August-2026/collection-kinds-plan.md) carries the full reasoning, the scopingdecisions and what was deliberately deferred.
Changes
Collection modes —
kindstays a free labelpackages/block-system/shared/src/modes.ts,packages/models/src/blocks/CollectionBlock.tsCollectionBlock.kindis a free string describing what a collection is for, invented by whichevertemplate needs it and registered nowhere. The new
modefield carries the one fact a consumer mustknow: who owns the children.
documentmeans one agent authored the whole artifact;feedmeansmany agents append independently;
collaborativeis declared and unimplemented so a third regimenever has to migrate out of a two-value assumption.
A kind→mode registry was built first and deleted. It is wrong peer-to-peer: the answer would
depend on which modules the reading client had installed, so of two agents in one channel the one
missing the module is unprotected. Written on the record, the fact travels with the data and a
client that has never heard of the label still knows what it may not do.
The reconcile guard
packages/block-system/shared/src/serialization.tsreconcileBlocksassumes the incoming tree is the whole truth — true of a re-saved artifact, falseof a container many agents append to, where its orphan pass deletes everything the editing agent's
tree omits. It now refuses any mode that is not
document, checked before the transaction opens soa refusal touches nothing. An absent mode passes: every post predating the field has none.
Composer anchoring, and mentions
packages/block-system/shared/src/serialization.ts,packages/models/src/WeNode.ts,packages/app-shell/.../SpaceStore.tsxcreateBlockstakes{ kind, mode, anchor }, the anchor being a raw{ id, predicate }applied tothe root only. One composer path now serves a post (unanchored), a channel message
(
we://children) and a reply (we://comment) — the latter an edge that had existed onWeNodesince the beginning with no consumers.
createPostgrows matching options and remains the onlystore change of its kind.
WeNode.mentionsplus extraction frommentioninline nodes, so "posts mentioning me" is a graphquery rather than a substring scan of
textContent— handles are mutable and not unique, so a textscan matches the wrong agent and misses renamed ones. Reconciled by diff on edit, the one relation
here where read-modify-write is correct: the author owns the text, so there is no second writer.
Six dead properties removed from
CollectionBlockdisplay,columns,gap,direction,formatandindentlooked like a collection's layout andwere not.
BlockRendererrenders purely from theeditorStateblob, so the authored layout hasalways lived there and these were a redundant projection nothing read. Two had never held a value at
all:
extractBlockDatacopies serialized-node properties by name, and the Lexical node callsthem
layoutandcolumnCount. No migration — orphan links for the three that were written stayinert.
What remains is two groups and a rule: container (
children,kind,mode,title,description,textContent), true of every collection; and document (editorState,type),true only of a composition. Nothing new joins the second, and kind-specific configuration waits for
content-model shapes.
Per-agent private state
packages/models/src/entities/ReadMarker.ts,MutedAgent.tsBoth tier-1 infra records in the root dataset — the
SpacePreferencerule. Written into a spacethey would sync one agent's read positions, and their mute list, to every member of that community;
the second is worse than a leak, since it would tell the muted person.
ReadMarkeris keyed per node rather than a per-space map, so two tabs switching channels cannotlose each other's write.
lastReadAtis ISO-8601 UTC because the comparison happens as a string andthat is the format whose lexicographic order is chronological.
Framing worth keeping past this PR: a notification in a local-first system is a standing query
whose new results I have not seen — (query, marker, delta). This is the marker. An unread dot and
"3 unread mentions" are the same shape with different queries, so the notification engine later
generalises this rather than being invented beside it.
MutedAgentis safety rather than social graph, which is why it does not wait for follows orcross-space feeds — it is a filter over whatever a feed already returns. Landing it now is
deliberate: a feed either carries the filter from its first fragment or none of them do.
Kit fragments
packages/templates/kit/src/lists/,overlays/composerModal.tscollectionFeed,commentThread,channelRail,kanbanBoard,mediaGrid,loadMore, andcomposerModal. These are where "just works" lives: they carry thescopedrill-down shape (whichis the only form available, since
childrenis an untyped relation andincludecannot be used),the loaded gate, the empty-state copy, and the mute filter — which is unconditional and applied in
the query, since filtering rendered rows would make a page of twenty show fifteen when five are
muted.
composerModalreplaced two hand-written copies of the composer save handshake, one in the defaulttemplate and one in the showcase. Its
saveActiontakes'$arg'as a placeholder the callerpositions, because the serialized tree is not always last —
updatePost(id, json)takes it second,createPost(json, options)first.Supporting store actions:
moveChild(a kanban card between columns as a relink of twowe://childrenedges; add before remove, so a failure between the two round trips leaves the cardin two columns rather than none) and
setAttending(an RSVP that writes only this agent's own entry— the add-only rule that keeps
participantsconflict-free).Distribution — seed-selected, build-time bundled
we-seed.json,packages/app-shell/scripts/,templateRegistry.tstemplateRegistrywas a hardcoded object with one live entry and a commented-out second one, whichis what happens when the only way to add a template is to impose it on every deployment. The seed
could already select
modules; templates — VISION's highest-volume contribution type — were themissing half.
Now: sources in-repo (typechecked and walked by the schema validator, so a renamed prop breaks the
build),
WeSeedFile.templatesselecting them, andgenerate-templateswriting the registry fromthat list at build time, so an unselected template leaves the import graph rather than being
filtered at boot. Runtime filtering would hide a template while still shipping every byte of it.
publish-templatesderives a marketplace bundle from the validated source. It writes JSON and stopsthere rather than talking to a backend: publishing is an authored act by a specific agent into a
specific space, and a script that could overwrite a shared space's templates from CI is a bad idea
however careful it is.
Templates can suggest a theme
packages/app-shell/src/shared/themeResolution.ts,TemplateMeta.themeId,AgentSettings.useTemplateThemeA template names a theme in
meta.themeIdand is seen in it. Resolved, never written — thesuggestion is a rung in the theme chain, so switching template changes the look without overwriting
anyone's stored choice, switching back restores by recomputation, and the opt-out is one boolean in
the resolver rather than writes to unwind.
Precedence turns on whether the template on screen is the space's default, not on which
preference field was written. Two review corrections got it there, and both are worth knowing:
that matters most, since a space's default theme was chosen alongside its default template, so
any space that had set one would never show a template's theme at all.
SpacePreference.templateId, missing that the switcher writesAgentSettings.currentTemplateIdinstead. On the path people actually use it reported "the spacechose this" and the suggestion never applied.
The rule lives in
resolveSpaceTheme, a pure function with 16 tests, because it is the feature andeverything around it is plumbing.
Pre-existing bugs the templates surfaced
Each was found by the templates being the first real load on the machinery:
limitwas unvalidatable. Typed and validated asnumberwhile the rendererdeep-resolves query params, so
limit: { $local: 'pageSize' }worked at runtime and failedvalidation — making every paginated list unvalidatable.
$pluralwas missing from the validator's token union. Documented, resolvable, and rejectedin any schema that used one. It survived because the only fragment emitting one had no validated
caller.
list could not advance past its first page. Added
$setLocal: { by }— a form of$setLocalrather than a general
$add, since the need is bumping a counter and general arithmetic inschemas invites computing layout values.
buildRoutesrenders each routethrough its own
RenderSchemacall, so a route subtree inherits no context — but the validatorcarried the parent scope across and therefore approved reads that resolve to nothing, which is
the one direction a validator must not fail in.
childrenarrays were not checked at all. A token has notype, so walking it as anode dropped it into the grouping-node branch; every store path, action name and
$localreference inside a token in a children array went unexamined.
onSuccess/onError/onFinallyrun from a promise callback and handler arrays from the click, both outside any owner,so passing
createMemoproduced computations that are never disposed. NownoMemo. This alsofixed a quieter bug: a memoized argument arrives as an accessor, and
$action's relative-pathbranch tests
typeof resolvedArgs[0] === 'string'beforedeepUnwrapruns, soonSuccessnavigations with a
$concatargument skipped relative-path resolution entirely.BlockComposerwithonSaveand noonReadyis now a validation error. The composer ispull-based, so that pairing is a handler nothing can call — and since
onReadyis optional, thecomposer falls back to rendering a save button of its own, leaving two buttons of which only the
unexpected one works.
of the same thing, with nothing comparing them; the first two items above are both instances. A
test extracts each side and fails in either direction.
Known follow-ups
Recorded in the plan doc's §6, deferred by decision rather than overlooked:
children—includeover children (a channel list with counts and previews in onequery). Blocked on the self-reference cycle.
positionscalar written nowwould be a shape that design supersedes. GraphView's
manuallayout is complete and unused,waiting on the same thing.
loadMorerefetches each page's predecessors, which isfine for hundreds and wrong for tens of thousands. Cursor paging (
Page.afterexists in the IR)plus windowing is the real fix.
VideoBlockis url + provider; video uploads need chunked, streamed storage.the validation layer beneath WE. The Channels template says so where a lock icon would lie.
documentnorfeedcan hold. Themodeenum is open so a third regime needs no migration.ChatSession/ChatMessage— the one standing violation of container-not-content. Blockedon the
semanticRolevocabulary decision, and its first real customer.kind: 'post'backfill — lets the Default template's reads move offtype: 'root'.childrenarray is still not scope-checked by name — the fix abovecovers store/action/
$localreferences inside it, but the array position itself has no separaterule.
Test plan
Automated, all run against this branch:
pnpm build— 30 packages including the electron, tauri and web targetspnpm lint,pnpm lint:css— cleanpnpm -r typecheck— cleanpnpm -r test— 1,725 passing. New: 38 in block-shared (reconcile guard including that a refusalleaves children intact, anchor placement, mode defaulting, mention extraction/dedup/reconcile),
22 in schema-shared semantic validation (route-boundary scope, children tokens, the composer
handshake), 5 for
$setLocal: { by }, 2 for lifecycle dispatch, 4 for operator parity, 16 fortheme resolution
pnpm --filter @we/schema-shared validate— 27 schemas, no issuesclean
Negative tests, run by injecting the bug and confirming the right failure:
warnings it caused
$pluralfrom the zod union, and adding a zod-only token, each fail the parity test withthe appropriate message
Manual, in the browser:
Not verified, and the reason this wants a careful first run: the SDNA shape changed — six
properties dropped from
CollectionBlock,modeadded,mentionsadded toWeNode, and two newroot models. Reads go through declared properties, so an installed shape carrying extra ones should
be inert, but that needs a pre-existing space to confirm and cannot be checked without a running
executor.