feat(ops_db): Ecto schemas for five Ops DB tables

Direct translation of DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.2:
- AccordBinding: governance context per artifact
- GovernedArtifact: registry with JSONB content + content_schema
- CustodyTransition: append-only chain of custody
- DeploymentState: current deployment per (artifact, target)
- VerificationResult: continuous attestation records
- ProjectorCheckpoint: Chronicle projector resume state

All JSONB fields use Ecto :map type (Postgres JSONB).
GIN indexes (jsonb_path_ops) on accord_terms and content columns.
Partitioning notes for time-series tables (DBA applies in prod).
Migrations renumbered to enforce FK dependency order
(accord_bindings → governed_artifacts → dependents).

Dev seed data (priv/repo/seeds.exs) creates nine governed artifacts
matching the JSONB content examples in the design doc §2.3
(Intune profile, DNS zone, DSC MOF, YANG config, Helm values,
JEA role, Conditional Access, TLS cert, OCI image) plus custody
transitions, deployment states, and verification results.

Verified: mix ecto.create + mix ecto.migrate + mix run seeds all
pass; 9 artifacts in governed_artifacts table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-18 07:17:51 -04:00
parent 4e22729bef
commit 4d9acf96d8
13 changed files with 671 additions and 0 deletions

View file

@ -0,0 +1,47 @@
defmodule Guildhall.OpsDb.AccordBinding do
@moduledoc """
Governance context under which an artifact is governed.
Schema per DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.2.5.
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
schema "accord_bindings" do
field :accord_cid, :string
field :accord_name, :string
field :accord_terms, :map, default: %{}
field :member_dids, {:array, :string}, default: []
field :witness_dids, {:array, :string}, default: []
field :tier_policy, :map, default: %{}
field :ceremony_classes, {:array, :string}, default: []
field :verification_interval_sec, :integer
field :chronicle_retention_days, :integer
field :effective_from, :utc_datetime_usec
field :effective_until, :utc_datetime_usec
has_many :governed_artifacts, Guildhall.OpsDb.GovernedArtifact
timestamps(type: :utc_datetime_usec, inserted_at: :created_at, updated_at: false)
end
def changeset(binding, attrs) do
binding
|> cast(attrs, [
:accord_cid,
:accord_name,
:accord_terms,
:member_dids,
:witness_dids,
:tier_policy,
:ceremony_classes,
:verification_interval_sec,
:chronicle_retention_days,
:effective_from,
:effective_until
])
|> validate_required([:accord_cid, :accord_name])
|> unique_constraint(:accord_cid)
end
end

View file

@ -0,0 +1,46 @@
defmodule Guildhall.OpsDb.CustodyTransition do
@moduledoc """
Append-only chain of custody. Each row is one custody event
ordered by `sequence_number`.
Schema per DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.2.2.
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
@primary_key {:id, :id, autogenerate: true}
schema "custody_transitions" do
field :sequence_number, :integer
field :from_state, :string
field :to_state, :string
field :actor_did, :string
field :ceremony_id, :string
field :witness_sigs, {:array, :map}, default: []
field :governance_envelope_hash, :string
field :chronicle_entry_id, :string
field :content_diff, :map, default: %{}
field :transitioned_at, :utc_datetime_usec
belongs_to :governed_artifact, Guildhall.OpsDb.GovernedArtifact
end
def changeset(transition, attrs) do
transition
|> cast(attrs, [
:sequence_number,
:from_state,
:to_state,
:actor_did,
:ceremony_id,
:witness_sigs,
:governance_envelope_hash,
:chronicle_entry_id,
:content_diff,
:transitioned_at,
:governed_artifact_id
])
|> validate_required([:sequence_number, :to_state, :transitioned_at, :governed_artifact_id])
|> foreign_key_constraint(:governed_artifact_id)
end
end

View file

@ -0,0 +1,47 @@
defmodule Guildhall.OpsDb.DeploymentState do
@moduledoc """
Current deployment per (artifact, target). Updated on each deployment,
rewound on rollback.
Schema per DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.2.3.
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
schema "deployment_states" do
field :target_kind, :string
field :target_identifier, :string
field :deployed_cid, :string
field :desired_cid, :string
field :target_config, :map, default: %{}
field :last_deployed_at, :utc_datetime_usec
field :last_reconciled_at, :utc_datetime_usec
field :drift_status, :string
belongs_to :governed_artifact, Guildhall.OpsDb.GovernedArtifact
timestamps(type: :utc_datetime_usec)
end
def changeset(state, attrs) do
state
|> cast(attrs, [
:target_kind,
:target_identifier,
:deployed_cid,
:desired_cid,
:target_config,
:last_deployed_at,
:last_reconciled_at,
:drift_status,
:governed_artifact_id
])
|> validate_required([:target_kind, :target_identifier, :governed_artifact_id])
|> validate_inclusion(:drift_status, [nil, "match", "drift", "unknown"])
|> unique_constraint([:governed_artifact_id, :target_kind, :target_identifier],
name: :deployment_states_artifact_target_uniq
)
|> foreign_key_constraint(:governed_artifact_id)
end
end

View file

@ -0,0 +1,53 @@
defmodule Guildhall.OpsDb.GovernedArtifact do
@moduledoc """
Registry of governed artifacts.
Schema per DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.2.1.
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
schema "governed_artifacts" do
field :cid, :string
field :artifact_type, :string
field :artifact_name, :string
field :artifact_runtime, :string
field :content, :map, default: %{}
field :content_schema, :map, default: %{}
field :sbom_cid, :string
field :tier, :string
field :shell_class_required, :string
field :chronicle_entry_id, :string
field :human_authored, :boolean, default: true
belongs_to :accord_binding, Guildhall.OpsDb.AccordBinding
has_many :custody_transitions, Guildhall.OpsDb.CustodyTransition
has_many :deployment_states, Guildhall.OpsDb.DeploymentState
has_many :verification_results, Guildhall.OpsDb.VerificationResult
timestamps(type: :utc_datetime_usec, inserted_at: :created_at, updated_at: false)
end
def changeset(artifact, attrs) do
artifact
|> cast(attrs, [
:cid,
:artifact_type,
:artifact_name,
:artifact_runtime,
:content,
:content_schema,
:sbom_cid,
:tier,
:shell_class_required,
:chronicle_entry_id,
:human_authored,
:accord_binding_id
])
|> validate_required([:cid, :artifact_type, :artifact_name])
|> unique_constraint(:cid)
|> foreign_key_constraint(:accord_binding_id)
end
end

View file

@ -0,0 +1,22 @@
defmodule Guildhall.OpsDb.ProjectorCheckpoint do
@moduledoc """
Chronicle projector resume state. See
DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.5.
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
@primary_key {:source, :string, autogenerate: false}
schema "projector_checkpoint" do
field :last_entry_id, :string
field :last_processed_at, :utc_datetime_usec
end
def changeset(checkpoint, attrs) do
checkpoint
|> cast(attrs, [:source, :last_entry_id, :last_processed_at])
|> validate_required([:source])
end
end

View file

@ -0,0 +1,46 @@
defmodule Guildhall.OpsDb.VerificationResult do
@moduledoc """
Continuous attestation records. High-volume time-series table;
partitioned monthly in production.
Schema per DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.2.4.
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
schema "verification_results" do
field :verified_cid, :string
field :target_kind, :string
field :target_identifier, :string
field :layer, :string
field :verdict, :string
field :evidence, :map, default: %{}
field :verified_at, :utc_datetime_usec
field :verifier_did, :string
field :verifier_infrastructure, :string
belongs_to :governed_artifact, Guildhall.OpsDb.GovernedArtifact
end
def changeset(result, attrs) do
result
|> cast(attrs, [
:verified_cid,
:target_kind,
:target_identifier,
:layer,
:verdict,
:evidence,
:verified_at,
:verifier_did,
:verifier_infrastructure,
:governed_artifact_id
])
|> validate_required([:verified_cid, :verdict, :verified_at, :governed_artifact_id])
|> validate_inclusion(:verdict, ["match", "drift", "error"])
|> validate_inclusion(:layer, [nil, "layer1", "layer2", "layer3"])
|> validate_inclusion(:verifier_infrastructure, [nil, "local", "external", "insurer"])
|> foreign_key_constraint(:governed_artifact_id)
end
end

View file

@ -0,0 +1,29 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateAccordBindings do
use Ecto.Migration
def change do
create table(:accord_bindings) do
add :accord_cid, :string, null: false
add :accord_name, :string, null: false
add :accord_terms, :map, null: false, default: %{}
add :member_dids, {:array, :string}, default: []
add :witness_dids, {:array, :string}, default: []
add :tier_policy, :map, default: %{}
add :ceremony_classes, {:array, :string}, default: []
add :verification_interval_sec, :integer
add :chronicle_retention_days, :integer
add :effective_from, :utc_datetime_usec
add :effective_until, :utc_datetime_usec
timestamps(type: :utc_datetime_usec, inserted_at: :created_at, updated_at: false)
end
create unique_index(:accord_bindings, [:accord_cid])
create index(:accord_bindings, [:accord_name])
execute(
"CREATE INDEX accord_bindings_terms_gin_idx ON accord_bindings USING GIN (accord_terms jsonb_path_ops)",
"DROP INDEX accord_bindings_terms_gin_idx"
)
end
end

View file

@ -0,0 +1,32 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateGovernedArtifacts do
use Ecto.Migration
def change do
create table(:governed_artifacts) do
add :cid, :string, null: false
add :artifact_type, :string, null: false
add :artifact_name, :string, null: false
add :artifact_runtime, :string
add :content, :map, null: false, default: %{}
add :content_schema, :map, default: %{}
add :sbom_cid, :string
add :tier, :string
add :shell_class_required, :string
add :chronicle_entry_id, :string
add :human_authored, :boolean, default: true
add :accord_binding_id, references(:accord_bindings, on_delete: :restrict)
timestamps(type: :utc_datetime_usec, inserted_at: :created_at, updated_at: false)
end
create unique_index(:governed_artifacts, [:cid])
create index(:governed_artifacts, [:artifact_type])
create index(:governed_artifacts, [:accord_binding_id])
create index(:governed_artifacts, [:tier])
execute(
"CREATE INDEX governed_artifacts_content_gin_idx ON governed_artifacts USING GIN (content jsonb_path_ops)",
"DROP INDEX governed_artifacts_content_gin_idx"
)
end
end

View file

@ -0,0 +1,33 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateCustodyTransitions do
@moduledoc """
Append-only chain of custody. Partitioning note: production deployments
should apply RANGE partitioning on `transitioned_at` (quarterly) per
DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.2.2. The DBA applies partition
DDL after migration; Ecto does not manage partitions.
"""
use Ecto.Migration
def change do
create table(:custody_transitions) do
add :sequence_number, :bigint, null: false
add :from_state, :string
add :to_state, :string, null: false
add :actor_did, :string
add :ceremony_id, :string
add :witness_sigs, {:array, :map}, default: []
add :governance_envelope_hash, :string
add :chronicle_entry_id, :string
add :content_diff, :map, default: %{}
add :transitioned_at, :utc_datetime_usec, null: false
add :governed_artifact_id,
references(:governed_artifacts, on_delete: :restrict),
null: false
end
create index(:custody_transitions, [:governed_artifact_id, :sequence_number])
create index(:custody_transitions, [:ceremony_id])
create index(:custody_transitions, [:actor_did])
create index(:custody_transitions, [:transitioned_at])
end
end

View file

@ -0,0 +1,31 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateDeploymentStates do
use Ecto.Migration
def change do
create table(:deployment_states) do
add :target_kind, :string, null: false
add :target_identifier, :string, null: false
add :deployed_cid, :string
add :desired_cid, :string
add :target_config, :map, default: %{}
add :last_deployed_at, :utc_datetime_usec
add :last_reconciled_at, :utc_datetime_usec
add :drift_status, :string
add :governed_artifact_id,
references(:governed_artifacts, on_delete: :restrict),
null: false
timestamps(type: :utc_datetime_usec)
end
create unique_index(
:deployment_states,
[:governed_artifact_id, :target_kind, :target_identifier],
name: :deployment_states_artifact_target_uniq
)
create index(:deployment_states, [:drift_status])
create index(:deployment_states, [:target_kind])
end
end

View file

@ -0,0 +1,30 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateVerificationResults do
@moduledoc """
Continuous attestation records. Partitioning note: production deployments
should apply RANGE partitioning on `verified_at` (monthly) per
DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.2.4. Ecto does not manage partitions.
"""
use Ecto.Migration
def change do
create table(:verification_results) do
add :verified_cid, :string, null: false
add :target_kind, :string
add :target_identifier, :string
add :layer, :string
add :verdict, :string, null: false
add :evidence, :map, default: %{}
add :verified_at, :utc_datetime_usec, null: false
add :verifier_did, :string
add :verifier_infrastructure, :string
add :governed_artifact_id,
references(:governed_artifacts, on_delete: :restrict),
null: false
end
create index(:verification_results, [:governed_artifact_id, :verified_at])
create index(:verification_results, [:verdict, :verified_at])
create index(:verification_results, [:verifier_infrastructure])
end
end

View file

@ -0,0 +1,17 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateProjectorCheckpoint do
@moduledoc """
Chronicle projector resume state. See §2.5 of
DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 the projector persists its
last processed entry_id so it can resume from the correct position
after a restart.
"""
use Ecto.Migration
def change do
create table(:projector_checkpoint, primary_key: false) do
add :source, :string, primary_key: true
add :last_entry_id, :string
add :last_processed_at, :utc_datetime_usec
end
end
end

View file

@ -0,0 +1,238 @@
# Dev seed data for guildhall_ops_db.
# Matches the nine JSONB content examples in
# DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 §2.3.
#
# Run with: mix run apps/guildhall_ops_db/priv/repo/seeds.exs
alias Guildhall.OpsDb.{
Repo,
AccordBinding,
GovernedArtifact,
CustodyTransition,
DeploymentState,
VerificationResult
}
now = DateTime.utc_now()
# ────────────────────────────────────────────────────────
# 1. Accord binding (shared by all artifacts below)
# ────────────────────────────────────────────────────────
accord =
Repo.insert!(%AccordBinding{
accord_cid: "sha256:dev-accord-example",
accord_name: "acme-saas-dev",
member_dids: ["did:web:guildhouse.dev:user:tking"],
witness_dids: ["did:web:guildhouse.dev:user:tking"],
ceremony_classes: ["SingleApproval"],
verification_interval_sec: 300,
chronicle_retention_days: 90,
effective_from: now,
accord_terms: %{
"name" => "Development Accord",
"tier_policy" => %{"default" => "a"}
}
})
# ────────────────────────────────────────────────────────
# 2. Governed artifacts — nine examples from §2.3
# ────────────────────────────────────────────────────────
artifacts = [
%{
cid: "sha256:intune-profile-example",
artifact_type: "intune_config_profile",
artifact_name: "Production Baseline",
artifact_runtime: "intune",
tier: "b",
content: %{
"@odata.type" => "#microsoft.graph.windows10GeneralConfiguration",
"displayName" => "Production Baseline",
"bitLockerEncryptDevice" => true,
"defenderRequireRealTimeMonitoring" => true,
"passwordMinimumLength" => 12,
"assignments" => [%{"groupId" => "corp-devices"}]
}
},
%{
cid: "sha256:dns-zone-example",
artifact_type: "dns_zone",
artifact_name: "guildhouse.dev zone",
artifact_runtime: "zone",
tier: "c",
content: %{
"zone_apex" => "guildhouse.dev.",
"serial" => 2_026_041_701,
"records" => [
%{"name" => "@", "type" => "SOA", "rdata" => "ns1.guildhouse.dev. ..."},
%{"name" => "@", "type" => "NS", "rdata" => "ns1.guildhouse.dev."},
%{"name" => "auth", "type" => "A", "rdata" => "10.0.0.10"}
]
}
},
%{
cid: "sha256:dsc-bitlocker-example",
artifact_type: "dsc_configuration",
artifact_name: "EnsureBitLockerEnabled",
artifact_runtime: "mof",
tier: "b",
content: %{
"instances" => [
%{
"class" => "MSFT_BitLockerResource",
"name" => "EnsureBitLockerEnabled",
"properties" => %{
"MountPoint" => "C:",
"PrimaryProtector" => "TpmProtector",
"AutoUnlock" => true
}
}
]
}
},
%{
cid: "sha256:yang-running-config-example",
artifact_type: "restconf_config",
artifact_name: "edge-router-1 interfaces",
artifact_runtime: "json",
tier: "c",
content: %{
"ietf-interfaces:interfaces" => %{
"interface" => [
%{
"name" => "GigabitEthernet0/0",
"type" => "iana-if-type:ethernetCsmacd",
"enabled" => true
}
]
}
}
},
%{
cid: "sha256:helm-values-example",
artifact_type: "helm_values",
artifact_name: "substrate-operator production values",
artifact_runtime: "values",
tier: "b",
content: %{
"bound_chart_cid" => "sha256:abc...",
"environment" => "production",
"image" => %{"digest" => "sha256:def..."},
"replicas" => 3
}
},
%{
cid: "sha256:jea-application-role-example",
artifact_type: "jea_role_capability",
artifact_name: "Application-class JEA role",
artifact_runtime: "psrc",
tier: "c",
content: %{
"visible_cmdlets" => ["Get-Service", "Get-Process", "Test-DscConfiguration"],
"visible_providers" => ["FileSystem"],
"language_mode" => "ConstrainedLanguage",
"session_type" => "RestrictedRemoteServer"
}
},
%{
cid: "sha256:conditional-access-example",
artifact_type: "entra_conditional_access",
artifact_name: "Require compliant device",
artifact_runtime: "json",
tier: "c",
content: %{
"displayName" => "Require compliant device for corp resources",
"conditions" => %{"users" => %{"includeGroups" => ["all-employees"]}},
"grantControls" => %{"builtInControls" => ["compliantDevice"]}
}
},
%{
cid: "sha256:tls-keycloak-cert-example",
artifact_type: "tls_server_cert",
artifact_name: "keycloak.guildhouse.dev",
artifact_runtime: "x509",
tier: "b",
content: %{
"subject" => "CN=keycloak.guildhouse.dev",
"not_before" => "2026-04-17T00:00:00Z",
"not_after" => "2027-04-17T00:00:00Z",
"fingerprint_sha256" => "abc...",
"ct_log_refs" => []
}
},
%{
cid: "sha256:oci-image-example",
artifact_type: "oci_image",
artifact_name: "ghcr.io/guildhouse/substrate-operator",
artifact_runtime: "oci",
tier: "b",
content: %{
"image_reference" => "ghcr.io/guildhouse/substrate-operator",
"digest" => "sha256:a1b2c3...",
"architectures" => ["linux/amd64", "linux/arm64"],
"sbom_cid" => "sha256:..."
}
}
]
inserted =
Enum.map(artifacts, fn attrs ->
%GovernedArtifact{accord_binding_id: accord.id, human_authored: true}
|> GovernedArtifact.changeset(attrs)
|> Repo.insert!()
end)
# ────────────────────────────────────────────────────────
# 3. One custody transition per artifact (proposed → ceremony_open)
# ────────────────────────────────────────────────────────
Enum.each(inserted, fn artifact ->
Repo.insert!(%CustodyTransition{
governed_artifact_id: artifact.id,
sequence_number: 1,
from_state: "proposed",
to_state: "ceremony_open",
actor_did: "did:web:guildhouse.dev:user:tking",
ceremony_id: "ceremony-dev-#{artifact.id}",
witness_sigs: [],
transitioned_at: now
})
end)
# ────────────────────────────────────────────────────────
# 4. Mix of deployment_states (healthy / drifted)
# ────────────────────────────────────────────────────────
inserted
|> Enum.with_index()
|> Enum.each(fn {artifact, idx} ->
drift = if rem(idx, 3) == 0, do: "drift", else: "match"
Repo.insert!(%DeploymentState{
governed_artifact_id: artifact.id,
target_kind: "dev-cluster",
target_identifier: "hetzner-talos",
deployed_cid: artifact.cid,
desired_cid: artifact.cid,
last_deployed_at: now,
last_reconciled_at: now,
drift_status: drift
})
end)
# ────────────────────────────────────────────────────────
# 5. Recent verification results
# ────────────────────────────────────────────────────────
Enum.each(inserted, fn artifact ->
Repo.insert!(%VerificationResult{
governed_artifact_id: artifact.id,
verified_cid: artifact.cid,
target_kind: "dev-cluster",
target_identifier: "hetzner-talos",
layer: "layer1",
verdict: "match",
verified_at: now,
verifier_did: "did:web:guildhouse.dev:verifier:local",
verifier_infrastructure: "local",
evidence: %{"method" => "seed_data"}
})
end)
IO.puts("Seeded #{length(inserted)} governed artifacts under Accord #{accord.accord_name}.")