This skill takes a *-semantic-model.md file and writes a single self-contained TypeScript file that, when executed with bun, asserts the live Semantius instance contains every entity, field, enum value, permission, role, role-permission link, permission-hierarchy edge, and webhook receiver the model declares, with the exact properties the model specifies.
Why this exists. The deployer is additive: it creates what's missing and updates what differs. Its in-process Stage 5 report (see the deployer skill) confirms the writes it issued, but it does not re-prove the end state from a cold read after the session ends. Manual re-runs of the deployer are still the only mechanism that retries reconciliation. Live state can drift between deploys (an admin edits a field's title in the UI, an entity gets renamed, a role_permission is deleted, a permission_hierarchy edge is dropped). Without a standalone post-deploy check, that drift is invisible until users hit it in production. The user's words: "if any difference between model and semantius platform remain undetected we will all lose our business and maybe existence." Take that seriously. Over-check rather than under-check.
Division of responsibility.
- This skill owns the workflow: parse the model, plan the checks, emit one self-contained TypeScript file. The skill itself does not call Semantius and does not run the test.
- The generated script owns execution: shells out to the
semantiusCLI, reads live state, compares against the model embedded inline as a JSON constant, prints a structured report, exits non-zero on any mismatch. - The
use-semantiusskill owns CLI conventions; this skill borrows itsread_*patterns and--singlesemantics, but applies them inside the generated script rather than at chat time.
Writing conventions (apply to chat output and the generated script)
These match the rest of the semantic-model skill family. Apply them to both the chat narration in this skill and every string the generated TypeScript file prints.
1. US English spellings, always. Examples (left = correct US, right in backticks = banned British form): optimize (not optimise), behavior (not behaviour), modeling (not modelling), labeled (not labelled), normalize (not normalise), analyze (not analyse).
2. No em-dashes (—, U+2014). Use parentheses, commas, periods, or semicolons. Scan output for — before writing any file.
3. Singular-subject grammar in confirmation prompts. "Looks good?" not "Look good?".
Step 0: Load required context
Before parsing anything, read these so the generated script reflects current conventions:
Read: <skills-root>/use-semantius/SKILL.md
Read: <skills-root>/use-semantius/references/data-modeling.md
Read: <skills-root>/use-semantius/references/rbac.md
Read: <skills-root>/semantic-model-deployer/SKILL.md # at least the parse rules in Stage 1 and the per-area checks in Stage 5
You also load this skill's own deeper reference for the checks the script must implement:
Read: <this-skill>/references/checks-catalog.md
The checks catalog is the canonical list of comparisons the generated script performs, with the exact semantius call shapes, the exact mapping from model property to live property, and the exact failure-message format. Treat it as the script's specification.
Schema compatibility
This skill consumes the same model files the deployer consumes; track the deployer's EXPECTED_MAJOR value, currently 3. At parse time, read the model's front-matter version and compare its major against this value. Major mismatch is a 🛑 blocker; tell the user the version found and the major this skill expects, and route them to the analyst's audit mode to migrate the file before regenerating the test script.
When the analyst's major bumps, this skill and the deployer both must move with it: a major bump means section numbers, table columns, or required front-matter keys have changed, and the checks catalog must follow. Keep the EXPECTED_MAJOR constant near the top of this file in sync with the deployer's.
High-level workflow
1. Locate the model → 2. Parse it → 3. Plan checks → 4. Emit the script → 5. Tell the user how to run it
Narrate what you're doing at each step. Do not call Semantius from chat; the script is the only thing that should ever touch the live instance.
Stage 1: Locate the model
If the user named a path, use it directly. Otherwise look in the current working directory for *-semantic-model.md and present the matches as a compact list (filename, system_slug, version); ask which one to target. Never glob across unrelated directories without being asked, the model file may be one of several.
Capture the model's absolute path; the generated script will sit next to it as <system_slug>-deploy-test.ts by default.
Stage 2: Parse the model
Parse the same way the deployer does (Stage 1 of the deployer skill is the canonical procedure; mirror it). The verifier does not need to enforce every Stage 1 blocker the deployer does (those are deploy-time concerns), but it does need every fact the deployer would write to the catalog. Extract:
Front-matter
version(required; major-gate first, see Schema compatibility above)system_slug(this is the module slug)system_name(the module's display name)system_description(the compact tagline)module_type(optional, defaults to"domain"; the verifier asserts the livemodule.module_typematches)entitieslist (informational; the §2 table is authoritative)
§2 Entity summary table
Build the canonical entity order. Each row gives table_name and singular_label.
§2 Permissions summary table (analyst v2.0+)
Parse the five-column table verbatim: Permission | Type | Description | Used by | Hierarchy parent. Build a permissions[] list (each entry: permission_name, type, description, used_by, hierarchy_parent). The Type values are baseline-read, baseline-manage, baseline-admin, workflow, workflow-narrow. Permissions and hierarchy edges in this table are what the generated script asserts exist; everything else in the §2 chunk is informational.
§3 Entity sections
For every entity, capture:
table_name,singular_label,plural_label,description,label_column,audit_log(defaultno)edit_mode(optional; the platform default isauto; the verifier skips the comparison when the model is silent regardless of live value)cube_mode(optional; the platform default isauto; the verifier skips the comparison when the model is silent regardless of live value)view_permission: always<system_slug>:readedit_permission: resolved from**Edit permission:**line (default<system_slug>:manage;admin⇒<system_slug>:admin; bare narrow suffix matching aworkflow-narrowrow ⇒<system_slug>:<suffix>)- For each field row in the entity's table:
field_name,format,title(the Label column)reference_table(parsed out of the Reference / Notes column forreferenceandparentrows)relationship_label(parsed from therelationship_label: "<verb>"annotation)singular_label_parent/plural_label_parent(fromparent label: "<sing>" / "<plur>")cube_type(defaultautowhen absent)default_value(fromdefault: "<value>")unique_value(true when the Notes cell carries theuniquetoken)description(the fifth column; verbatim)enum_values(collected from the §5 sub-section the Notes cell references; only present forformat: enum)
- The entity's
computed_fieldsandvalidation_rulesJSON arrays (parse verbatim; default[]when the heading is absent) - The entity's
select_ruleJSON object (parse verbatim; default{}when the heading is absent) - The entity's
input_type_rulesJSON array (parse verbatim; default[]when the heading is absent)
§4 Relationship summary
For every FK row in §3, the §4 table gives reference_delete_mode (the Delete mode column). Carry that into the field record so the script can assert it on read_field.
§5 Enumerations
One sub-section per enum field. The values list is the authoritative enum_values for the corresponding field.
Per-module scaffold the deployer always applies
The deployer applies a standard scaffold to every module it touches. Even when the model does not enumerate them explicitly, the generated script asserts these exist:
- The module exists with the right
module_slug,module_name,module_type, anddescription. - The three baseline permissions exist:
<slug>:read,<slug>:manage, and<slug>:adminwhen the model declares an admin-tier row (aType: baseline-adminrow in the §2 Permissions summary table). Without that row, onlyreadandmanageare required. - The three default roles exist:
<slug>_viewer,<slug>_manager, and<slug>_admin(admin only when the baseline-admin permission exists). The roles'module_idmatches the module'sid. - The role-permission joins: viewer carries
<slug>:read; manager carries<slug>:manage; admin carries<slug>:admin. - The baseline permission hierarchy:
<slug>:manage → <slug>:read, and<slug>:admin → <slug>:managewhen admin exists. - The module's view/manage/admin permission columns:
view_permissionis a string column (carrying the permission name verbatim, e.g."itsm:read");manage_permission_idandadmin_permission_idare FK id columns (admin_permission_idnullable when no admin tier). There is noview_permission_idcolumn on the livemodulestable — earlier catalog versions claimed there was, that was incorrect. Plus three FK id columns for the default roles:default_viewer_role_id,default_manager_role_id,default_admin_role_id(nullable when no admin tier). The generated script reads the livemodulesrow, compares the string and resolves each FK against the natural-key-to-id map it built during the permission/role check.
Additional permissions, roles, or hierarchy edges that the model declares in the §2 Permissions summary table (workflow / workflow-narrow rows, custom hierarchy edges) are layered on top of this scaffold and asserted individually.
Semantius built-in entities (platform-owned; relaxed check)
Semantius ships with a small set of built-in entities the deployer dedups against rather than recreating. The current canonical set is users (others may follow). The script emits a BUILT_IN_TABLES constant (currently ["users"]) at the top of the module so a future major bump can extend it in one place.
Built-in entities are platform-owned. The deployer reuses the live row as-is and does not write its metadata; treating any of that metadata as a deploy contract produces false-positive drift on every run. The verifier's job for built-ins is to confirm the load-bearing surface the model relies on (the columns the model reads and writes, plus the workflow rules the model registers) and nothing more. Concretely, for every entity in BUILT_IN_TABLES:
- Lookup: query
read_entity --single '{"filters":"table_name=eq.<table>"}'unscoped bymodule_id(the built-in's live row lives in the platform's identity module, not the current model's module). - Field existence only: for each field the model declares, assert one live
fieldsrow exists with the samefield_name. Do not compareformat,title,description,unique_value,enum_values,default_value,relationship_label,reference_table,reference_delete_mode,input_type_rule, or any other field-level property. - Rule-name presence: when the model declares
computed_fieldsorvalidation_ruleswith non-empty arrays, assert each entry's identifier (nameforcomputed_fields,codeforvalidation_rules) is present in the live entity's corresponding JSON array. Live may carry additional entries (additive). JsonLogic content and other rule metadata are not compared. - All other entity-level metadata is skipped: no checks on
module_id,singular_label,plural_label,description,label_column,audit_log,view_permission,edit_permission,edit_mode,cube_mode,select_rule.
This is a deliberate relaxation against the rest of the verifier's "over-check, never under-check" stance. The justification is asymmetric ownership: the deployer never writes a built-in's module_id, view_permission, audit_log, etc., so the model's declaration of those properties is documentation of the model's own assumption about the platform, not a deploy assertion. The verifier reports drift on things the deployer is responsible for; platform-owned metadata is governed by the platform's release process, not the model.
Webhook receivers
If the model carries any webhook receiver declarations (a ## Webhook receivers section, or an equivalent block; analyst convention is to declare them with a name, event, and optional url/description), parse them into a webhook_receivers[] list and assert each one exists with matching properties. The model files in current use rarely declare these; when the section is absent, the script does not assert any.
Stage 3: Plan the checks
Build the complete check list in memory. Every model fact maps to at least one assertion in the generated script. The catalog in references/checks-catalog.md is the canonical specification; read it before writing the script and follow it row-for-row.
The high-level grouping the script emits, in execution order:
- Module check. Confirm the module exists by slug, with matching
module_name,module_type, anddescription, and that the six scaffold permission/role columns resolve correctly (the asymmetricview_permissionstring plus five FK ids). - Permissions check. For every row in the §2 Permissions summary table, plus the implicit baseline rows, look up the live
permissionsrecord bypermission_name(its unique natural key) using a--singleread scoped globally — never filtered bymodule_id. A row that exists with the right name but a null or mismatchedmodule_idis then reported as amodule_idmismatch, not as missing. After the lookup, comparedescriptionandmodule_id(against the module id from check 1.1). The script also builds apermission_name → idmap from these lookups, used by checks 3 and 5. - Permission-hierarchy check. For every
Hierarchy parentvalue in the table (and for the implicit baseline edges), confirm a livepermission_hierarchyrow exists with the matching(including_permission_id, included_permission_id)— the broader (including) permission on theHierarchy parentside, the narrower (included) permission on the row's ownPermissionside. - Roles check. Confirm the three default roles exist by their
slug(the live natural key, notnamewhich is the deployer-chosen display label). Use a per-role--singleread filtered only byslug=eq.<role_slug>, scoped globally. A role with the right slug but a null or mismatchedmodule_idis reported as amodule_idmismatch, not as missing. Comparemodule_idagainst the module id from check 1.1. Role display name and description are deployer-chosen and not asserted. - Role-permissions check. Confirm each default role carries its baseline permission via
role_permissions. Any model-declared role-permission link beyond the baseline gets its own check. - Entities check. For every entity in the model, confirm the live
entitiesrecord exists in this module with matchingsingular,plural,singular_label,plural_label,description,label_column,audit_log,edit_mode,cube_mode,view_permission,edit_permission, and the entity-level JSON properties (computed_fields,validation_rules,select_rule) compared by value-equality. Entities inBUILT_IN_TABLEStake the relaxed built-in branch instead (field-existence-only plus rule-name presence; see the "Semantius built-in entities" subsection in Stage 2). - Fields check. For every model field on every entity (after auto-field stripping, see below), confirm the live
fieldsrecord exists with matchingformat(with the label-column alias),title,description,reference_table,reference_delete_mode,relationship_label,singular_label_parent,plural_label_parent,cube_type,default_value,unique_value,enum_values, andinput_type_rule. Thefieldstable joins to its entity via thetable_namestring column (notentityorentity_id); the livefields.idis the composite string"<table_name>.<field_name>". Batch-read fields once per entity bytable_name=eq.<table>and look up by name in a localMap. Fields on built-in entities are checked for existence only; see the "Semantius built-in entities" subsection in Stage 2. - Webhook receivers check (only if the model declares any).
Auto-field stripping (mandatory)
Semantius auto-creates these fields on every entity. The model never declares them, so the script must not assert their absence or look them up:
| Field name | Why skipped |
|---|---|
id |
Auto-created primary key |
label (the generic ctype: label row, distinct from the named label_column field) |
Auto-created computed display field |
created_at, updated_at |
Auto-maintained timestamps |
Keep the named label_column field (e.g. product_name). It is rendered in §3 and is a normal assertion target.
Additive-is-OK rule
The script must not fail when Semantius carries extra entities, fields, permissions, roles, role-permission links, hierarchy edges, or webhook receivers that the model does not declare. The deployer is additive, an admin may have added bespoke fields, and the model's contract is "everything I declare is there", not "nothing else is there". A field's description cell being blank in the model means the model has nothing to assert about that field's live description, so the script skips that one comparison (do not assert description === ""); the same goes for any other optional property where the model is silent.
The one exception is enum value lists: when the model declares an enum's values, the live enum_values array must contain every model value with matching ordering and casing. Extra live values beyond the model's are accepted (an admin may have widened the enum). Missing or differently-cased values are a failure.
Stage 4: Emit the script
Write the script to <system_slug>-deploy-test.ts in the model's directory (override path on request). The file is one TypeScript module, runnable with bun <slug>-deploy-test.ts. Keep it self-contained, no external dependencies beyond bun's built-ins and the semantius CLI on PATH.
The script's anatomy, top to bottom:
- Shebang + module docstring. One paragraph naming the model file the script was generated from, the date, and the
EXPECTED_MAJORof the deployer it pairs with. - The parsed model as a JSON constant. Embed everything Stage 2 captured as a single
const MODEL = {...} as const;literal. The script is then truly self-contained, it never re-reads the.mdfile at runtime. This is the load-bearing design choice: a CI runner doesn't need the.mdfile shipped alongside, and any future analyst-version-bump in the source.mdcannot accidentally break the script. - A tiny CLI runner. A
runSemantius(server, tool, payload, single?)helper that shells out viaBun.spawn(["semantius", "call", server, tool, ...]), parses stdout as JSON, and translates exit codes per the use-semantius contract (0⇒ success;1⇒ not found when--single;2⇒ ambiguous when--single;3⇒ retry-once transient;4⇒ tool failure;5⇒ auth failure, abort). Hard-coded retry exactly once on exit3. - A check registry. Each check is a small async function returning
{ name: string, passed: boolean, expected?: unknown, actual?: unknown, message?: string }. The registry is the literal list from the checks catalog, in the order Stage 3 plans. - A driver. Iterates the registry, captures results, prints a structured report:
- One line per check while running (✓ for pass, ✗ for fail, the check name, and, on fail, the expected/actual pair quoted exactly).
- At the end, a one-block summary with totals (
<total> checks, <passed> passed, <failed> failed). - If
failed > 0, print the failures again as a numbered list andprocess.exit(1). Otherwise printVerification passed.andprocess.exit(0).
- Optional
--jsonflag. When present, the driver suppresses per-check console output and prints a single JSON document at the end ({summary, failures}) for CI consumption. Pass-through for human and machine callers without forking the script.
Comparison semantics (read this carefully)
- String fields: byte-for-byte equality. Trim whitespace from both sides before comparing only when the model file explicitly trims (it does for table cells); otherwise compare verbatim. Empty model values (
"") are skipped, see the additive-is-OK rule above. - Platform sentinel values: several live columns carry platform-chosen defaults whenever the model declares nothing —
fields.description="",fields.default_value="",fields.relationship_label="has",fields.reference_table="",fields.reference_delete_mode="restrict"(on non-FK rows),fields.singular_label_parent="",fields.plural_label_parent="",fields.cube_type="auto",fields.input_type_rule={},entities.cube_mode="auto",entities.edit_mode="auto". Skip every comparison where the model is silent, regardless of the live value being one of these defaults. The "Platform sentinel values" table inreferences/checks-catalog.mdis the canonical list; keep both in sync. - Boolean fields: strict equality after coercion (
"yes"⇒true,"no"⇒false, undefined ⇒falseforaudit_log). - Required: not a separately stored field. Semantius does not expose a per-field
requiredflag; the deployer and optimizer agree thatformat: parentis required,format: referenceis optional, and other formats default to required. The script therefore assertsformatequality and does not separately proberequired; format equality is the load-bearing check and subsumes the inference. formataliasing: the deployer rewrites the entity's label-column field format fromstringtotextat create time (the field also carriesctype: label). Accept livetextas equivalent to modelstringonly for the field whosefield_nameequals the entity'slabel_column. All otherstringfields staystringin live state and are compared verbatim. This is the only known platform format rewrite; do not generalize.enum_values: the live array must contain every model value, in the same order, with matching casing. Extra live values are accepted (they appear after the model's last entry). Re-ordering of model values is a failure (display order matters in UI dropdowns).reference_table/reference_delete_mode: byte-for-byte equality on FK rows. A mismatch onreference_delete_modeis high-severity, deleting the wrong target on cascade is a data-loss class of bug. On non-FK rows the live row still has non-null values for these columns (platform-default placeholders,""and"restrict"); skip the comparison entirely on non-FK rows.computed_fields/validation_rules/select_rule(entity-level): deep value-equality after JSON canonicalization (sort object keys, leave arrays in declared order). The deployer round-trips these byte-for-byte on the entity row; the script must accept that contract and fail loudly on any divergence.input_type_rule(field-level, singular): the deployer extracts the matching entry from the entity's §3Input type rulesarray (matched byfieldname) and stores only the entry'sjsonlogicsub-object onfields.input_type_rule. Thefieldanddescriptionkeys are NOT round-tripped —fieldis redundant with the row'sfield_name, anddescriptionis analyst documentation that lives in the model only. Deep value-equality is against the entry'sjsonlogic, after JSON canonicalization. The generated script must compareentry.jsonlogic(not the wrapping entry) againstlive.input_type_rule, otherwise every entity with input type rules produces false-positive drift on every run.- Permission, role, and hierarchy ids: resolve by natural key (
permissions.permission_name,roles.slug, the(including_permission_id, included_permission_id)pair), not by numericid. Ids drift across instances; natural keys are the model's contract. - Module permission columns:
view_permissionis a string column compared directly to<slug>:read.manage_permission_id,admin_permission_id,default_viewer_role_id,default_manager_role_id,default_admin_role_idare FK ids — resolve the expected id via the corresponding natural-key lookup, then assert equality. A null where the model expects a value is a failure; a value where the model expects null (because the model has no admin tier) is not a failure (additive rule). There is noview_permission_idFK onmodules— do not query it. - PostgREST filter syntax: single-column filters use
col=eq.<val>. Multi-column AND filters MUST useand=(col1.eq.<v1>,col2.eq.<v2>)(PostgREST RPC form with.separators inside the parens). The naive comma-joined formcol1=eq.<v1>,col2=eq.<v2>is parsed as one filter whose value contains commas and produces(22P02) invalid input syntax for type integerat PostgREST. The catalog gives the correct call shape on every multi-column row; copy verbatim.
Failure-message format
Every failure line in both human-readable and --json output must include enough context to diagnose without re-reading the model. Use this exact shape:
✗ <check group>: <natural-key path>
expected: <quoted model value or summary>
actual: <quoted live value or "missing">
why: <one short clause naming the kind of drift>
Examples:
✗ field: applications.application_type.enum_values
expected: ["point_solution","internal_system","platform","custom_built"]
actual: ["point_solution","internal_system","platform"]
why: model enum value missing from live state
✗ permission_hierarchy: apm:manage → apm:read
expected: present
actual: missing
why: hierarchy edge declared in model is not in live state
✗ entity: vendors.singular_label
expected: "Vendor"
actual: "Vendor Name"
why: entity label differs (asymmetry suggests an admin edit)
The natural-key path is the script's primary debugging surface; a maintainer should be able to copy the path into read_* calls and re-confirm the live state by hand.
Don't over-engineer
The script is a leaf utility, not a framework. Do not pull in test runners (no vitest, no bun test), do not register custom matchers, do not emit JUnit XML, do not write a config file. One file, one entrypoint, no flags except --json. The user just wants to type bun apm-deploy-test.ts and see whether the deploy held up.
Stage 5: Tell the user how to run it
After writing the file, share a one-line summary and the run command. Keep it tight:
Wrote
<slug>-deploy-test.tsnext to the model. Run with:bun <slug>-deploy-test.tsExits non-zero on any drift. Add
--jsonfor machine-readable output (use this in CI).
Do not offer to run the script yourself. The whole point of this skill is that the user (or their CI) runs it after every deploy, on demand. Running it once during this chat tells you nothing about the next deploy.
What this skill does not do
- Does not call Semantius. Reads no live state. Every fact about the live instance flows through the generated script at run time.
- Does not modify the model file. The model is read-only input.
- Does not propose fixes for drift. The generated script's failure output is the diagnostic; fixing the drift is the deployer's job (re-run the deploy) or the admin's job (revert the manual edit). After fixing, re-run the test script.
- Does not verify business data (rows in the entity tables). Schema-only, like the model itself. Sample-data assertions are out of scope and would couple the test to a specific instance's content.
- Does not verify users, user-roles, or webhook-receiver-logs. Those are operational state, not schema. Default-role membership is checked structurally (the role exists with the right permission joins), not by member count.
Re-generating the script after a model change
The generated script is disposable. After the model file changes (the analyst added a field, the user customized the model and pushed it back via the optimizer), regenerate the script by re-running this skill against the new .md. Do not hand-edit the generated .ts to keep it in sync; an out-of-date test script is worse than no test script because it silently passes drift.
Existing scripts in the workspace are fine to overwrite. Confirm before overwriting if the existing file has a modification time newer than the model, that's the signal that someone may have hand-edited it; in that case ask before clobbering.