Skip to content

Entity Model

Every Upjack entity conforms to a two-layer JSON Schema composition. The base entity schema defines common metadata fields shared by all entities across all apps. The app entity schema defines domain-specific fields for a particular entity type. These are composed at validation time using JSON Schema allOf.

Entities are stored as individual JSON files in the tenant workspace git repository. In the full platform runtime, every write operation (create, update, delete) is a git commit, providing a complete audit trail.

Implementation note: The upjack library handles file I/O only. Git commits are the responsibility of the hosting platform or calling code. The commit conventions below describe the intended platform behavior, not current library behavior.

The base entity schema defines the minimum required structure for all Upjack entities.

FieldTypePattern / FormatDescription
idstring^[a-z]{2,4}_[0-9A-HJKMNP-TV-Z]{26}$Type-prefixed ULID. Immutable after creation.
typestring^[a-z][a-z0-9_]*$Entity type name matching the entity definition in the manifest. Immutable after creation.
versionintegerminimum: 1Schema version number. Used for lazy migration (see below). Immutable after creation.
created_atstringISO 8601 date-timeTimestamp of entity creation. Immutable after creation.
updated_atstringISO 8601 date-timeTimestamp of last modification. Auto-updated on every write.
FieldTypeDefaultDescription
created_bystring"agent"Origin of the entity. Enum: user, agent, system, ingestion, schedule. Immutable after creation.
statusstring"active"Lifecycle state. Enum: active, archived, deleted.
tagsarray[]Freeform labels. Items: strings, maxLength 64, pattern ^[a-z0-9][a-z0-9-]*$, maxItems 20, uniqueItems.
sourceobjectProvenance information for imported or enriched entities.
relationshipsarrayTyped links to other entities.
Sub-fieldTypeRequiredDescription
originstringNoHuman-readable origin (e.g., "linkedin", "csv-import", "web-scrape").
refstringNoExternal identifier in the source system.
urlstring (uri)NoURL back to the source record.
Sub-fieldTypeRequiredDescription
relstringYesRelationship type (e.g., "works_at", "parent_of", "related_to").
targetstringYesTarget entity ID. Pattern: ^[a-z]{2,4}_[0-9A-HJKMNP-TV-Z]{26}$
labelstringNoHuman-readable label for the relationship.

The base schema sets additionalProperties: true. This is critical for allOf composition — without it, the app schema’s domain-specific fields would be rejected by the base schema during validation.

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://upjack.dev/schemas/v1/upjack-entity.schema.json",
"title": "Upjack Entity Base",
"description": "Base schema for all Upjack entities.",
"type": "object",
"required": ["id", "type", "version", "created_at", "updated_at"],
"additionalProperties": true,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z]{2,4}_[0-9A-HJKMNP-TV-Z]{26}$",
"description": "Type-prefixed ULID. Immutable after creation."
},
"type": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Entity type name."
},
"version": {
"type": "integer",
"minimum": 1,
"description": "Schema version for lazy migration."
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 creation timestamp."
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 last-modified timestamp."
},
"created_by": {
"type": "string",
"enum": ["user", "agent", "system", "ingestion", "schedule"],
"default": "agent",
"description": "Origin of the entity."
},
"status": {
"type": "string",
"enum": ["active", "archived", "deleted"],
"default": "active",
"description": "Lifecycle state."
},
"tags": {
"type": "array",
"items": {
"type": "string",
"maxLength": 64,
"pattern": "^[a-z0-9][a-z0-9-]*$"
},
"maxItems": 20,
"uniqueItems": true,
"default": [],
"description": "Freeform labels."
},
"source": {
"type": "object",
"properties": {
"origin": { "type": "string" },
"ref": { "type": "string" },
"url": { "type": "string", "format": "uri" }
},
"description": "Provenance information."
},
"relationships": {
"type": "array",
"items": {
"type": "object",
"required": ["rel", "target"],
"properties": {
"rel": { "type": "string" },
"target": {
"type": "string",
"pattern": "^[a-z]{2,4}_[0-9A-HJKMNP-TV-Z]{26}$"
},
"label": { "type": "string" }
}
},
"description": "Typed links to other entities."
}
}
}

Entity IDs follow the format {prefix}_{ULID}:

  • Prefix: 2-4 lowercase letters defined in the entity manifest (prefix field). Must be unique within an app.
  • Separator: underscore (_)
  • ULID: 26-character Crockford Base32 encoded ULID (spec)

The ULID character set excludes I, L, O, and U to avoid ambiguity: 0-9A-HJKMNP-TV-Z.

^[a-z]{2,4}_[0-9A-HJKMNP-TV-Z]{26}$
Entity TypePrefixExample ID
leadldld_01HZ3QKBN9YWVJ0RPFA7MT8C5X
companycoco_01HZ3QKBN9YWVJ0RPFA7MT8C5Y
dealdldl_01HZ3QM4R2XW8K1DPGB6NT9C7Z
activityactact_01HZ3QN7V5YX9L2EQHC8PU0D8A
pipeline_configpcpc_01HZ3QP9W6ZY0M3FRIC9QV1E9B
  • Type-evident: You can identify the entity type from the ID alone without a database lookup.
  • Sortable: ULIDs are monotonically sortable by creation time.
  • Collision-resistant: 128-bit randomness per millisecond.
  • Human-friendly: Short prefixes make IDs recognizable in logs and conversations.

Entity validation uses JSON Schema allOf composition. At validation time, the platform composes the base schema with the app-specific schema:

{
"allOf": [
{ "$ref": "https://upjack.dev/schemas/v1/upjack-entity.schema.json" },
{ "$ref": "./lead.schema.json" }
]
}
  1. The base schema defines required metadata fields (id, type, version, created_at, updated_at) and optional common fields (status, tags, source, relationships).
  2. The app schema defines domain-specific fields (e.g., email, company_name, deal_value).
  3. allOf requires the entity to satisfy both schemas simultaneously.
  4. Because the base schema sets additionalProperties: true, it does not reject the app schema’s fields.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.nimblebrain.ai/apps/crm/lead.schema.json",
"title": "CRM Lead",
"description": "A sales lead in the CRM.",
"type": "object",
"required": ["name", "email"],
"properties": {
"name": {
"type": "string",
"maxLength": 256,
"description": "Full name of the lead."
},
"email": {
"type": "string",
"format": "email",
"description": "Primary email address."
},
"company_name": {
"type": "string",
"maxLength": 256,
"description": "Company the lead works at."
},
"title": {
"type": "string",
"maxLength": 256,
"description": "Job title."
},
"stage": {
"type": "string",
"enum": ["new", "contacted", "qualified", "converted", "lost"],
"default": "new",
"description": "Sales pipeline stage."
},
"score": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "Lead qualification score (0-100)."
},
"next_action": {
"type": "string",
"description": "Next action to take with this lead."
},
"next_action_date": {
"type": "string",
"format": "date",
"description": "When the next action is due."
}
},
"additionalProperties": true
}
{
"id": "ld_01HZ3QKBN9YWVJ0RPFA7MT8C5X",
"type": "lead",
"version": 1,
"created_at": "2026-02-15T10:30:00Z",
"updated_at": "2026-02-15T14:22:00Z",
"created_by": "agent",
"status": "active",
"tags": ["inbound", "saas"],
"name": "Alice Chen",
"email": "alice@example.com",
"company_name": "TechCorp",
"title": "VP Engineering",
"stage": "qualified",
"score": 85,
"next_action": "Schedule demo call",
"next_action_date": "2026-02-20",
"source": {
"origin": "linkedin",
"url": "https://linkedin.com/in/alicechen"
},
"relationships": [
{
"rel": "works_at",
"target": "co_01HZ3QKBN9YWVJ0RPFA7MT8C5Y",
"label": "TechCorp"
}
]
}

Entities follow a simple lifecycle:

active --> archived --> deleted
^ |
| |
+-------------+
(restore)
StateMeaningQueryableRestorable
activeNormal operational state. Returned by default queries.YesN/A
archivedRemoved from active use but preserved. Not returned by default queries.With filterYes (to active)
deletedSoft-deleted. Not returned by any default query.With filterYes (to active)
  • Soft delete is the default. entity_delete sets status: "deleted" and updates updated_at.
  • Hard delete removes the file from the workspace entirely. Only used with explicit hard: true.
  • Restore changes status from archived or deleted back to active via entity_update.

Entities are stored as individual JSON files in the tenant workspace git repository.

{namespace}/data/{plural}/{id}.json
EntityPath
Lead ld_01HZ...5Xapps/crm/data/leads/ld_01HZ3QKBN9YWVJ0RPFA7MT8C5X.json
Company co_01HZ...5Yapps/crm/data/companies/co_01HZ3QKBN9YWVJ0RPFA7MT8C5Y.json
Deal dl_01HZ...7Zapps/crm/data/deals/dl_01HZ3QM4R2XW8K1DPGB6NT9C7Z.json
Pipeline config (singleton)apps/crm/data/pipeline_configs/pc_01HZ3QP9W6ZY0M3FRIC9QV1E9B.json

Each file contains a single JSON object — the complete entity with base and domain fields. Files are formatted with 2-space indentation for human readability and clean git diffs.

Platform-level. These commit conventions describe the intended behavior of the NimbleBrain platform runtime. The upjack library writes files but does not make git commits.

Every entity write is an atomic git commit:

  • Create: crm: create lead ld_01HZ...5X
  • Update: crm: update lead ld_01HZ...5X
  • Delete (soft): crm: delete lead ld_01HZ...5X
  • Delete (hard): crm: hard-delete lead ld_01HZ...5X

The version field is a schema version number, not a record revision counter. It indicates which version of the entity schema this record was created or last migrated under.

  1. App v0.1.0 defines lead schema version 1. All leads are created with "version": 1.
  2. App v0.2.0 adds a new required field with a default. The lead schema is now version 2.
  3. Existing leads still have "version": 1. They are not migrated immediately.
  4. When an existing lead is read, the runtime checks version < current_schema_version.
  5. If a migration function exists, it is applied on read (lazy). The migrated entity is written back with the new version.
  6. New leads are created with "version": 2.

This avoids bulk migrations on app update. Records are migrated lazily as they are accessed.

The following fields were considered for the base schema and intentionally excluded:

FieldReason for Exclusion
name / titleNot every entity has a name. Singletons, activities, and config entities often lack one. Domain-specific naming belongs in the app schema.
descriptionToo domain-specific. A lead’s “description” means something different from a deal’s.
confidenceGoes stale quickly. Better as a computed/transient value than a stored field.
notesBetter modeled as a related entity (e.g., activity of type note) for proper history tracking.
assignee / ownerNot all apps have multi-user assignment. Single-user apps (the common case for v0.1) do not need this.
priorityDomain-specific semantics. A lead priority scale differs from a task priority scale.

These fields can and should be added in app-specific schemas where appropriate.