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: true } });

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 / attachmentsbody is a plain longText field; there’s no formatting or file-attachment support.