Unified Config Asset Model — P0.3 Design Doc
Date: 2026-07-07 Author: zongzheng (with Claude Code) Status: Historical data-model design; runtime-consumption sections corrected by ADR-046 Ancestry: current config-asset code · PRD prd-specialist-value-output R1–R3/R6 · ADR-020 · ADR-030 · ADR-033 L0/L1 · ADR-034 · ADR-035 (Proposed) · ADR-037 Gates: Phase 1 (foundation entities) · Phase 2 (asset onboarding). The former Humanwork unified-assembly phase is retired; Hermes owns runtime context.
1. Goals & Non-Goals
Goal: Define one storage model, version model, publish state machine, and validation pipeline for the five Specialist config asset types (Soul / Skill / KB golden answer / KB domain rule / tool binding) — so version control, audit, rollback, eval gating, and archetype inheritance are each implemented exactly once.
Non-goals:
- Not a general-purpose config platform: the foundation is designed only for these five known
asset_types; no extension points are added before a sixth type appears. - KB document bodies (K1/K2 uploaded documents, Haystack index, ADR-008 pipeline) are not migrated in — only curated assets enter the foundation.
- Hermes-native prompt/session configuration is not an asset type in this data model. Published persona and skill assets may be established once in AgentFS when a new session is created; Humanwork never assembles them per turn.
- The eval gate harness/runner implementation (Phase 4, P4.1) — this document only reserves the hook point.
2. Foundation Data Model
2.1 specialist_config_assets (asset master table)
ADR-020 isolation matrix row (entity docstring must be declared verbatim): the
org_instancerow belongs to row-1 class (Org × Specialist) — every query must filter onorg_idANDspecialist_idsimultaneously; thecatalogrow is a platform asset (SuperAdmin surface),org_id IS NULL, read only via an explicit catalog branch at the service layer, and must never be mixed into tenant query results. Before RLS is fully pushed down (issue #3), isolation is enforced at the service-query layer.
| Column | Type | Constraint/Default | Notes |
|---|---|---|---|
id | uuid | PK, gen_random_uuid() | |
asset_type | varchar(32) | NOT NULL, CHECK IN (soul,skill,kb_golden_answer,kb_domain_rule,tool_binding) | Closed enum; new types require a migration |
scope | varchar(16) | NOT NULL, CHECK IN (catalog,org_instance) | |
org_id | uuid | NULL, FK→organizations | catalog rows must be NULL; org_instance rows must be NOT NULL (see CHECK below) |
specialist_id | uuid | NOT NULL, FK→specialists | catalog rows point to the archetype Specialist (the specialists table already has a catalog field) |
slug | varchar(64) | NOT NULL | kebab-case, unique within scope (see indexes) |
display_name | varchar(128) | NOT NULL | |
archetype_asset_id | uuid | NULL, FK→self | The instance's archetype lineage (§7); always NULL for catalog rows |
published_version_id | uuid | NULL, FK→versions (deferred constraint) | Pointer to the currently live version; NULL = never published |
draft_version_id | uuid | NULL, FK→versions | Pointer to the single current draft-in-progress; at most one draft per asset at a time |
source | varchar(24) | NOT NULL DEFAULT manual, CHECK IN (manual,imported_repo,loop_proposal) | loop_proposal = R4.4 proposal (ADR-037) |
metadata | jsonb | NULL | Asset-level notes: materialization source hash (materializedFromHash, §7), import source path, etc.; not part of assembly, not part of content hash |
created_by | uuid | NOT NULL | user id |
created_at / updated_at | timestamptz | NOT NULL DEFAULT now() | |
archived_at | timestamptz | NULL | Soft delete; retrieval/assembly always filters archived_at IS NULL |
CHECK constraint (scope-shape coupling, prevents "half-catalog" rows):
CHECK ( (scope = 'catalog' AND org_id IS NULL AND archetype_asset_id IS NULL)
OR (scope = 'org_instance' AND org_id IS NOT NULL) )
Indexes:
-- unique slug within scope (two partial-unique indexes, since org_id can be NULL)
CREATE UNIQUE INDEX uq_config_assets_instance_slug
ON specialist_config_assets (asset_type, org_id, specialist_id, slug)
WHERE scope = 'org_instance' AND archived_at IS NULL;
CREATE UNIQUE INDEX uq_config_assets_catalog_slug
ON specialist_config_assets (asset_type, specialist_id, slug)
WHERE scope = 'catalog' AND archived_at IS NULL;
-- assembly hot query: all live assets for a given (org, specialist)
CREATE INDEX idx_config_assets_lookup
ON specialist_config_assets (org_id, specialist_id, asset_type)
WHERE archived_at IS NULL;
-- lineage reverse lookup ("upstream changed?" scan)
CREATE INDEX idx_config_assets_archetype
ON specialist_config_assets (archetype_asset_id)
WHERE archetype_asset_id IS NOT NULL;
Why org_id uses NULL instead of a sentinel org (explicitly documented difference from ADR-008 K2): K2 uses the HP_GLOBAL_ORG_UUID sentinel because retrieval filters need a unified query shape of org_id IN (org, global) (retrieval is a fan-in read). Config assets have no such fan-in read — catalog and instance go through two explicit branches at the service layer (catalog listing page vs. tenant config page); NULL semantics are honest, the CHECK can enforce shape coupling, and this avoids a "magic org" entering the FK graph. The assembler only reads org_instance rows at read time (catalog content is already copied into the instance after materialization, §7) — there is no cross-scope join.
2.2 specialist_config_versions (version table, shared across all types)
ADR-020: Attached to the owning asset row (reading a version always goes through the asset row's scope check); docstring declaration same as §2.1.
| Column | Type | Constraint/Default | Notes |
|---|---|---|---|
id | uuid | PK | |
asset_id | uuid | NOT NULL, FK→assets ON DELETE CASCADE | |
version_no | int | NOT NULL | Monotonically increasing within the asset; UNIQUE(asset_id, version_no) |
content | jsonb | NOT NULL | Written after validation against the asset_type's Zod schema (§3); large text goes to R2 as a pointer object (§4) |
content_r2_key | varchar(512) | NULL | R2 object key; when non-NULL, content contains a {"$r2": true, ...} pointer |
content_hash | varchar(64) | NOT NULL | sha256(canonical-JSON(content) ‖ R2 bytes); used for both import idempotency and pull detection (§7) |
publish_status | varchar(16) | NOT NULL DEFAULT draft, CHECK IN (draft,publishing,published,blocked,rolled_back,discarded) | State machine in §5 |
eval_run_id | uuid | NULL | Wired to EvalRun in Phase 4; always NULL while the gate is unwired (§5.3 transitional semantics) |
publish_note | varchar(1000) | NULL | Required on override (written reason, PRD R4 AC2) |
author_id | uuid | NOT NULL | |
created_at | timestamptz | NOT NULL DEFAULT now() | |
published_at / published_by | timestamptz / uuid | NULL |
Indexes: UNIQUE(asset_id, version_no); idx_config_versions_asset (asset_id, created_at DESC).
DB-level enforcement of invariants (textual rules must be hardened into constraints, to prevent concurrency bypass):
-- at most one draft-in-progress per asset (hardening of §2.1 draft_version_id semantics)
CREATE UNIQUE INDEX uq_config_versions_one_draft
ON specialist_config_versions (asset_id) WHERE publish_status = 'draft';
-- at most one publishing-in-progress version per asset (hardening of §5.1 T1 precondition)
-- also doubles as the scan surface for the async eval worker
CREATE UNIQUE INDEX uq_config_versions_one_publishing
ON specialist_config_versions (asset_id) WHERE publish_status = 'publishing';
State machine concurrency: all state transitions use an atomic claim (root CLAUDE.md idempotency contract #3):
UPDATE specialist_config_versions SET publish_status = $next, ...
WHERE id = $id AND publish_status = $prev RETURNING *;
2.3 Expand-only migration order
CREATE TABLE specialist_config_assets(without the two pointer columns to versions);CREATE TABLE specialist_config_versions+ FK→assets;ALTER TABLE specialist_config_assets ADD COLUMN published_version_id / draft_version_id+ FK→versions (NOT VALIDthenVALIDATE, to avoid a circular FK during table creation);- Indexes
CONCURRENTLY(following the transaction=false + post-check indisvalid pattern used by1806600000000-AddReleaseSignalIndex.ts); - Import backfill (Phase 1 P1.3 / Phase 2, separate PR); the deprecation contract for
specialists.systemPrompt/specialists.tools[](dropped columns) is a separate migration after full cutover + 4 weeks unused (PRD R1.2 deprecation criteria, master plan P2.5).
3. Content Zod schema for the five asset_types (first draft)
Unified convention: every operator-visible string field goes through the noInfraDisclosure() refinement (NFR5 — reuses scrubFilesystemPaths's detection regex + #3054 rules to validate rather than sanitize: rejected at save with a field-level error, same experience as PRD R1 AC3). All max values are publication-time validation bounds. They do not define a per-turn prompt budget or authorize Humanwork to assemble model context.
// api/src/config-assets/schemas/soul.schema.ts (R1.1)
export const SoulContent = z.object({
identity: z.object({
displayName: z.string().min(1).max(64),
roleTitle: z.string().min(1).max(128),
bio: z.string().max(2_000),
}),
voice: z.object({
tone: z.array(z.string().max(64)).max(8), // e.g. "warm", "concise"
styleRules: z.array(z.string().max(300)).max(12), // "always use active voice…"
}),
boundaries: z.object({
never: z.array(z.string().max(300)).max(16), // things to never do/say
escalate: z.array(z.string().max(300)).max(16), // escalate on encounter
}),
languages: z.array(z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/)).min(1).max(8),
signOff: z.object({ enabled: z.boolean(), template: z.string().max(200) }).optional(),
}); // overall serialization budget ≤ SOUL_BUDGET_CHARS (§6 budget table) — rejected at save if exceeded, never truncated at runtime
// skill.schema.ts (R2.1; #3447 archetype/instance share the same schema)
export const SkillContent = z.object({
trigger: z.object({
description: z.string().max(500), // human-readable scenario description
topics: z.array(z.string().max(64)).min(1).max(12),
keywords: z.array(z.string().max(64)).max(24), // matching input for the Phase 3 trigger engine
}),
instructions: z.string().min(1).max(12_000),
responseTemplates: z.array(z.object({
name: z.string().max(64),
body: z.string().max(4_000),
})).max(8),
escalationRules: z.array(z.string().max(300)).max(12),
redLines: z.array(z.object({ // structured red lines (data basis for R2.5)
id: z.string().regex(/^[a-z0-9-]{1,48}$/),
rule: z.string().min(1).max(500),
severity: z.enum(["block", "flag"]), // compatibility metadata; Hermes owns runtime handling, never a Humanwork post-reply gate
})).max(24),
tools: z.array(z.string().regex(/^[a-z0-9_]{1,64}$/)).default([]), // reserved: tool slug (consumed by R6/P4.7, skills∩bindings)
});
// ── AS-BUILT (Phase 2A, implemented in api/src/config-assets/schemas/skill.schema.ts) ──
// Implementation deviations from the first draft above (all to align with existing soul.schema.ts conventions, not semantic changes):
// 1. All operator-visible string fields use the shared helper opStr(max)=z.string().max(max).superRefine(noInfraDisclosure)
// to enforce NFR5 (the draft only stated in §3's intro text that fields "go through noInfraDisclosure"; the implementation applies it per field).
// Regex-only identifier fields (redLines[].id, tools[]) keep bare z.string().regex(...).
// 2. keywords / responseTemplates / escalationRules / redLines get .default([]) (stable default when omitted by importer/form).
// 3. instructions / redLines[].rule use opStr(N).min(1) (zod v3 convention, same as soul's identity.displayName),
// equivalent to the draft's "non-empty + length-limited + NFR5", without using .pipe().
// 4. A top-level .superRefine errors when JSON.stringify(val).length > SKILL_BUDGET_CHARS(16k) (layer-⑤ budget, rejected at save, not truncated at runtime).
// kb-golden-answer.schema.ts (R3.1/R3.2)
const Applicability = z.object({
effectiveFrom: z.string().date().optional(),
effectiveTo: z.string().date().optional(),
audience: z.array(z.string().max(64)).optional(), // e.g. ["retail","vip"]
jurisdiction: z.array(z.string().max(8)).optional(), // ISO 3166
});
export const KbGoldenAnswerContent = z.object({
question: z.string().min(1).max(1_000),
answer: z.string().min(1).max(6_000), // the master copy for verbatim-with-adaptation (ADR-033 L1)
sources: z.array(z.string().url()).max(8),
applicability: Applicability.default({}),
adaptationNotes: z.string().max(1_000).optional(), // what rewriting is permitted
});
// kb-domain-rule.schema.ts (R3.2)
export const KbDomainRuleContent = z.object({
statement: z.string().min(1).max(2_000),
category: z.enum(["policy", "eligibility", "process", "fact"]),
applicability: Applicability.default({}),
references: z.array(z.string().max(300)).max(8),
});
// tool-binding.schema.ts (R6.2 storage half only)
// ⚠️ The following 3 items have been corrected against the real code @ 490517fb (P2.5 implementation; the original design's placeholder names didn't match the code):
// ① field name is `kind` (not sourceKind) — matches the real AgentToolDescriptor.kind (agent-api/tool-registry.ts:1).
// ② the enum has no `mcp` — the real AgentToolKind = 'bespoke' | 'nango'; `mcp` is a value added in P4.6, not yet in the code,
// pre-adding it would make P4.7's service-layer re-validation mismatch the registry.
// ③ credentialRef references an integration_credentials row, keyed at (org_id, integration_type)
// (entities.ts @Unique(["orgId","integrationType"])), not the ADR-030 OSA/Channel dimension.
// This schema is stable configuration metadata only. It must not be rendered as
// a per-turn toolsManifest or used to narrow Hermes' progressively disclosed
// normal tools. MCP/provider execution resolves real credentials at its boundary.
export const ToolBindingContent = z.object({
toolSlug: z.string().regex(/^[a-z0-9_]{1,64}$/), // same regex as skill.schema.ts tools[]; slug-existence validation is P4.7
kind: z.enum(["bespoke", "nango"]), // ① + ②: real field name + real enum (no mcp)
accessClass: z.enum(["read", "write"]), // compatibility metadata; only an explicitly configured server-owned write may use the narrow approval carrier
constraints: z.object({
allowedOps: z.array(z.string().max(64)).max(64).optional(), // restricted op identifiers, not operator prose → bare string is correct
rateLimitPerHour: z.number().int().positive().max(10_000).optional(),
}).default({}),
// ③ references an integration_credentials row (keyed by org_id × integration_type); the credential body never enters content/prompt/trace (R6 AC3).
// Existence/resolution validation is P4.7.
credentialRef: z.string().uuid().optional(),
});
4. D2 — Storage split: PG jsonb vs R2 (proposal, pending review confirmation)
| Item | Proposal | Rationale |
|---|---|---|
| Default | All in PG jsonb | The combined max size across the five schemas per version is < 32KB, well within jsonb's comfort zone; version diffing, eval snapshots, and audit are all in-DB operations |
| R2 overflow threshold | When canonical-JSON serialization > 64KB, offload the whole thing: content stores {"$r2": true, "key": ..., "bytes": n, "sha256": ...}, content_r2_key is a redundant column | Under current schema limits this cannot actually trigger — the threshold defends against future types (e.g. rich document templates); no field-level splitting (not worth the complexity) |
| eval sandbox read-only access | Internal API GET /v1/internal/eval/config-snapshot?versionIds=… (same AgentTokenGuard auth), server-side dereferences R2 and returns full content | The sandbox doesn't hold R2 credentials; snapshots are fetched by version id, inherently reproducible (ADR-035 "pin what you back-test") |
| Version GC | Every asset retains full published/rolled_back history + the most recent 50 other versions; discarded drafts are physically purged after 90 days; R2 objects are deleted along with their version row | Audit standard: anything ever published is never deleted |
5. Publish State Machine (making edit ≠ publish mechanical)
5.1 Transition table
| # | From → To | Trigger | Preconditions | Audit event |
|---|---|---|---|---|
| T1 | draft → publishing | Role per PRD permission table (catalog: SA; instance: AM+SA) clicks Publish | content already passed Zod + NFR5 (validated at save; re-verify hash consistency here); asset has no other version currently publishing | config.publish.requested |
| T2 | publishing → published | eval worker callback (before Phase 4, see §5.3) | eval passed (or gate unwired); atomic claim succeeded | config.publish (includes eval_run_id) |
| T3 | publishing → blocked | eval worker callback | golden-set regression exceeds noise band (ADR-035 semantics) | config.publish.blocked |
| T4 | blocked → published | explicit override by same T1 role | publish_note required (written reason, PRD R4 AC2) | config.publish.override |
| T5 | published (old) → superseded | side effect of T2 | asset pointer published_version_id atomically switches to the new version; the old version's status stays published (historical), only the pointer moves away | (merged into T2 event payload: previousVersionId) |
| T6 | asset-level rollback | same role as T1 | pointer switches back to some historical published version; the version switched away from is marked rolled_back | config.rollback |
| T7 | draft → discarded | author/same-permission role | — | config.draft.discarded |
Audit goes through the existing AuditService (ADR-019: outside the business tx, fire-and-forget).
5.2 NFR2 asynchronous semantics
While publishing, the old published_version_id remains the active platform
asset pointer and only switches atomically at T2. This does not rewrite a warm
Hermes turn or session. A new release applies when a new Hermes session is
created; stable session configuration is not re-rendered per turn.
5.3 Transitional semantics (Phase 1–3, eval gate not wired) ⚠️
There is no harness wiring before P4.1. Explicitly specified (otherwise "edit≠publish" is theater): after T1 the worker immediately proceeds to T2, eval_run_id = NULL, and the audit payload carries evalGate: "not_wired" — the publish flow, audit, pointer switch, and rollback are all real from day one of Phase 1; only the eval verdict is pass-through, and this is identifiable in the audit trail. After Phase 4 wiring, existing records with evalGate: "not_wired" form the list of "historical publishes that never passed the gate."
5.4 eval hook point
The publishing state itself is the hook point: the Phase 4 worker consumes the partial index WHERE publish_status='publishing', calling the agent/evals/domain harness (which can be wrapped as a Langfuse run_experiment task, with scores landing on the Langfuse dashboard; gate verdicts and EvalRun records stay on the API side — per the master plan P4.1 research note). This phase only builds the state and fields, not the worker.
6. Runtime consumption boundary (corrected by ADR-045/046)
This data model does not define a per-turn prompt assembly chain. Published stable assets are validated and written to the session's native AgentFS home at cold admission or explicit configuration mutation. Hermes owns its system prompt, skill loading, history, retrieval, clarification, response style, and tool discovery.
Humanwork must not concatenate red lines, crisis text, directives, skill text, KB snippets, history, correction examples, tool grants, or response-style prose around an inbound message. Per-asset size limits remain publication-time data validation; they are not a second runtime token budget.
7. Archetype → Instance Inheritance (R2.6: copy-on-materialize + explicit pull)
catalog asset (scope=catalog, specialist=archetype)
│ materialize (AM enables this archetype for an org)
▼
org_instance asset: deep-copies catalog's published content → instance version 1 (draft)
archetype_asset_id = catalog.id (lineage)
→ goes through the normal publish flow (T1–T2)
- Detecting upstream changes: compare
catalog.published_version.content_hashagainst the hash recorded at instance-materialization time in the asset'smetadata.materializedFromHash(§2.1 metadata column). The Ops UI list page shows "upstream changed, pull update?" for instances whose hash doesn't match. - Pull action: generates a new draft version on the instance from the catalog's new published content (preserving a three-way diff view of the instance's local changes); after the operator confirms, it goes through the publish/eval gate.
- Never propagates silently: publishing to the catalog never touches any instance row — propagation only happens via explicit pull (R2.6: silent propagation would bypass R4.6).
8. Import Framework (three sources → source=imported_repo)
| Source | Current location | Target asset_type | Parsing |
|---|---|---|---|
| SOUL.md | historical repo orgs/<slug>/SOUL.md import source only; never a host mount or runtime fallback | soul | one-time migration heuristics map markdown into a versioned asset; published session configuration is established in cloud AgentFS |
| SKILL.md | repo orgs/<slug>/skills/**/SKILL.md | skill | frontmatter/headings map to trigger, body maps to instructions; red lines need manual completion (listed as TODO in the import report) |
| systemPrompt | specialists.system_prompt column | soul (merged into the same Specialist's soul asset) | entries >300 chars or containing 4 signal words (matches for the persona-override heuristic) are flagged separately — they're effectively "full template replacement," and importing them as soul would change behavior, so the report forces manual adjudication |
dry-run output (default mode; only --apply writes to the DB):
[soul] amy@kaito CREATE (from orgs/kaito/SOUL.md, 1_842 chars, hash 3fa2…)
identity.displayName: "Amy" ← "# Amy — IR Specialist"
unmapped: 2 sections → voice.styleRules ⚠ review
[skill] launchpad-faq@kaito UPDATE (content_hash changed 9c01… → 77ab…)
--- instructions (unified diff) ---
...
[soul] bob@acme SKIP (hash unchanged)
⚠ MANUAL: specialists.system_prompt for amy@kaito matches override heuristic (1_204 chars) — decide split
Idempotency: sources with an unchanged content_hash are skipped; safe to re-run. The first publish of an import artifact follows §5.3 (flagged in the audit trail during the transitional period; must pass the gate once eval wiring is in, PRD §8 rule 4).
9. ADR-033 L0/L1 Cross-check + Boundary Restatement
- L0 (Identity & Persona): Soul is structured, release-pinned stable data. It is established once for a new Hermes session and never injected by a Humanwork per-turn assembler. ✅
- L1 (Knowledge: golden answers + structured rules): kb_golden_answer / kb_domain_rule enter the foundation and gain versioning + publishing + eval; golden answers are prioritized at retrieval time (P2.4). ✅
- Layer-separation boundary: any structured skill policy remains a native skill/session asset. Humanwork does not inject it, classify the turn with it, or gate the reply. ✅
- Boundary: KB document bodies are not migrated in (ADR-008 pipeline unchanged);
kb_golden_answer(a production retrieval asset) ≠ an eval golden scenario (an ADR-035 directory-style fixture bundle, format OD-13 undecided) — entity, naming, and storage are fully isolated; the former lives in the foundation, the latter in Phase 4'sEvalGoldenScenario.
10. Review Checklist & Open Questions
Checklist (P0.3 acceptance, check off item by item during review):
- §2 both tables' full fields + indexes + ADR-020 docstring wording
- §3 five Zod schemas (red-line section, reserved tools field, applicability metadata, credential id reference) — fully implemented: soul/skill (P1/P2A), kb_golden_answer/kb_domain_rule (P2B), tool_binding storage half (P2.5, the 3 placeholder names in the schema have been corrected against the real code)
- §5 state machine + §5.3 transitional semantics ("not_wired" audit flag)
- §4 D2 storage split proposal
- §6 runtime boundary corrected by ADR-046: no crisis/directive/asset prompt assembly
- §7 copy-on-materialize + explicit pull
- §8 three-source import dry-run + diff
- §9 ADR-033 cross-check + golden answer/golden scenario naming isolation
Open questions:
| # | Question | Status |
|---|---|---|
| D2 | PG/R2 threshold 64KB + snapshot API + GC policy | This document gives a proposal, pending review confirmation |
| D3 | Managed runtime consumption | Settled by ADR-045/046: native AgentFS session assets; no Humanwork per-turn assembler |
| OD-13 | eval golden scenario contribution format | Undecided per ADR-035, only affects Phase 4, does not gate this model |
| Q3(PRD) | Whether Hermes eventually represents persona configuration as a native skill | Hermes-owned evolution; never a Humanwork per-turn assembly decision |
Settled: the asset's metadata jsonb column (§2.1 already includes this column, §7 updated) |
11. D3 — Native session configuration
ADR-045/046 supersede the former assembler-placement proposal. The API writes
published, release-pinned stable assets into the conversation's cloud AgentFS
when it creates a new Hermes session. That session retains them. Publishing a
new platform asset does not rewrite SOUL.md, invalidate the SessionDB prompt,
or rebuild an existing warm session; the new release applies to a newly created
session under an explicit product lifecycle. Humanwork never sends an assembled
body or asset manifest as model input.
D3 was corrected on 2026-08-05; the superseded assembly plan remains in Git history only.