Check rules for the declarative path

The declarative API and persistence work moves API contracts, table schemas, and read-model queries into the language. One api block owns the public surface. One data with a table block owns the schema. One query declaration owns a read model. The compiler builds the manifest, the OpenAPI document, the typed client, the row decoder, and the migration plan from those declarations.

The type system can keep declared code honest. What it cannot do, on its own, is stop application code from working around the declaration and going back to strings and helpers. That is what the new vary check rules are for. Each one names a pattern that the declarative path replaces, and each one points the diagnostic at the canonical replacement.

The rule reference is in check.

Eight rules ship together. They are the second half of the declarative story.

VCA006: manual-route-catalog

Flags hand-maintained HTTP route catalogs.

Before:

let user_route = "/users/:id"
let post_route = "/posts/:id"
let list_route = "/posts"

def get_route_methods() -> Dict[Str, Str] {
    return {"users.show": "GET", "posts.show": "GET", "posts.list": "GET"}
}

A list of paths beside a list of methods is the smell. The paths drift from the interface, the OpenAPI document, the generated client, and the tests because each one stores them separately.

After: declare the routes inside an api block. The path, method, and handler are one source of truth, and every projection reads it.

VCG017: manual-json-string

Flags hand-built JSON strings and JSON substring checks.

Before:

let payload = "{\"id\": " + id + ", \"status\": \"ok\"}"
let parsed = Json.parse("{\"user_id\": 42, \"verified\": true}")
assert response.contains("{\"ok\":true}")

Each one is an invitation to a quoting bug. The fix is to build Json values structurally, return them through typed response payloads, and assert on decoded values rather than on substrings of stringified output.

VCG026: manual-handler-adapter

Flags wrapper classes that exist only to inject shared dependencies into free handler functions.

Before:

class UserEndpoint(store: UserStore, auth: AuthService) {
    def get_user(self, id: Str) -> UserResponse {
        return user_handler(self.store, self.auth, id)
    }
    def list_users(self) -> UserListResponse {
        return list_handler(self.store, self.auth)
    }
}

The class does not express any product behaviour. Every method is return some_handler(self.store, self.auth, ...). The declarative path binds handlers to the contract directly, so the dependency wiring is no longer a class.

VCG022: manual-row-mapping

Flags constructors built out of row.get_* calls.

Before:

let record = UserRecord(
    row.get_str("name"),
    row.get_long("age"),
    row.get_bool("active"),
)

The selected row shape is duplicated in the application code. Type the projection with sql ... into DataType or declare a query and let the compiler write the decoder. Aliases, nullability, and column types stay under the type checker.

VCG024: manual-response-envelope

Flags hand-built JSON response envelopes.

Before:

let resp = Json.empty_object()
resp.set_bool("ok", true)
resp.set_str("message", "Success")
return resp.stringify()

Every endpoint repeating that pattern is duplicating envelope policy. The fix is a declared response type or a shared envelope helper. Serialization is the framework's job, not the handler's.

VCG025: manual-field-projection

Flags repeated visibility conditionals on JSON setters.

Before:

if user_field_allowed("email") { obj.set_str("email", user.email) }
if user_field_allowed("phone") { obj.set_str("phone", user.phone) }
if user_field_allowed("ssn") { obj.set_str("ssn", user.ssn) }

Field-by-field visibility checks bury the privacy policy inside endpoint bodies. The fix is field_projection from the projection stdlib: declare the allowed list once and apply it. The policy becomes inspectable instead of distributed.

VCG027: structured-markup-string

Flags XML, Atom, or HTML assembled by string concatenation.

Before:

let entry = "<entry><title>" + title + "</title><id>" + id + "</id></entry>"
let doc = "<?xml version=\"1.0\"?><root>" + content + "</root>"

Concatenated markup makes escaping a caller responsibility. Markup builders escape in one place and stop attribute and tag rules from leaking into application code.

VCG018: dsl-replaceable-sql

Flags raw SQL strings where the typed SQL DSL would do.

Before:

db.execute("INSERT INTO users (name, email) VALUES (?, ?)", name, email)
db.execute_query("SELECT id, name FROM users WHERE active = true")

Raw SQL hides the table name, the column names, and the row shape from the compiler. The DSL blocks (query, query_one, count, insert, update, delete) keep those names typed and still emit parameterised SQL.

How the rules read

Each diagnostic names the rule, points at the offending span, and prints the canonical replacement.

src/services/users.vary:17:5
VCG022 manual-row-mapping (great-code)
  manual SqlRow getter constructor mapping
  fix: project the query with `sql ... into DataType` or a declared `query`

vary explain VCG022 prints the same fix with the rationale and an example. The rules ship without auto-fixes on purpose. Each replacement is a structural change that benefits from a human reading the surrounding code once.

Why this set, together

The point of the declarative api, data plus table, and query forms is that the compiler owns the parts of the system that have to agree across boundaries. The point of these check rules is that the rest of the application has to ask the compiler instead of working around it.

Manual patternDeclarative replacement
Route catalog and method tablesapi block routes
Hand-built JSON strings and substring assertsJson builders and typed responses
Handler adapter classesHandlers bound to the contract
row.get_* constructor mappingsql ... into or a query declaration
Manual response envelopesDeclared response types
Field visibility conditionalsfield_projection and named field policy
Concatenated markup stringsMarkup builders
Raw SQL stringsThe typed SQL DSL

A single declaration, a single source of truth, and a check rule that says "no, not that way" when application code tries to build the same thing by hand. That is what closes the loop. The type system keeps the declarative path honest. The check engine keeps application code on it.

If you want the declarations these rules push toward, the declarative API and persistence article is the companion piece. The full rule list and rationale text shows up in vary explain and in the next release notes.