Schema

core

Definition

A schema is a formal description of the structure, types, constraints, and relationships of data within a system. It defines what data looks like (its shape), what values are acceptable (its constraints), how different pieces of data relate to each other (its relationships), and what operations are valid against it (its interface). A schema serves as a contract: producers must emit data that conforms to the schema, and consumers can rely on the schema's guarantees when processing data.

The concept of schema spans multiple domains in software engineering. In databases, a schema defines tables, columns, types, indexes, and foreign key relationships. In APIs, a schema defines request and response formats, required fields, and value constraints. In programming languages, a schema maps to type definitions, struct declarations, and interface specifications. In data serialization, a schema defines the wire format for encoding and decoding messages. Despite these different contexts, the core purpose remains the same: establishing a shared, unambiguous understanding of data structure that can be validated mechanically.

Schemas are the antidote to implicit assumptions. Without an explicit schema, every component that touches data must make assumptions about its structure -- assumptions that are invisible, untestable, and inevitably diverge over time. An explicit schema makes these assumptions visible, testable, and enforceable. When a schema changes, the change is visible in version control, and tools can automatically validate that all consumers handle the new structure correctly. This is why schema-first design is considered a best practice in API design, database design, and data engineering.

Schema Types

Different kinds of schemas serve different purposes across the software stack:

Schema TypeDomainPurposePrismatic Tool
Database SchemaStorageTable structure, types, indexes, constraintsEcto migrations
Application SchemaBusiness logicData structures, validations, type castingEcto schemas
API SchemaInterfaceRequest/response formats, OpenAPI definitionsOpenApiSpex
Validation SchemaData qualityChangeset validations, business rulesEcto changesets
Serialization SchemaTransportWire format for encoding/decodingJason, Protobuf
Graph SchemaKnowledgeNode types, edge types, propertiesKuzuDB schema
Configuration SchemaOperationsConfig structure, defaults, environmentApplication config

Ecto Schemas in Elixir

The Prismatic Platform uses Ecto as its primary schema definition and data mapping library. Ecto schemas define the structure of data as Elixir structs with typed fields, associations, and validation rules:

defmodule PrismaticPerimeter.Schema.Asset do
  @moduledoc """
  Schema representing an attack surface asset discovered
  by the Prismatic Perimeter EASM module.
  """
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{
    id: integer() | nil,
    domain: String.t(),
    type: atom(),
    risk_score: float() | nil,
    first_seen: DateTime.t() | nil,
    last_seen: DateTime.t() | nil,
    metadata: map()
  }

  schema "perimeter_assets" do
    field :domain, :string
    field :type, Ecto.Enum, values: [:domain, :ip, :certificate, :service, :cloud_resource]
    field :risk_score, :float
    field :status, Ecto.Enum, values: [:active, :inactive, :decommissioned], default: :active
    field :first_seen, :utc_datetime_usec
    field :last_seen, :utc_datetime_usec
    field :metadata, :map, default: %{}

    belongs_to :organization, PrismaticPerimeter.Schema.Organization
    has_many :vulnerabilities, PrismaticPerimeter.Schema.Vulnerability
    has_many :scan_results, PrismaticPerimeter.Schema.ScanResult

    timestamps(type: :utc_datetime_usec)
  end

  @required_fields ~w(domain type)a
  @optional_fields ~w(risk_score status first_seen last_seen metadata organization_id)a

  @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
  def changeset(asset, attrs) do
    asset
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields)
    |> validate_inclusion(:type, [:domain, :ip, :certificate, :service, :cloud_resource])
    |> validate_number(:risk_score, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 10.0)
    |> validate_format(:domain, ~r/^[a-zA-Z0-9][a-zA-Z0-9.-]+[a-zA-Z0-9]$/)
    |> unique_constraint([:domain, :type, :organization_id])
    |> foreign_key_constraint(:organization_id)
  end
end

Changeset Validation

Ecto changesets are the validation layer that enforces schema constraints at the application level. Every data mutation flows through a changeset, which validates the data before it reaches the database:

defmodule PrismaticPerimeter.Assets do
  @moduledoc "Context module for asset management operations."

  alias PrismaticPerimeter.{Repo, Schema.Asset}

  @spec create(map()) :: {:ok, Asset.t()} | {:error, Ecto.Changeset.t()}
  def create(attrs) do
    %Asset{}
    |> Asset.changeset(attrs)
    |> Repo.insert()
  end

  @spec update(Asset.t(), map()) :: {:ok, Asset.t()} | {:error, Ecto.Changeset.t()}
  def update(%Asset{} = asset, attrs) do
    asset
    |> Asset.changeset(attrs)
    |> Repo.update()
  end

  @spec validate(map()) :: {:ok, Ecto.Changeset.t()} | {:error, Ecto.Changeset.t()}
  def validate(attrs) do
    changeset = Asset.changeset(%Asset{}, attrs)

    if changeset.valid? do
      {:ok, changeset}
    else
      {:error, changeset}
    end
  end
end

The changeset pattern separates validation from persistence: you can validate data without inserting it into the database. This is useful for dry-run operations, preview modes, and API request validation.

Database Schema Design

Database schemas in the Prismatic Platform follow PostgreSQL best practices with explicit types, constraints, and indexes:

Design PrincipleImplementationBenefit
Explicit TypesEcto.Enum, :utc_datetime_usec, custom typesRuntime type safety
Not Nullvalidate_required/2 + DB constraintsNo surprise nils
Foreign Keysbelongs_to + foreign_key_constraintReferential integrity
Unique Constraintsunique_constraint + DB indexesNo duplicate data
Check Constraintscheck_constraint + DB checksDomain rule enforcement
Timestampstimestamps(type: :utc_datetime_usec)Audit trail, microsecond precision
Soft Deletes:status field with :decommissionedData preservation

Schema Migrations

Database schema changes are managed through versioned Ecto migrations that provide a deterministic, repeatable path from one schema version to another:

defmodule PrismaticPerimeter.Repo.Migrations.CreatePerimeterAssets do
  use Ecto.Migration

  def change do
    create table(:perimeter_assets) do
      add :domain, :string, null: false
      add :type, :string, null: false
      add :risk_score, :float
      add :status, :string, null: false, default: "active"
      add :first_seen, :utc_datetime_usec
      add :last_seen, :utc_datetime_usec
      add :metadata, :map, default: %{}
      add :organization_id, references(:perimeter_organizations, on_delete: :restrict)

      timestamps(type: :utc_datetime_usec)
    end

    create index(:perimeter_assets, [:domain, :type, :organization_id], unique: true)
    create index(:perimeter_assets, [:organization_id])
    create index(:perimeter_assets, [:status])
    create index(:perimeter_assets, [:risk_score])
    create index(:perimeter_assets, [:last_seen])
  end
end

Migrations are the only sanctioned way to modify the database schema. Direct DDL changes are forbidden because they break the migration chain and make deployments non-reproducible.

API Schema (OpenApiSpex)

The Prismatic API gateway uses OpenApiSpex to define API schemas that serve as the contract between API clients and the server:

defmodule PrismaticApi.Schemas.AssetResponse do
  @moduledoc false
  require OpenApiSpex

  OpenApiSpex.schema(%{
    title: "AssetResponse",
    description: "Response containing an attack surface asset",
    type: :object,
    required: [:data],
    properties: %{
      data: %OpenApiSpex.Schema{
        type: :object,
        required: [:id, :domain, :type],
        properties: %{
          id: %OpenApiSpex.Schema{type: :integer, description: "Unique identifier"},
          domain: %OpenApiSpex.Schema{type: :string, description: "Domain name or IP"},
          type: %OpenApiSpex.Schema{
            type: :string,
            enum: ["domain", "ip", "certificate", "service"],
            description: "Asset classification"
          },
          risk_score: %OpenApiSpex.Schema{
            type: :number,
            format: :float,
            minimum: 0.0,
            maximum: 10.0,
            description: "Risk score from 0.0 to 10.0"
          },
          status: %OpenApiSpex.Schema{
            type: :string,
            enum: ["active", "inactive", "decommissioned"],
            description: "Current asset status"
          },
          first_seen: %OpenApiSpex.Schema{
            type: :string,
            format: :"date-time",
            description: "When the asset was first discovered"
          },
          last_seen: %OpenApiSpex.Schema{
            type: :string,
            format: :"date-time",
            description: "When the asset was last observed"
          }
        }
      },
      links: %OpenApiSpex.Schema{
        type: :object,
        description: "HATEOAS navigation links",
        properties: %{
          self: %OpenApiSpex.Schema{type: :string},
          vulnerabilities: %OpenApiSpex.Schema{type: :string},
          organization: %OpenApiSpex.Schema{type: :string}
        }
      }
    }
  })
end

The API schema is automatically derived from the Ecto schema through the TypeMapper module, ensuring that database structure and API contract remain synchronized without manual duplication.

Schema Relationships

Schemas define relationships between entities that mirror real-world domain concepts:

RelationshipEcto MacroDatabase ImplementationExample
One-to-Manyhas_many / belongs_toForeign keyOrganization has many Assets
Many-to-Manymany_to_manyJoin tableAssets have many Tags
One-to-Onehas_one / belongs_toForeign key with unique constraintAsset has one SecurityRating
Embeddedembeds_one / embeds_manyJSONB columnAsset embeds many Findings
Self-referentialbelongs_to (self)Foreign key to same tableAsset parent/child hierarchy
defmodule PrismaticPerimeter.Schema.Organization do
  @moduledoc "Schema for monitored organizations."
  use Ecto.Schema

  schema "perimeter_organizations" do
    field :name, :string
    field :domain, :string
    field :industry, :string
    field :security_rating, :string

    has_many :assets, PrismaticPerimeter.Schema.Asset
    has_many :compliance_assessments, PrismaticPerimeter.Schema.ComplianceAssessment
    has_one :current_rating, PrismaticPerimeter.Schema.SecurityRating,
      where: [current: true]

    timestamps(type: :utc_datetime_usec)
  end
end

Schema Versioning

Schema evolution is a critical concern in production systems. The Prismatic Platform follows strict versioning practices:

StrategyWhen to UseMigration Approach
Add ColumnNew optional fieldalter table, add :field, :type
Add Required ColumnNew mandatory fieldAdd nullable, backfill, then add NOT NULL
Rename ColumnField name changeAdd new, copy data, remove old (3-step)
Remove ColumnDeprecated fieldRemove from schema first, then migration
Change TypeType evolutionAdd new column, migrate data, swap
Add IndexPerformance optimizationcreate_if_not_exists index(...)
Schema SplittingDecompositionExtract to new table with foreign key

Backward-Compatible Migrations

All migrations must be backward-compatible to support zero-downtime deployments:

defmodule PrismaticPerimeter.Repo.Migrations.AddConfidenceScoreToAssets do
  use Ecto.Migration

  def change do
    alter table(:perimeter_assets) do
      # New column is nullable -- backward compatible
      add :confidence_score, :float

      # New column with default -- backward compatible
      add :assessment_version, :integer, default: 1
    end

    # Index for query performance -- backward compatible
    create index(:perimeter_assets, [:confidence_score], where: "confidence_score IS NOT NULL")
  end
end

Embedded Schemas

For data that does not need its own table but still benefits from structural definition and validation, Ecto provides embedded schemas:

defmodule PrismaticPerimeter.Schema.Finding do
  @moduledoc "Embedded schema for risk assessment findings."
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :category, Ecto.Enum,
      values: [:network_security, :dns_health, :patching_cadence,
               :endpoint_security, :application_security, :leaked_credentials]
    field :severity, Ecto.Enum, values: [:critical, :high, :medium, :low, :informational]
    field :title, :string
    field :description, :string
    field :evidence, :string
    field :confidence, :float
    field :observed_at, :utc_datetime_usec
    field :remediation, :string
  end

  @spec changeset(t(), map()) :: Ecto.Changeset.t()
  def changeset(finding, attrs) do
    finding
    |> cast(attrs, [:category, :severity, :title, :description, :evidence,
                    :confidence, :observed_at, :remediation])
    |> validate_required([:category, :severity, :title])
    |> validate_number(:confidence, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 1.0)
  end
end

Embedded schemas are stored as JSONB in PostgreSQL, providing flexibility for semi-structured data while maintaining validation through changesets.

Graph Schema (KuzuDB)

The Prismatic Platform also uses graph schemas for knowledge representation in KuzuDB:

defmodule PrismaticStorageKuzu.Schema do
  @moduledoc "Graph schema definition for KuzuDB knowledge graph."

  @spec define_schema() :: :ok
  def define_schema do
    # Node schemas
    create_node_table("Module", [
      {"name", "STRING"},
      {"app", "STRING"},
      {"doc", "STRING"},
      {"line_count", "INT64"}
    ])

    create_node_table("Function", [
      {"name", "STRING"},
      {"arity", "INT64"},
      {"visibility", "STRING"},
      {"spec", "STRING"}
    ])

    # Relationship schemas
    create_rel_table("DEFINES", "Module", "Function", [
      {"line", "INT64"}
    ])

    create_rel_table("CALLS", "Function", "Function", [
      {"call_count", "INT64"}
    ])

    create_rel_table("DEPENDS_ON", "Module", "Module", [
      {"type", "STRING"}
    ])
  end
end

Schema Validation Patterns

The Prismatic Platform implements several validation patterns through schemas:

PatternMechanismUse Case
Required Fieldsvalidate_required/2Mandatory data presence
Format Validationvalidate_format/3Regex pattern matching (emails, domains)
Inclusionvalidate_inclusion/3Enum value validation
Rangevalidate_number/3Numeric bounds checking
Lengthvalidate_length/3String length constraints
Customvalidate_change/3Complex business logic
Uniqueunique_constraint/3Database-level uniqueness
Cross-fieldCustom validation functionsFields that depend on each other
defp validate_risk_assessment(changeset) do
  changeset
  |> validate_required([:domain, :type])
  |> validate_format(:domain, ~r/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
  |> validate_number(:risk_score,
    greater_than_or_equal_to: 0.0,
    less_than_or_equal_to: 10.0
  )
  |> validate_coherent_dates()
end

defp validate_coherent_dates(changeset) do
  first_seen = get_field(changeset, :first_seen)
  last_seen = get_field(changeset, :last_seen)

  if first_seen && last_seen && DateTime.compare(first_seen, last_seen) == :gt do
    add_error(changeset, :last_seen, "must be after first_seen")
  else
    changeset
  end
end

Schema-First Design

The Prismatic Platform follows a schema-first design philosophy where the schema is defined before implementation:

PhaseActivityArtifact
1. DesignDefine data structure and constraintsEcto schema module
2. MigrateCreate database tables and indexesEcto migration
3. ValidateDefine changeset validation rulesChangeset functions
4. APIGenerate OpenAPI schemaOpenApiSpex schema
5. TestWrite property-based tests against schemaExUnit + StreamData
6. DocumentSchema documentation in module docs@moduledoc

This approach ensures that all layers of the application agree on data structure from the start, reducing integration bugs and making changes traceable.

Context in Prismatic

Schemas are the structural foundation of the Prismatic Platform's data architecture. Every persistent entity -- from Perimeter assets and vulnerability findings to agent configurations and quality metrics -- is defined through Ecto schemas with typed fields, validated through changesets, and persisted to PostgreSQL through migrations. The API gateway auto-generates OpenAPI schemas from Elixir type specifications, ensuring that API contracts stay synchronized with database structure without manual duplication.

The platform's schema design enforces data quality at multiple levels: type checking at compile time through Dialyzer and @spec annotations, validation at runtime through Ecto changesets, and constraint enforcement at the database level through PostgreSQL constraints and indexes. This defense-in-depth approach to data integrity aligns with the NO MERCY doctrine: invalid data is rejected at the earliest possible point, never allowed to propagate through the system.

  • Database -- Storage systems that schemas define structure for
  • PostgreSQL -- Primary relational database with schema enforcement
  • Validation -- Data validation implemented through schema changesets
  • Typespec -- Elixir type specifications complementing Ecto schemas
  • API Gateway -- Generates API schemas from Ecto definitions
  • REST API -- Interface layer consuming schema-defined data
  • Swagger UI -- Interactive documentation powered by API schemas
  • Protocol -- Elixir protocols defining behavior contracts alongside schemas
  • Relational Database -- Database paradigm built on schema-defined tables
  • Dialyzer -- Type checker validating schema type annotations
  • Prismatic Storage -- Storage layer built on schema definitions
  • Property-Based Testing -- Testing approach generating data from schemas

See Also

  • Architecture -- Platform data architecture and schema design
  • Apps -- Application schemas across umbrella apps
  • Technologies -- Ecto, PostgreSQL, and OpenApiSpex
  • Capabilities -- Data modeling and validation capabilities

Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL