Skip to content

feat(agents): generic externally-writable custom collections (comments as first consumer)#4551

Open
balegas wants to merge 35 commits into
mainfrom
vbalegas/custom-state
Open

feat(agents): generic externally-writable custom collections (comments as first consumer)#4551
balegas wants to merge 35 commits into
mainfrom
vbalegas/custom-state

Conversation

@balegas

@balegas balegas commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

An alternative to #4529 that delivers the same session-comments feature, but built on a generic, extensible interface instead of hardcoding comments into the entity schema. Comments becomes one custom collection among potentially many; the mechanism underneath is reusable.

Three capabilities the agent runtime didn't have before:

  1. Opt-in externally-writable collections. Entity state is agent-owned. A collection becomes writable from the HTTP router only when its definition sets externallyWritable — everything else stays agent-only by default. (Every collection is always writable by the agent; only an opt-in subset is writable externally, hence the name.)
  2. Authenticated, auditable writes. Router writes require authentication. The server stamps the authenticated principal into the change-event header (provenance, outside the user payload), and the client materializes it into a read-only virtual column (_principal). A client can't spoof it via value.
  3. Schema-validated writes. Each collection carries a schema; router writes validate value server-side via the existing validateWriteEvent before append.

Design

Runtime (@electric-ax/agents-runtime)

  • CollectionDefinition.externallyWritable?: boolean.
  • At registration, writable collections are emitted as externally_writable_collections ({ type } per collection).
  • The entity stream DB materializes headers.principal into the fixed _principal virtual column, mirroring _timeline_order, and strips it (with _seq/_timeline_order) before client write-back.
  • createEntityTimelineQuery accepts customSources — consumer-provided sources unioned into the timeline query. The runtime itself knows nothing about specific custom collections, and the agent's LLM context never includes them.

Server (@electric-ax/agents-server)

  • externally_writable_collections persisted as a jsonb column on entity_types (migration 0016) and resolved via getEffectiveSchemas. Legacy principalColumn in registration payloads is accepted and ignored for version-skew tolerance.
  • EntityManager.writeCollection gates on it (403 if not writable), enforces entity-status/fork-lock, stamps headers.principal = { url, kind, id }, validates value, and appends.
  • POST /:type/:instanceId/collections/:collection exposes it (auth + write permission, same middleware chain as send). Single POST, operation in the body, 201 insert / 200 update·delete.

Comments consumer (@electric-ax/agents + @electric-ax/agents-server-ui)

Test Plan

  • @electric-ax/agents-runtime — typecheck + 839 tests (incl. principal virtual-column materialization, write-back stripping, registration emit, timeline projection)
  • @electric-ax/agents-server — typecheck + suite (incl. writeCollection 403/409 gating, principal-header stamping, schema validation, externally_writable_collections jsonb round-trip)
  • @electric-ax/agents — typecheck + 60 tests (comments declared on Horton/worker)
  • @electric-ax/agents-server-ui — typecheck + 88 tests (optimistic comment write to /collections/comments, author from _principal)
  • Manual: post a comment from the UI, confirm it syncs, right-aligns for the author, and the principal is recorded in the change-event header

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Electric Agents Desktop Builds

Build artifacts for commit 31db3c0.

Platform Status Artifact
macOS Apple Silicon Passed DMG
macOS Intel Passed DMG
Windows x64 Passed Installer
Linux x64 Passed AppImage / deb

Workflow run

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Electric Agents Mobile Build

Local mobile checks ran for commit 31db3c0.

The EAS Android preview build was skipped because the mobile-eas-build label is not present.
Add the mobile-eas-build label to this PR to produce an installable preview build.

Workflow run

@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 45.40881% with 434 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.53%. Comparing base (1219cb4) to head (31db3c0).
⚠️ Report is 8 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...agents-server-ui/src/components/EntityTimeline.tsx 0.00% 140 Missing ⚠️
...s/agents-server-ui/src/components/MessageInput.tsx 0.00% 75 Missing ⚠️
...agents-server-ui/src/components/views/ChatView.tsx 0.00% 49 Missing ⚠️
.../agents-server-ui/src/components/CommentBubble.tsx 0.00% 34 Missing ⚠️
...ages/agents-server-ui/src/hooks/useForkFromHere.ts 0.00% 25 Missing ⚠️
packages/agents-server-ui/src/lib/principals.ts 0.00% 22 Missing ⚠️
...s-server-ui/src/components/workspace/SplitMenu.tsx 0.00% 13 Missing ⚠️
packages/agents-runtime/src/create-handler.ts 87.91% 11 Missing ⚠️
packages/agents-runtime/src/entity-stream-db.ts 85.48% 9 Missing ⚠️
.../agents-server-ui/src/components/AgentResponse.tsx 0.00% 8 Missing ⚠️
... and 12 more
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #4551       +/-   ##
===========================================
- Coverage   70.96%   58.53%   -12.44%     
===========================================
  Files          83      375      +292     
  Lines        9856    41246    +31390     
  Branches     3124    11826     +8702     
===========================================
+ Hits         6994    24142    +17148     
- Misses       2844    17028    +14184     
- Partials       18       76       +58     
Flag Coverage Δ
packages/agents 71.37% <ø> (ø)
packages/agents-mcp 77.70% <ø> (?)
packages/agents-mobile 75.49% <ø> (?)
packages/agents-runtime 83.06% <89.65%> (?)
packages/agents-server 74.94% <85.48%> (+0.10%) ⬆️
packages/agents-server-ui 7.42% <19.96%> (?)
packages/electric-ax 46.42% <ø> (ø)
packages/experimental 87.73% <ø> (?)
packages/react-hooks 86.48% <ø> (?)
packages/start 82.83% <ø> (?)
packages/typescript-client 91.83% <ø> (?)
packages/y-electric 56.05% <ø> (?)
typescript 58.53% <45.40%> (-12.44%) ⬇️
unit-tests 58.53% <45.40%> (-12.44%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

balegas and others added 27 commits June 11, 2026 12:41
Alternative to PR #4529: reach the comments feature through a generic,
extensible custom-collection interface — opt-in router-writable
collections, authenticated writes with the principal stamped into the
change-event header and materialized into a virtual column, and
schema-validated payloads. Comments becomes one such collection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Projects headers.principal onto a configurable row column for collections
declared writable, in both the wire-batch and applyEvent paths. Also wraps
user-provided Zod schemas with a virtual-column-preserving validator so that
injected synthetic fields survive TanStack DB's schema validation step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…te-back

cleanRow now deletes every registered principal column (computed once as the
distinct values of principalColumnByCollection) in addition to _seq and
_timeline_order, preventing the server-stamped _principal field from leaking
into outgoing ChangeEvent.value when a client calls an auto-generated action.

Also adds: (1) a test covering the writable: true default column path, and
(2) a focused leak-specific test asserting the outgoing event value is clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ration

Extract inline body-building into exported `buildEntityTypeRegistrationBody` and add `writable_collections` map so the server knows which state collections are router-writable and their principal column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add writable_collections jsonb column to entity_types table via migration
0015, wire it through schema.ts, all write paths (createEntityType,
ensureEntityType, updateEntityTypeInPlace) and rowToEntityType so the
field survives the createEntityType → getEntityType round-trip. Add a
real-DB round-trip test in entity-type-registry.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…amping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the missing isForkWorkLockedEntity/assertEntityNotForkWorkLocked guard
to writeCollection, matching the pattern used by createAttachment and
deleteAttachment. Add a test asserting stopped entities are rejected with 409.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exposes EntityManager.writeCollection over HTTP, mirroring the send route,
with 201 for inserts and 200 for updates/deletes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ble interface

Adds a reusable commentsCollection definition (schema + CollectionDefinition)
that Horton and worker can declare as custom state, replacing the hardcoded
built-in comment schema from PR #4529. Drops from_principal — provenance now
comes from the server-stamped _principal virtual column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… and worker

Adds `state: { comments: commentsCollection }` to both the horton and worker
registry.define calls so the comments collection is registered as an
externally-writable collection and materialized client-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…principal

Port #4529's comment projection into createEntityTimelineQuery, adapted for
the generic custom-state comments collection: guards on db.collections.comments
existence so entities without comments are unaffected, and resolves the comment
author from the _principal virtual column (row._principal?.url → comment.from)
rather than a built-in from_principal field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mentSnapshotValue

Replace inline import() type expressions with named imports to fix esbuild
transform failure in vitest when running the entity-timeline tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…llection

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
C5 clone introduced TIMELINE_ORDER_FALLBACK='zzzz:timeline-end' to satisfy
the cloned UI, conflicting with the codebase's '~' sentinel (and '~pending:'
orders). Make '~' the single source of truth and derive the pending prefix
from it.
…mediate author display

Set _principal: { url: principalUrl } on the optimistic row in createSendCommentAction so the
runtime timeline projection resolves the author immediately (rather than waiting for the server
roundtrip). Extends OptimisticComment type with _principal?: { url: string }. Also adds a
one-line comment in entity-timeline.ts noting the comments projection is not generic. Test
asserts _principal.url is set on the optimistic row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…streams

Export commentsCollection from agents-runtime/client and define
UI_ENTITY_CUSTOM_STATE in entity-connection.ts, merging it into every
createEntityStreamDB call so db.collections.comments is always defined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runtime timeline query no longer knows about comments. Instead,
createEntityTimelineQuery accepts a generic extraSources option and builds
the union + ordering dynamically over the source map (also removing the
duplicated with/without-comments union block). The UI passes a comments
source (createCommentsTimelineSource) so comment rows are merged by the
live-query engine, and EntityTimelineCommentRow / the TimelineRow union
now live in agents-server-ui.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l as a reserved virtual column

The principal column name was persisted server-side but never read — the
server only uses the collection's event type, and clients resolve the
column from their local CollectionDefinition. externallyWritable is now a
plain boolean and the registration/DB config is { type } per collection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d fork-from-here hook

GenericChatBody no longer filters comment rows in JS — useEntityTimeline
takes a comments flag and omits the comments extraSource when the view
hides them, so the query engine maintains the row set. The duplicated
fork-from-here anchor map moves to a shared useForkFromHere hook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… to lib

formatSender/userDisplayName live once in lib/principals.ts (CommentBubble
had re-implemented UserMessage's copy with drifting ellipsis). The pure
comment-target codecs, buildCommentsTimeline, and TimelineRowAdjacency move
from ChatView.tsx to lib/comments.ts with their tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view cleanups

The entity-type schema accepts and ignores principalColumn so registration
survives version skew with older runtimes. Also: remove the double cast on
the UI customState merge, use randomUUID for server-generated collection
keys, re-attach virtual columns for async schema validators, and drop the
implementation plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
balegas and others added 2 commits June 11, 2026 12:44
…ion to 0016 after rebase

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main's standalone error rows (#4547) added an error variant to the timeline
union; CommentTimelineRow needs the matching undefined field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@balegas balegas force-pushed the vbalegas/custom-state branch from ca266da to 6043ed8 Compare June 11, 2026 11:58
balegas and others added 3 commits June 11, 2026 13:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aligns with the customState vocabulary for consumer-defined collections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sh changeset wording

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +2 to +5
'@electric-ax/agents-runtime': minor
'@electric-ax/agents-server': minor
'@electric-ax/agents-server-ui': minor
'@electric-ax/agents': minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally do patch changes still even for breaking changes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — switched all four entries to patch in 9673112.

Comment thread docs/superpowers/plans/2026-06-10-generic-writable-collections.md Outdated
Comment thread docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md Outdated
@msfstef

msfstef commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Design question: is comments meant to be per-agent or platform-level?

The externally-writable safeguard itself makes sense to me — default-deny, agent owns its state. But the PR seems ambiguous about whether the comments collection's existence is meant to be optional:

  • Write side: comments only exists because horton/worker explicitly declare state: { comments: commentsCollection }. Any agent definition that omits that line silently loses commenting.
  • Read side: the UI assumes comments everywhere — UI_ENTITY_CUSTOM_STATE registers it on every entity stream, the Comment tab and comments-only view are offered for all chat entities, and posting to a type that didn't declare comments fails only at submit time with a 403.

The design spec (dropped in 13050d0) justifies the writability opt-in, but I couldn't find a rationale for making existence opt-in per agent — it seems to just fall out of the mechanism.

If comments is a platform feature, could it join the default layer instead — e.g. merged into every registration alongside DEFAULT_STATE_SCHEMAS, with its externally_writable_collections entry — so the server-side truth matches what the UI already assumes? If it's genuinely per-agent, the UI should gate the comment affordances on the type's externally_writable_collections (already synced to the client). As-is we have per-agent boilerplate on the write side and an unconditional promise on the read side.

@msfstef

msfstef commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Provenance is "last touch", which the comments feature can't actually be built on

The header-principal design answers who performed this write — but comments need who authored this row, and for any operation other than insert those are different questions:

  • There's no stored author field (from_principal was deliberately dropped); authorship exists only as _principal, materialized from the latest event's header.
  • Updates are full row replaces, and the router stamps the caller's principal on every event. So B updating A's comment replaces the content and flips the displayed author to B. A's authorship survives only in raw stream history, which nothing reads.
  • Agent-side updates are worse: no principal header + full replace means the row comes out with no _principal at all.
  • The schema's edited_at/deleted_by fields imply edits/soft-deletes are intended — but deleted_by is client-supplied in value (spoofable), and the provenance model can't represent "created by A, edited by B" at all.

Combined with no ownership checks (the dropped spec records this as a decision: any principal with entity write may perform any operation), anyone who can prompt the session can delete or rewrite anyone's comment — and rewriting silently reattributes it.

Insert-only is the case where the provenance model is actually sufficient, so concretely: could ExternallyWritableCollectionConfig grow an operations allowlist (e.g. { type, operations: ['insert'] }), with comments shipping insert-only? That closes the moderation/reattribution hole without the server needing to materialize state for row-level ownership checks, and update/delete can be enabled later once there's a durable-authorship story (server-echoed creator, ownership-gated ops, or an explicit author field).

balegas and others added 2 commits June 11, 2026 15:48
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comments existence is now genuinely per-agent instead of assumed
platform-wide by the UI. The canonical commentsCollection declares a
comments/v1 contract forwarded in type registration; the server
reserves the comments collection name for that contract (and the
contract for that name); the entity GET response exposes the type's
externally_writable_collections so the UI registers the comments
collection — and shows the Comments view, composer mode, timeline
merge, and display toggle — only for types that declared it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@balegas

balegas commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Design question: is comments meant to be per-agent or platform-level?

We decided that comments would only be a feature for horton and hard-code it to the ui. I think this is limiting, so I introduced contracts and entities can opt-in for them. The ui will render comments for any entity with that capability.

  • The canonical commentsCollection declares a contract: 'comments/v1' marker, forwarded in type registration.
  • The server reserves the comments collection name for that contract (and vice versa), so an agent's unrelated writable collection can never be mistaken for platform comments.
  • The entity GET response now includes the type's externally_writable_collections, and the UI registers the comments collection on the entity stream only when the contract is advertised — the Comments view, composer Comment tab, timeline merge, and display toggle are all gated on it, so the submit-time 403 is no longer reachable through the UI.

@balegas

balegas commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Provenance is "last touch", which the comments feature can't actually be built on

Provenance here is intentionally attributed to the mutation: _principal answers "who performed the write that produced the current value", which is the correct attribution for materializing the latest value of a row — it isn't trying to be a durable authorship record. You're right that durable authorship ("created by A, edited by B") isn't representable in the materialized row today, and that anyone with entity write can rewrite or remove a comment — we're consciously accepting that for now: comments inherit entity-level write semantics.

The fuller treatment, if/when we want it, is additive on top of this design rather than a rework of it:

  • Comment history — every revision (with its principal header) is already durably retained in the stream; surfacing it is a read-side feature, no write-path changes needed.
  • Permissions on the state contract — an operations allowlist like the one you sketched (or ownership rules) hangs naturally off the collection config / the comments/v1 contract added for the per-agent gating.

So: deferring both and leaving the scope of this PR as is. Happy to open a follow-up issue capturing the moderation/authorship gaps so they don't get lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@balegas balegas requested a review from msfstef June 12, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants