Skip to main content

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_instance row belongs to row-1 class (Org × Specialist) — every query must filter on org_id AND specialist_id simultaneously; the catalog row 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.

ColumnTypeConstraint/DefaultNotes
iduuidPK, gen_random_uuid()
asset_typevarchar(32)NOT NULL, CHECK IN (soul,skill,kb_golden_answer,kb_domain_rule,tool_binding)Closed enum; new types require a migration
scopevarchar(16)NOT NULL, CHECK IN (catalog,org_instance)
org_iduuidNULL, FK→organizationscatalog rows must be NULL; org_instance rows must be NOT NULL (see CHECK below)
specialist_iduuidNOT NULL, FK→specialistscatalog rows point to the archetype Specialist (the specialists table already has a catalog field)
slugvarchar(64)NOT NULLkebab-case, unique within scope (see indexes)
display_namevarchar(128)NOT NULL
archetype_asset_iduuidNULL, FK→selfThe instance's archetype lineage (§7); always NULL for catalog rows
published_version_iduuidNULL, FK→versions (deferred constraint)Pointer to the currently live version; NULL = never published
draft_version_iduuidNULL, FK→versionsPointer to the single current draft-in-progress; at most one draft per asset at a time
sourcevarchar(24)NOT NULL DEFAULT manual, CHECK IN (manual,imported_repo,loop_proposal)loop_proposal = R4.4 proposal (ADR-037)
metadatajsonbNULLAsset-level notes: materialization source hash (materializedFromHash, §7), import source path, etc.; not part of assembly, not part of content hash
created_byuuidNOT NULLuser id
created_at / updated_attimestamptzNOT NULL DEFAULT now()
archived_attimestamptzNULLSoft 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.

ColumnTypeConstraint/DefaultNotes
iduuidPK
asset_iduuidNOT NULL, FK→assets ON DELETE CASCADE
version_nointNOT NULLMonotonically increasing within the asset; UNIQUE(asset_id, version_no)
contentjsonbNOT NULLWritten after validation against the asset_type's Zod schema (§3); large text goes to R2 as a pointer object (§4)
content_r2_keyvarchar(512)NULLR2 object key; when non-NULL, content contains a {"$r2": true, ...} pointer
content_hashvarchar(64)NOT NULLsha256(canonical-JSON(content) ‖ R2 bytes); used for both import idempotency and pull detection (§7)
publish_statusvarchar(16)NOT NULL DEFAULT draft, CHECK IN (draft,publishing,published,blocked,rolled_back,discarded)State machine in §5
eval_run_iduuidNULLWired to EvalRun in Phase 4; always NULL while the gate is unwired (§5.3 transitional semantics)
publish_notevarchar(1000)NULLRequired on override (written reason, PRD R4 AC2)
author_iduuidNOT NULL
created_attimestamptzNOT NULL DEFAULT now()
published_at / published_bytimestamptz / uuidNULL

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

  1. CREATE TABLE specialist_config_assets (without the two pointer columns to versions);
  2. CREATE TABLE specialist_config_versions + FK→assets;
  3. ALTER TABLE specialist_config_assets ADD COLUMN published_version_id / draft_version_id + FK→versions (NOT VALID then VALIDATE, to avoid a circular FK during table creation);
  4. Indexes CONCURRENTLY (following the transaction=false + post-check indisvalid pattern used by 1806600000000-AddReleaseSignalIndex.ts);
  5. 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)

ItemProposalRationale
DefaultAll in PG jsonbThe 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 thresholdWhen canonical-JSON serialization > 64KB, offload the whole thing: content stores {"$r2": true, "key": ..., "bytes": n, "sha256": ...}, content_r2_key is a redundant columnUnder 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 accessInternal API GET /v1/internal/eval/config-snapshot?versionIds=… (same AgentTokenGuard auth), server-side dereferences R2 and returns full contentThe sandbox doesn't hold R2 credentials; snapshots are fetched by version id, inherently reproducible (ADR-035 "pin what you back-test")
Version GCEvery 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 rowAudit standard: anything ever published is never deleted

5. Publish State Machine (making edit ≠ publish mechanical)

5.1 Transition table

#From → ToTriggerPreconditionsAudit event
T1draftpublishingRole per PRD permission table (catalog: SA; instance: AM+SA) clicks Publishcontent already passed Zod + NFR5 (validated at save; re-verify hash consistency here); asset has no other version currently publishingconfig.publish.requested
T2publishingpublishedeval worker callback (before Phase 4, see §5.3)eval passed (or gate unwired); atomic claim succeededconfig.publish (includes eval_run_id)
T3publishingblockedeval worker callbackgolden-set regression exceeds noise band (ADR-035 semantics)config.publish.blocked
T4blockedpublishedexplicit override by same T1 rolepublish_note required (written reason, PRD R4 AC2)config.publish.override
T5published (old) → supersededside effect of T2asset 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)
T6asset-level rollbacksame role as T1pointer switches back to some historical published version; the version switched away from is marked rolled_backconfig.rollback
T7draftdiscardedauthor/same-permission roleconfig.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_hash against the hash recorded at instance-materialization time in the asset's metadata.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)

SourceCurrent locationTarget asset_typeParsing
SOUL.mdhistorical repo orgs/<slug>/SOUL.md import source only; never a host mount or runtime fallbacksoulone-time migration heuristics map markdown into a versioned asset; published session configuration is established in cloud AgentFS
SKILL.mdrepo orgs/<slug>/skills/**/SKILL.mdskillfrontmatter/headings map to trigger, body maps to instructions; red lines need manual completion (listed as TODO in the import report)
systemPromptspecialists.system_prompt columnsoul (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's EvalGoldenScenario.

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:

#QuestionStatus
D2PG/R2 threshold 64KB + snapshot API + GC policyThis document gives a proposal, pending review confirmation
D3Managed runtime consumptionSettled by ADR-045/046: native AgentFS session assets; no Humanwork per-turn assembler
OD-13eval golden scenario contribution formatUndecided per ADR-035, only affects Phase 4, does not gate this model
Q3(PRD)Whether Hermes eventually represents persona configuration as a native skillHermes-owned evolution; never a Humanwork per-turn assembly decision
newWhere the materialization source hash is storedSettled: 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.