Skip to content

Counter-intuitive behaviors & gotchas

Quick reference for things that look like a bug but aren't. Each entry explains the "intuitive guess" first, then what SpecStar actually does, and links to the relevant guide.

If a behavior here genuinely bites you, please open an issue — most of these are 0.x trade-offs that may sharpen up before 1.0.


Reads / response shape

GET /{model}/{id} returns an envelope, not the bare resource

Intuitive guess Actual behavior
GET /user/123 → {name: "...", email: "..."} GET /user/123 → {"data": {...}, "revision_info": {...}, "meta": {...}}

The envelope is intentional: the same endpoint can return data, revision info, and metadata together. To get the bare data object:

  • GET /user/123/data, or
  • GET /user/123?returns=only-data — the only-* selector unwraps the section. (Plain ?returns=data still wraps it as {"data": {...}}.)

To change the default shape for every GET, set default_get_returns (e.g. spec.configure(default_get_returns="only-data")).

Note the asymmetry: POST / PUT return a flat RevisionInfo (top-level resource_id, revision_id, created_by, …) — a different shape from this GET envelope. A client reads the new id from the flat write response, then reads the resource back through the envelope.

See API conventions.

?partial=true on a list returns data: {} for every row

partial is a field selector (slash-prefixed paths), not a boolean. ?partial=true is normalised to the path /true which matches no field in your struct, so the projector strips data down to an empty object (a boolean-looking partial value also emits a SpecStarWarning). Use slash-prefixed field names instead:

GET /user?partial=/name&partial=/email

See API conventions § partial.

Three id fields in every response

Field Where Use for
resource_id meta.resource_id URL paths, stable across revisions
revision_id revision_info.revision_id switch, revision-targeted reads
uid meta.uid Internal — do not put in URLs (differs per revision)

Lists & filters

Bare ?field=value query params are silently ignored

Intuitive guess Actual behavior
GET /task?name=Alice returns Alice's tasks Returns all tasks; name is silently dropped

Filtering goes through the structured qb DSL or data_conditions / conditions. See Query Builder.

Default page size is effectively unlimited until you set it

SPECSTAR_DEFAULT_QUERY_LIMIT defaults to 2**32 - 1. Set a sane limit (e.g. 1000) for production. Per-request ?limit= overrides it.

limit=0 returns zero rows, not "all rows"

limit is the literal page size. 0 means "give me nothing." For all rows, omit limit (or set it to a large finite number).


Writes

Unknown fields are silently dropped (until you opt in)

By default, POST / PUT with extra keys returns 200 and the extras are gone. A typo'd field = lost data. Opt into strict mode:

SpecStar(forbid_unknown_fields=True)
# or
spec.configure(forbid_unknown_fields=True)

With it on, unknown fields return 422. See API conventions § Strictness.

resource_id cannot be set through the request body

POST /note  {"title": "a", "resource_id": "my-id"}   # 422
PUT /note/<id>  {"title": "a", "resource_id": "x"}   # 422
PATCH /note/<id>  [{"op":"replace","path":"/resource_id","value":"x"}]  # 422

resource_id is server-generated at creation and immutable afterwards, so it never belongs in POST / PUT / PATCH bodies. SpecStar rejects it with 422 rather than silently dropping it (the previous behavior — see CHANGELOG). To customise how ids are generated, pass id_generator= to spec.add_model(...).

The one exception: if your Struct legitimately declares a field named resource_id, the guard steps aside and treats it as ordinary data.

A custom id supplied programmatically (rm.create(data, resource_id=...) or a custom id_generator) must be safe in file paths and URLs: ids with /, \, or control characters are rejected with a ValidationError. A / would otherwise break disk storage (the id is used as a filename) and make the resource unreachable over HTTP — /{model}/{resource_id} is a single path segment, so GET /model/a/b (and even a%2Fb) returns 404. Stick to letters, digits, :, -, _.

PUT is full-replace, not upsert

Intuitive guess Actual behavior
PUT /user/unknown-id creates the user Returns 404 — PUT requires the resource to exist. Use POST /user to create.

PATCH accepts two flavors — pick by shape (array = ops, object = merge)

PATCH understands both REST patch standards on the same endpoint:

PATCH /user/123  [{"op":"replace","path":"/name","value":"new"}]   # RFC 6902 JSON Patch (array)
PATCH /user/123  {"name": "new"}                                   # RFC 7386 Merge Patch (object)

A JSON array is treated as RFC 6902 operations; a JSON object is a RFC 7386 merge patch (partial update; null deletes a field). Set Content-Type: application/json-patch+json or application/merge-patch+json to be explicit. See API conventions § PATCH.

Same-content writes are de-duplicated

A PUT that produces byte-identical content as the current revision returns 200 but does not create a new revision. Compare the returned revision_id to the prior one to detect a dedup. See API conventions § Revisions and mutability.

DELETE returns 200 + meta body, not 204 No Content

Useful when callers want the post-delete metadata (e.g. to confirm soft delete, capture timestamps). If you only care about success, just check status_code < 300.

Soft-deleted resources return 410 Gone, not 404

Distinct from "never existed" 404. Pass ?include_deleted=true to read them through the same endpoints.

Programmatic list_resources() includes soft-deleted rows (HTTP excludes them)

The two layers disagree on the default:

Call Soft-deleted included?
rm.list_resources() / count_resources() / iter_all() (bare) Yes — the query's is_deleted is UNSET, i.e. no filter
GET /{model} (no is_deleted param) No — the route param defaults to False

To exclude them in a single programmatic call, pass it explicitly:

from specstar.query_types import ResourceMetaSearchQuery
rm.list_resources(ResourceMetaSearchQuery(is_deleted=False))   # live only

Or set a default for all programmatic list/count/iter calls (non-breaking — defaults to None = include both):

spec.configure(default_is_deleted=False)   # exclude soft-deleted by default
# None = include both (default) · False = exclude · True = only deleted
# An explicit is_deleted in the query always wins.

Unique() ignores soft-deleted rows

A Unique() constraint only considers live resources. After you soft-delete a row, its value (SKU, employee id, …) can be reused by a new resource. This is often desirable, but surprising if you expected the value to stay reserved. Permanently delete (or keep the row) if you need the value held.


Blobs / attachments

Deleting a resource does not delete its blobs

Blobs (Binary / file_id) are not reference-counted or garbage-collected on delete, so deleting a resource leaves its uploaded files behind. Do not naively delete a resource's blobs in an on-delete handler: the same file_id can be referenced by other resources, other fields, or older revisions of the same resource (and soft-deleted resources can still be restored). Deleting it would corrupt those references.

Safe cleanup is a refcount-aware sweep, not an on-delete hook. Recipe:

# Run as a periodic / manual GC, never inline on delete.
# A blob is an orphan only if NO live resource/revision references it.
def collect_orphan_blobs(manager, *, dry_run=True):
    referenced: set[str] = set()
    # 1. gather every file_id referenced by any live revision of any resource
    for meta in manager.iter_all():                  # all live resources
        for rev_id in manager.list_revisions(meta.resource_id):
            data = manager.get_resource_revision(meta.resource_id, rev_id).data
            referenced |= file_ids_in(data)          # your model-specific extractor
    # 2. delete blobs that nothing references
    orphans = [bid for bid in manager.blob_store.list_blobs() if bid not in referenced]
    if not dry_run:
        for bid in orphans:
            manager.blob_store.delete(bid)
    return orphans

Adapt file_ids_in(...) and the blob-store listing to your storage backend; both a full revision scan and blob enumeration are required to be correct.


Versioning / migration

Resources registered with bare add_model(Model) store schema_version=None

Later upgrading to Schema(Model, "v2").step("v1", ...) then fails with "No migration path from version None to 'v2'". Two recoveries:

  1. Recommended from day one: always use Schema(Model, "v1") even before you have migrations.
  2. Already have unversioned data: register a step(None, ...) migration. See Migrations howto.

switch needs the full revision_id or a bare revision number

Both forms work; anything else returns 400 with a hint:

POST /user/{rid}/switch/3                  # OK — normalised to {rid}:3
POST /user/{rid}/switch/{rid}:3            # OK — explicit
POST /user/{rid}/switch/whatever           # 400 — invalid format

switch to an older revision may raise RevisionNotMigratedError

After migrate(resource_id) bumps meta.schema_version to the new target, older revisions still sit at their original schema_version. switch to one of those raises RevisionNotMigratedError (400). Fix by migrating that specific revision first:

rm.migrate(resource_id, revision_id=old_revision_id)
rm.switch(resource_id, old_revision_id)

Upgrading past 0.13.0a4 builds the base indexes on Postgres boot

Intuitive guess Actual behavior
An upgrade with no schema change boots as fast as before The first boot of an existing table may build up to eleven indexes

Up to 0.13.0a4 the base meta indexes (the indexed_data GIN, is_deleted, created_*, updated_*, rev_*) had fixed names, so in a schema with several tables only the first one got them — every other table's == / in_ filters scanned every row. Each table now names its own, and on boot an existing table gets what it lacks with CREATE INDEX CONCURRENTLY: writes go on, but on a large table the boot waits for the build. Build them beforehand with base_index_statements() (see Storage format) and the boot finds them and builds nothing.

Finish the rollout before relying on it: an old-version process that boots after an upgraded one (a crash restart, a rollback) builds the fixed-name indexes again, non-concurrently, on the first table it opens — blocking that table's writes until its startup transaction commits. The next upgraded boot drops those copies.

Configuration

configure(admin=...) sets the RBAC root user name, not a URL path

spec.configure(admin="alice")   # username "alice" gets full access
spec.configure(admin="/admin")  # username "/admin" — almost certainly NOT what you wanted

The web admin UI is the separate TypeScript app under wizard/.

Programmatic mgr.create/update/... use anonymous + now() if you don't set a user

By default (non-strict) a programmatic write with no operation context records the same values an unauthenticated HTTP request would — created_by="anonymous" and the current UTC time — so it "just works". To attribute writes to a real user, set a default or wrap the call:

spec.configure(default_user="me", default_now=datetime.utcnow)
# or, per-call
with mgr.using(user="me", now=datetime.utcnow()):
    mgr.create(...)

If you'd rather a missing context be a hard error (no silent anonymous), opt into strict mode — then create/update/migrate/switch without a context raise MissingOperationContextError:

spec.configure(strict_operation_context=True)

MigrateRouteTemplate is opt-in

/{model}/migrate/... endpoints are not registered by default. Add them explicitly before add_model():

from specstar.crud.route_templates.migrate import MigrateRouteTemplate
spec.add_route_template(MigrateRouteTemplate())

The mounted paths follow model_naming and are singular — /issue/migrate/execute, not /issues/migrate/execute.


Backup / restore

/import accepts both a multipart file field and a raw body

GET /{model}/export streams application/octet-stream. POST /{model}/import accepts either multipart/form-data with a file field or the archive as a raw application/octet-stream body — so the export bytes round-trip directly (--data-binary @dump.acbak). See Backup & Restore for curl recipes.


GraphQL is opt-in

The README lists GraphQL as a feature; that means supported, not on by default. To use it:

pip install specstar[graphql]
from specstar.crud.route_templates.graphql import GraphQLRouteTemplate
spec.add_route_template(GraphQLRouteTemplate())

Naming / paths

Multi-word class names are kebab-cased, not snake-cased

BlogPost → /blog-post, XMLNode → /xml-node (acronyms are treated as one word, not split letter-by-letter). The default model_naming="kebab" is lowercase with hyphens; see Routes howto § model_naming reference for a full matrix and an example of supplying a callable (e.g. to pluralise).

Path params are always resource_id, not id

GET /user/{resource_id} — there is no /user/{id}. Same for every generated route.