Entity Model
Overview
Section titled “Overview”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
upjacklibrary 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.
Base Entity Schema
Section titled “Base Entity Schema”The base entity schema defines the minimum required structure for all Upjack entities.
Required Fields
Section titled “Required Fields”| Field | Type | Pattern / Format | Description |
|---|---|---|---|
id | string | ^[a-z]{2,4}_[0-9A-HJKMNP-TV-Z]{26}$ | Type-prefixed ULID. Immutable after creation. |
type | string | ^[a-z][a-z0-9_]*$ | Entity type name matching the entity definition in the manifest. Immutable after creation. |
version | integer | minimum: 1 | Schema version number. Used for lazy migration (see below). Immutable after creation. |
created_at | string | ISO 8601 date-time | Timestamp of entity creation. Immutable after creation. |
updated_at | string | ISO 8601 date-time | Timestamp of last modification. Auto-updated on every write. |
Optional Fields
Section titled “Optional Fields”| Field | Type | Default | Description |
|---|---|---|---|
created_by | string | "agent" | Origin of the entity. Enum: user, agent, system, ingestion, schedule. Immutable after creation. |
status | string | "active" | Lifecycle state. Enum: active, archived, deleted. |
tags | array | [] | Freeform labels. Items: strings, maxLength 64, pattern ^[a-z0-9][a-z0-9-]*$, maxItems 20, uniqueItems. |
source | object | — | Provenance information for imported or enriched entities. |
relationships | array | — | Typed links to other entities. |
source Object
Section titled “source Object”| Sub-field | Type | Required | Description |
|---|---|---|---|
origin | string | No | Human-readable origin (e.g., "linkedin", "csv-import", "web-scrape"). |
ref | string | No | External identifier in the source system. |
url | string (uri) | No | URL back to the source record. |
relationships Array Items
Section titled “relationships Array Items”| Sub-field | Type | Required | Description |
|---|---|---|---|
rel | string | Yes | Relationship type (e.g., "works_at", "parent_of", "related_to"). |
target | string | Yes | Target entity ID. Pattern: ^[a-z]{2,4}_[0-9A-HJKMNP-TV-Z]{26}$ |
label | string | No | Human-readable label for the relationship. |
additionalProperties: true
Section titled “additionalProperties: true”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.
Base Schema (JSON Schema)
Section titled “Base Schema (JSON Schema)”{ "$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
Section titled “Entity IDs”Entity IDs follow the format {prefix}_{ULID}:
- Prefix: 2-4 lowercase letters defined in the entity manifest (
prefixfield). 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.
ID Pattern
Section titled “ID Pattern”^[a-z]{2,4}_[0-9A-HJKMNP-TV-Z]{26}$Examples
Section titled “Examples”| Entity Type | Prefix | Example ID |
|---|---|---|
| lead | ld | ld_01HZ3QKBN9YWVJ0RPFA7MT8C5X |
| company | co | co_01HZ3QKBN9YWVJ0RPFA7MT8C5Y |
| deal | dl | dl_01HZ3QM4R2XW8K1DPGB6NT9C7Z |
| activity | act | act_01HZ3QN7V5YX9L2EQHC8PU0D8A |
| pipeline_config | pc | pc_01HZ3QP9W6ZY0M3FRIC9QV1E9B |
Why Prefixed ULIDs
Section titled “Why Prefixed ULIDs”- 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.
Schema Layering
Section titled “Schema Layering”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" } ]}How It Works
Section titled “How It Works”- The base schema defines required metadata fields (
id,type,version,created_at,updated_at) and optional common fields (status,tags,source,relationships). - The app schema defines domain-specific fields (e.g.,
email,company_name,deal_value). allOfrequires the entity to satisfy both schemas simultaneously.- Because the base schema sets
additionalProperties: true, it does not reject the app schema’s fields.
App Schema Example (Lead)
Section titled “App Schema Example (Lead)”{ "$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}Composed Entity (What Gets Stored)
Section titled “Composed Entity (What Gets Stored)”{ "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" } ]}Lifecycle States
Section titled “Lifecycle States”Entities follow a simple lifecycle:
active --> archived --> deleted ^ | | | +-------------+ (restore)| State | Meaning | Queryable | Restorable |
|---|---|---|---|
active | Normal operational state. Returned by default queries. | Yes | N/A |
archived | Removed from active use but preserved. Not returned by default queries. | With filter | Yes (to active) |
deleted | Soft-deleted. Not returned by any default query. | With filter | Yes (to active) |
- Soft delete is the default.
entity_deletesetsstatus: "deleted"and updatesupdated_at. - Hard delete removes the file from the workspace entirely. Only used with explicit
hard: true. - Restore changes status from
archivedordeletedback toactiveviaentity_update.
Storage
Section titled “Storage”Entities are stored as individual JSON files in the tenant workspace git repository.
Path Format
Section titled “Path Format”{namespace}/data/{plural}/{id}.jsonExamples
Section titled “Examples”| Entity | Path |
|---|---|
Lead ld_01HZ...5X | apps/crm/data/leads/ld_01HZ3QKBN9YWVJ0RPFA7MT8C5X.json |
Company co_01HZ...5Y | apps/crm/data/companies/co_01HZ3QKBN9YWVJ0RPFA7MT8C5Y.json |
Deal dl_01HZ...7Z | apps/crm/data/deals/dl_01HZ3QM4R2XW8K1DPGB6NT9C7Z.json |
| Pipeline config (singleton) | apps/crm/data/pipeline_configs/pc_01HZ3QP9W6ZY0M3FRIC9QV1E9B.json |
File Format
Section titled “File Format”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.
Git Commits
Section titled “Git Commits”Platform-level. These commit conventions describe the intended behavior of the NimbleBrain platform runtime. The
upjacklibrary 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
Version Field and Lazy Migration
Section titled “Version Field and Lazy Migration”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.
How It Works
Section titled “How It Works”- App v0.1.0 defines lead schema version 1. All leads are created with
"version": 1. - App v0.2.0 adds a new required field with a default. The lead schema is now version 2.
- Existing leads still have
"version": 1. They are not migrated immediately. - When an existing lead is read, the runtime checks
version < current_schema_version. - If a migration function exists, it is applied on read (lazy). The migrated entity is written back with the new version.
- New leads are created with
"version": 2.
This avoids bulk migrations on app update. Records are migrated lazily as they are accessed.
Fields Intentionally Excluded from Base
Section titled “Fields Intentionally Excluded from Base”The following fields were considered for the base schema and intentionally excluded:
| Field | Reason for Exclusion |
|---|---|
name / title | Not every entity has a name. Singletons, activities, and config entities often lack one. Domain-specific naming belongs in the app schema. |
description | Too domain-specific. A lead’s “description” means something different from a deal’s. |
confidence | Goes stale quickly. Better as a computed/transient value than a stored field. |
notes | Better modeled as a related entity (e.g., activity of type note) for proper history tracking. |
assignee / owner | Not all apps have multi-user assignment. Single-user apps (the common case for v0.1) do not need this. |
priority | Domain-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.