Skip to content

Objects Layer

The objects layer is a unified JSONB shadow table (cinatra.objects) that provides a single queryable surface over every persistent object in Cinatra — regardless of which package stores it or what storage strategy that package uses internally.

Cinatra objects layer diagram

Cinatra packages own their own persistence: blob-keyed metadata, row-per-entity tables, or relational tables. This works well for per-package operations but makes cross-package queries (e.g. “show all objects created by this user”, “find everything related to this campaign”) expensive without a shared index.

cinatra.objects solves this with a fire-and-forget shadow write on every mutation. The primary store remains authoritative; the objects table is derived state that is always within one write of being current.


CREATE TABLE cinatra.objects (
-- Base columns
id text PRIMARY KEY,
type text NOT NULL, -- namespaced type id (@scope/pkg:local)
parent_id text, -- self-FK; denormalised parent_type below
parent_type text,
data jsonb NOT NULL, -- full object payload
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by text, -- better-auth user id
org_id text, -- organisation scope
-- Provenance and lifecycle
source text, -- where the row came from (manual UI, agent run, import, …)
agent_id text, -- agent that produced this row, when applicable
run_id text, -- specific run id, for agent-produced rows
deleted_at timestamptz, -- soft-delete; rows with deleted_at IS NOT NULL are tombstoned
classification_confidence real, -- LLM classification confidence (0..1) when type was inferred
-- Versioning and packaging
package_version text, -- package version that produced this row
agent_spec_version text, -- OAS spec version active at write time
canonical_keys text[], -- per-type canonical identity keys (used for dedupe / upsert)
external_id text, -- foreign-system identity, if any
-- Derived index sync state
graphiti_sync_status text NOT NULL DEFAULT 'synced', -- 'synced' | 'pending' | 'failed'
exported_to jsonb NOT NULL DEFAULT '{}' -- per-channel export receipts
);

ON CONFLICT (id) DO UPDATE is used on every write, so all mutations are idempotent upserts and the table can be backfilled or re-backfilled at any time without producing duplicates.


Every registered type belongs to one of five categories. All type IDs are namespaced as @cinatra/<package>:<local-id>:

CategoryTypesNotes
profile@cinatra/entity-accounts:account, @cinatra/entity-contacts:contactLong-lived identity records; written by the CRM connectors and entity surfaces.
project@cinatra/campaigns:campaign, @cinatra/campaigns:context, @cinatra/agent-builder:agent-template, @cinatra/lists:listLong-lived containers that group child objects (a campaign, a list of recipients, a saved agent template).
idea@cinatra/asset-blog:blog-post-ideaIntermediate creative stage produced by the blog agent.
content@cinatra/asset-blog:blog-post, @cinatra/asset-blog:saved-mediaPublished or publishable outputs.
report@cinatra/campaigns:recipients, @cinatra/objects:objectAgent-produced outputs; objects:object is the fallback type for anything not registered to a more specific one.

Extensions can register their own object types at startup (see registerObjectType in @cinatra/object-types). The list above describes the first-party types that ship with the platform; an instance with additional installed extensions will see more.

account
└── contact
blog-post-idea (idea)
└── blog-post (content, optional parent)
saved-media (content, no parent)
campaign (project, no parent)
campaigns:context (project, no parent)
agent-template (project, no parent)
list (project, no parent)
campaigns:recipients (report, no parent in objects)

parent_id / parent_type are nullable. Objects without a registered parent (reports, top-level projects) store NULL.


Every package that owns a registered type calls shadowUpsertObject() from src/lib/objects-dual-write.ts on every mutating store operation:

src/lib/objects-dual-write.ts
import "server-only";
import { upsertObject, type UpsertObjectInput } from "@/lib/objects-store";
export function shadowUpsertObject(input: UpsertObjectInput): void {
try {
upsertObject(input); // fire-and-forget — not awaited
} catch (error) {
console.error(
`[objects:shadow-write] type=${input.type} id=${input.id ?? "(auto)"} failed:`,
error,
);
}
}

Key properties:

  • Never throws — a failed shadow write is logged and swallowed; the primary store write is unaffected.
  • Fire-and-forgetupsertObject is not awaited; latency impact on the calling operation is negligible.
  • IdempotentON CONFLICT (id) DO UPDATE SET type = EXCLUDED.type, data = EXCLUDED.data, ... so any operation is safe to replay.
  • Type updates on conflicttype is included in the update clause, so an id that transitions between types (e.g. startup → account) is correctly re-typed on the next write.
Store fileTypes written
packages/entity-accounts/src/integration/register-object-types.ts@cinatra/entity-accounts:account
packages/entity-contacts/src/integration/register-object-types.ts@cinatra/entity-contacts:contact
packages/asset-blog/src/integration/register-object-types.ts@cinatra/asset-blog:blog-post-idea, @cinatra/asset-blog:blog-post, @cinatra/asset-blog:saved-media
packages/agents/src/integration/register-object-types.ts@cinatra/agent-builder:agent-template
packages/objects/src/integration/register-types.ts@cinatra/objects:object, @cinatra/campaigns:campaign, @cinatra/campaigns:context, @cinatra/campaigns:recipients
packages/lists/src/integration/register-object-types.ts@cinatra/lists:list

Outputs that agent runs produce — contact discovery batches, generated content drafts, miscellaneous run artifacts — flow through these registered types, typically attached to a @cinatra/lists:list or written as a generic @cinatra/objects:object, rather than each agent registering its own bespoke result type. Per-run provenance (the agent id and run id that produced the row) is stamped on the object via the AsyncLocalStorage-based propagation set up at run start.


One-shot backfill scripts populate the table from existing primary-store data. Safe to re-run on any database at any time.

Terminal window
pnpm backfill:objects # all directly-stored first-party types
pnpm backfill:objects:entities # synthesized contacts + accounts
pnpm verify:objects-counts # side-by-side count comparison

Each package registers its object types with objectTypeRegistry from @cinatra/object-types at startup. The registry holds the Zod schema, category, lifecycle rules, renderers, and relation declarations for each type.

import { objectTypeRegistry } from "@cinatra/object-types";
objectTypeRegistry.register({
type: "@cinatra/asset-blog:blog-post",
category: "content",
schema: blogPostSchema,
lifecycle: { sources: ["agent", "user"], mutableBy: ["agent", "user"] },
renderers: { listRow: BlogPostListRow, card: BlogPostCard, detail: BlogPostDetail },
});

Type IDs use the @scope/package:local-id namespace format enforced by OBJECT_TYPE_NAMESPACE_RE.


Use readAllObjects() from src/lib/objects-store.ts to query across all or a filtered subset of types. Pair it with registerAllObjectTypes() from src/lib/register-all-object-types.ts to ensure the registry is populated before resolving renderers.

import { readAllObjects } from "@/lib/objects-store";
import { registerAllObjectTypes, ASSET_TYPE_IDS } from "@/lib/register-all-object-types";
import { objectTypeRegistry } from "@cinatra/object-types";
import { hasReactRenderers } from "@cinatra/object-types/renderer-types";
// Ensure all registered types are loaded (idempotent)
registerAllObjectTypes();
// Fetch — optionally filter by type IDs
const objects = readAllObjects({ typeIds: Array.from(ASSET_TYPE_IDS) });
// Render via each type's listRow renderer
objects.map((obj) => {
const def = objectTypeRegistry.resolve(obj.type);
if (!def || !hasReactRenderers(def)) return null;
const ListRow = def.renderers.listRow;
return <ListRow value={obj.data as Record<string, unknown>} />;
});

readAllObjects is limited to 200 rows per call. Use the typeIds filter to stay within relevant scope.

src/lib/register-all-object-types.ts exports two pre-built sets for filtering by UI family:

ExportContents
ASSET_TYPE_IDSThe set of asset-family type IDs used for ?family=assets filtering (blog post ideas, blog posts, saved media)
ENTITY_TYPE_IDSThe set of registered entity-family type IDs used for ?family=entities filtering (contacts, accounts, campaigns, agent templates, lists)
OBJECT_TYPE_NEW_URLSCreation route per type (used by /objects/new chooser)

The unified workspace surface lives in the sidebar’s Information → Data group.

  • /data — unified list page showing all objects across registered types, filterable by ?family=assets, ?family=entities, or ?type=<typeId>. Each row is rendered via the type’s registered listRow renderer.
  • /data/types — type chooser browse view.
  • /data/new — type chooser linking each registered type to its creation route.
  • /assets, /entities/accounts, /entities/contacts, etc. remain reachable as typed entry points; they read from the same underlying objects table and surface only their family.
  • /administration/objects (admin-only) — operational view used for cross-org diagnostics and type-registry inspection.

All sub-routes (detail, edit, new) under typed paths are unchanged — they read and write through the same Objects layer.


  • Architecture — full platform architecture
  • src/lib/objects-store.tsupsertObject, readAllObjects, readObjectsByType, ObjectRecord
  • src/lib/objects-dual-write.tsshadowUpsertObject helper
  • src/lib/register-all-object-types.tsregisterAllObjectTypes, family sets, creation URL map
  • packages/object-types/src/ — registry, type definitions, category taxonomy