Skip to content

Notes History

A bundled feature that turns any entity’s single overwritable notes field into a chronological, authored history, without adding a column, a join table, or a migration. Every note is a fresh, timestamped, authored entry; nothing is ever overwritten.

Status: ✅ Stable

What: A host-agnostic, append-only note-entry per (entityType, entityId). Each entry carries a body, and the author + timestamp come for free from the framework’s base columns (insertedById / insertedAt), no custom author/date fields to manage. Read an entity’s history by listing entries filtered on entityId, sorted by insertedAt.

When you reach for it: A record has a single “Notes” textarea today and you need to know who wrote what and when, call logs, case notes, internal comments, without building a comment/activity-log table per feature.

One event-sourced entity, no relational pivot and no JOIN:

  • note-entry (read_note_entries), host-agnostic rows keyed by (entityType, entityId). The framework projects the table from its own create events, so attaching a note history adds no column to the host entity.

Cross-entity views compose in the read-layer, this entity’s note history lists entries filtered on entityId, sorted by insertedAt. No JOIN, no pivot.

Only two handlers are registered: add-note (create) and the list query. There is no update, no delete. A correction is a new entry, not an edit : that’s the entire reason this bundle exists instead of a single overwritable textarea. The author is never client-supplied: add-note always attributes the note to the authenticated caller, so a note can’t be authored as someone else.

Unlike tagstag-assignment, note-entry has no dedup key, an entity legitimately carries many notes, so every add-note is an ordinary random-id stream. There’s nothing to make idempotent.

Mount the client plugin once (for the component + i18n), then drop <NotesSection> where you need it:

client.tsx
import { runDevApp } from "@cosmicdrift/kumiko-dev-server";
import { createNotesHistoryFeature } from "@cosmicdrift/kumiko-bundled-features/notes-history";
import { notesHistoryClient } from "@cosmicdrift/kumiko-bundled-features/notes-history/web";
// server: mount the feature (roles default to TenantAdmin/TenantMember)
await runDevApp({ features: [createNotesHistoryFeature()] });
// client: register the component + translations once
createKumikoApp({ clientFeatures: [notesHistoryClient()] });

Shows every note for an entity newest-first (author + timestamp), plus a textarea to append a new one. It owns its own state, not part of a host form’s save. Mount it standalone or as an entityEdit extension section:

import { NotesSection, NOTES_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/notes-history/web";
// standalone, drop it into any screen:
<NotesSection entityName="contact" entityId={contactId} />
// or as an extension section in an entityEdit screen schema:
{ kind: "extension", title: "Notes", component: { react: { __component: NOTES_SECTION_EXTENSION_NAME } } }

The list is capped at 200 entries (newest-first), there is no “load more” yet. An entity with a longer history only shows its 200 most recent notes; upgrade to cursor pagination (the query already supports cursor) if that limit is ever hit in practice.

The feature is driven entirely by dispatching its handler, nothing is wired into the noted entity.

HandlerPayloadNotes
notes-history:write:add-note{ entityType, entityId, body }Author is always the authenticated caller, never the payload

Reads are the one list query, notes-history:query:note-entry:list (filter on entityId, sort by insertedAt).

// Append a note to any entity by (type, id), no column on that entity
{ "entityType": "contact", "entityId": "<contact-uuid>", "body": "Called about renewal." }

Every path uses one access rule. The default is { roles: ["TenantAdmin", "TenantMember"] }. Apps with a different role vocabulary adopt their own model, otherwise the handler is access_denied for their users:

// pin roles (e.g. a global SystemAdmin operator also writes notes) …
createNotesHistoryFeature({ roles: ["TenantAdmin", "TenantMember", "SystemAdmin"] });
// … or adopt the host's whole model
createNotesHistoryFeature({
access: {
openToAll: { reason: "every signed-in tenant member may read and add notes on shared records" },
},
});

body is annotated userOwned (owner: authorId) on the entity, so a mounted crypto-shredding KMS encrypts it per-author and erases it on forget by destroying that author’s subject key, without needing a physical delete, which would otherwise break the history for other people who read it.

That coverage isn’t automatic: mount the companion feature notes-history-user-data alongside user-data-rights for a user’s authored notes to appear in GDPR exports. Without a mounted KMS, userOwned fields fall back to plaintext storage framework-wide (a property of the crypto-shredding design, not specific to this bundle), apps needing real Art. 17 coverage for notes must mount one.

import { createNotesHistoryFeature } from "@cosmicdrift/kumiko-bundled-features/notes-history";
import { notesHistoryUserDataFeature } from "@cosmicdrift/kumiko-bundled-features/notes-history-user-data";
await runDevApp({ features: [createNotesHistoryFeature(), notesHistoryUserDataFeature] });
  • You need to edit or retract a note, this bundle is deliberately append-only. If mutable notes-with-history is genuinely the requirement, this isn’t it.
  • The note drives business logic, that’s a first-class field on the entity, not free-text history.
  • You need rich text / attachments, body is a plain longText field; there’s no formatting or file-attachment support.