Releases: Code30-OSS/TenantLess
Release list
v1.6.0 — Canonical ARM identity, verified drift fingerprints
Minor release. ARM ids now have one identity rule, used everywhere the simulator compares
them: an ASCII-only case fold (A–Z → a–z, every other byte unchanged). This one rule is
shared by the server, the CLI and PostgreSQL. The served id is still the verbatim casing it
was first written with.
Drift applies now verify their recorded fingerprint against the state they actually persisted,
and roll back on a mismatch. A live state-model conformance test drives the real server and
CLI against PostgreSQL 16.
ARM response shapes, CLI flags and the profile schema do not change. The on-disk identity
migration is one-way, so read the upgrade notes before upgrading a volume you care about.
Upgrade notes (one-way — read before upgrading)
-
Upgrade the server and the CLI together, and do not downgrade afterwards. 1.6.0
migrates the volume in two steps:- the first 1.6.0 server boot, or the first 1.6.0
tenantless init-db/generate/
apply-drift/reset/revert-drift, re-derives the overlay identity constraint onto
the new fold; - the first 1.6.0
init-db/generateretires the old locale-lower()identity indexes.
After that, older versions refuse the volume:
- once the constraint is converted, a 1.5.0 server refuses to boot, because its
structural check expects the old constraint; - once the indexes are retired, a 1.5.0 CLI fails with
PartialBaseSchemaErroron
generate/init-db, because it expects the retiredlower(id)index.
The wheel and the container image are versioned independently. A mixed pair (for example
a 1.6.0 CLI run against a volume that a 1.5.0 server still serves) breaks the older side on
its next start. To go back, restore a snapshot taken before the upgrade. - the first 1.6.0 server boot, or the first 1.6.0
-
Run
tenantless init-dbonce against an upgraded volume (or
tenantless generate --only-if-empty; the Compose demo profile already does this). The
server never builds indexes at boot. The new identity indexes are builtCONCURRENTLYby
init-db/generatewhile the server keeps serving. Until they exist, every identity
lookup (resource detail, resource-group listing, ARM writes) scans the whole resource table
and is slow on a large tenant. If they are missing or invalid, the server logs aWARNat
boot that names them and gives this remedy. -
Drift fingerprints change once, for the same state. Fingerprint rows are now ordered by
the canonical identity key rather than by the raw id. The hashed content is unchanged, but
the digest of unchanged state differs whenever raw and folded order disagree. On the stock
demo tenant (seed 42) the unchanged state fingerprints as4ce91b8c…on 1.5.0 and
6bf663cd…on 1.6.0 (4 of 4,960 rows change position, e.g.Microsoft.DataFactory/…vs
Microsoft.Databricks/…). So the firstapply-driftafter the upgrade records a
parent_fingerprintthat does not equal the last 1.5.0result_fingerprint. Consumers of
/_sim/driftsee one chain break at the upgrade boundary, and golden "same seed → same
fingerprint" values recorded on 1.5.0 must be re-recorded. -
Non-ASCII case variants are now distinct identities. Ids that differ only in the case
of non-ASCII letters (e.g.…/Àvs…/à) were one identity under the localelower()and
are two now. ASCII case-insensitivity is unchanged. The upgrade refuses to migrate a volume
that holds such a divergence or a fold collision, and names the ids. Rename or de-duplicate
them first. After the migration, estates holding non-ASCII ids are accepted as they are. -
Built from source off an untagged
mainafter 1.5.0? Volumes whose identity indexes
were built by that interim build carry an older (output-identical) fold definition in any
long-lived session's cache. Reconnect long-lived sessions, or run
REINDEX INDEX CONCURRENTLY synthetic.idx_res_arm_id_keyand
REINDEX INDEX CONCURRENTLY synthetic.idx_res_rg_ascii_foldonce. Tagged releases and
1.5.0 volumes are not affected.
Changed
- One identity rule for ARM ids. Every stateful comparison uses the same ASCII-only
fold: lookups, writes, tombstones and resurrection, the delete cascade, drift apply and
revert, and the resolver joins. The fold is identical across the Rust server, the Python CLI
and PostgreSQL, and a shared known-answer corpus pins it. The servedidis always the
verbatim casing it was first stored with. A deleted resource keeps that casing when a later
PUTrecreates it through a differently-cased URL. - Drift never hides a parent that still has children, whatever their casing. The leaf-only
rule for temporal disappear now uses the canonical identity, so it agrees with the delete
cascade. Generated tenants are unaffected: their drift output is byte-identical.
Fixed
- A
PUTthat recreates a deleted resource through a differently-cased URL returned500. It
now returns201with the original casing. DELETEof a resource whose name contains a backslash could leave its children live. It now
matches the name literally (backslash,%and_).- Concurrent schema provisioning (for example a server restart during
generate) could fail
withtuple concurrently updated. It now waits instead.
Verified
- Drift applies re-read the persisted state inside the apply transaction, verify the
fingerprint and roll back on any mismatch. - A model-based conformance test drives the real server and the real drift CLI against
PostgreSQL 16. It checks global invariants after every step, including identity, served
casing, revisions, fingerprint chaining and cascades. It runs in CI as a non-skipping job.
Container images: ghcr.io/code30-oss/tenantless-mock-server:1.6.0 and ghcr.io/code30-oss/tenantless-generator:1.6.0.
v1.5.0 — Generic ARM writes (off by default)
Minor release. Adds a generic, service-model-independent ARM write plane — PUT / PATCH / DELETE on any resource id — on top of the 1.4.0 overlay substrate.
No breaking changes. The write methods are gated behind --enable-arm-writes and stay off by default, so with writes disabled every response shape, header, CLI flag, and the profile schema are unchanged, and ARM reads remain byte-identical to 1.4.0. Writes are synchronous (the terminal status is returned inline, never a long-running-operation poll).
Added
PUT/PATCH/DELETEon any resource id — create, full-replace, two-level merge, and tombstone — for any type, including deeply nested and uncatalogued ("opaque") resources, whose extra top-level keys survive a create → read round-trip verbatim. Every write is a copy-on-write overlay snapshot; the baseline stays immutable.- Conditional writes — reads emit a strong
ETag, and writes honourIf-Match/If-None-Match: a staleIf-Matchis a412,If-None-Match: *guards creation, and the read → check → write is serialized so concurrent conditional writers cannot lose an update. - Atomic nested-containment cascade on
DELETE— deleting a parent tombstones every strict nested descendant in one transaction (by parsed id segments, soservers/s1never sweepsservers/s10). - Write-safety perimeter — writes are off by default; when enabled, the server refuses to start on a non-loopback host without authentication unless an explicit
--allow-insecure-writesoverride is passed, and never grants authorization on its own.
Changed
- A user write takes ownership of a resource: configuration drift (
apply-drift/revert-drift) no longer overwrites, tombstones, or re-mints onto it, so hand-edited resources detach cleanly from drift simulation and the drift fingerprint chain stays consistent across the co-existence.
Verified
- The full write lifecycle plus the off-by-default gating and the non-loopback startup refusal, validated end-to-end against ephemeral PostgreSQL 16.
Container images: ghcr.io/code30-oss/tenantless-mock-server:1.5.0 and ghcr.io/code30-oss/tenantless-generator:1.5.0.
v1.4.0 — The stateful ARM overlay goes live
Minor release. Turns on the persistent write-plane substrate that 1.3.0 landed dormant: every
ARM read now resolves through the overlay, all in-place drift is migrated onto it, and the
baseline becomes truly immutable. No breaking changes: on an empty overlay the served ARM
responses are byte-identical to 1.3.0, the new ETag header is additive, and the new reset
command adds functionality without altering any existing flag, response shape, or the profile
schema. Writes are still not enabled by default.
Added
- Unified resolver behind every ARM read. Resource, resource-group, and detail handlers now
read throughsynthetic.arm_resolved_resources—baseline ∪ overlay(present) − tombstones—
instead of the rawsynthetic.resourcestable. A tombstoned id resolves to a404, an
appeared id resolves to its overlay body, and the immutable baseline shows through unchanged. ETagemission on detail GETs. Detail responses now carry a strong, quoted validator
derived from a canonical preimage of the served representation —b-<hash>for a baseline
resource,o-<revision>for one carrying overlay drift. Derivation only;If-Match
consumption arrives with ARM writes in a later release.resetcommand. Restores a tenant to its seeded baseline by clearing all overlay drift
(modified rows, appeared rows, and tombstones), leavingsynthetic.resourcesuntouched.- Structural reader-inventory gate. A build-time gate that inventories every reader of
synthetic.resourcesand fails closed unless each one is either routed through the resolver
or explicitly sanctioned with an in-sourceSYNRES-ALLOW[...]marker — so no future reader
can silently bypass the liveness view. - Fail-closed boot guard. The server (and the generator /
init-dbpath) refuses to start
on a structurally incomplete or in-place-mutated substrate rather than serving stale state,
via a single read-only preflight probe that never takes anACCESS EXCLUSIVElock at boot.
Changed
- All drift now writes to the overlay, never the baseline. Apply and revert of tag,
property, SKU, and kind edits plus resource appear/disappear are migrated onto
synthetic.arm_overlay; no drift path mutatessynthetic.*in place anymore. Migrated drift
stays observable through the resolver, and baseline immutability is now a hard guarantee. - The console, search, and summary readers apply the liveness filter too. Dependency edges,
violation counts, and summary aggregates now resolve both endpoints through
synthetic.arm_resolved_resources, so findings and edges on tombstoned or non-live resources
drop out — converging exactly with what the ARM plane serves.
Verified
- Byte-identity on an empty overlay, plus full-visit keyset pagination and
$filtertraversal,
re-validated against a ~500K-resource mixed baseline/overlay/tombstone estate — no regression
from the resolver at scale, and theEXPLAINaggregate-scale gate holds on PostgreSQL 16.
v1.3.0 — ARM overlay/tombstone/revision substrate
Minor release. Adds the persistent substrate for the stateful ARM write plane. No breaking changes — no reader consults the overlay yet, so ARM response shapes, CLI flags, and the profile schema are unchanged and existing responses are byte-identical.
Added
- ARM overlay substrate (
sql/009_arm_overlay.sql) — a unifiedsynthetic.arm_overlaytable (one row per normalized resource id;present=falseis the tombstone), an unowned non-cycling revision sequence, and aBEFOREtrigger that assigns a fresh, strictly-advancing revision on every write. - Boot provisioning — idempotent
ensure_arm_overlay_schemain the mock-server startup preflight and the generator /init-dbpath, applied in a bounded transaction. A deep structural-completeness check fails closed on a corrupt substrate rather than serving on it. - ETag derivation — versioned derivation of strong, quoted baseline (
b-<hex>) and overlay (o-<revision>) validators from a canonical preimage of the served representation. Derivation only — not yet wired into any handler.
Images
ghcr.io/code30-oss/tenantless-mock-server:1.3.0ghcr.io/code30-oss/tenantless-generator:1.3.0
PR #64 · full changelog in CHANGELOG.md.
v1.2.1 — Refresh served JWT identity on tenant mutation
Patch release. Fixes a JWT-identity staleness bug.
Fixed
- The served token identity now tracks the current tenant instead of freezing at boot. The RS256 signer backing
/token, the JWKS and OpenID-discovery documents, and--enforce-authvalidation embeds the served tenant in its issuer. It was built once at startup, so after a control-plane generate (new tenant), restore (the snapshot's tenant), or reset (empty → nil) the mock kept serving the previous (or nil) identity until a restart. The control plane now rebuilds the signer to the effective tenant when a tenant-mutating job completes — after the DB change commits and before the job is reported succeeded, as one atomic key+kid+issuer epoch, and only when the tenant actually changed (an unchanged tenant does not rotate the key or invalidate live tokens). A failed re-read/rebuild fails the job rather than reporting a mismatched identity. Analyze and snapshot-save do not refresh.
No breaking changes: additive behavior, unchanged ARM shapes / CLI flags / profile schema; the default any-Bearer (--enforce-auth off) posture is unaffected.
Docker image: ghcr.io/code30-oss/tenantless/mock-server:1.2.1
v1.2.0 — Execution budgets
Minor release. Adds resource-exhaustion guards across the server so an any-Bearer caller cannot force unbounded memory/CPU/time. No breaking changes: ARM response shapes are unchanged, the new CLI flags are all additive (with defaults), and the profile schema is untouched. Every new failure is returned in the ARM CloudError JSON envelope with a fixed, non-leaking message.
Added
- Global request timeout —
REQUEST_TIMEOUT_SECS(default 30); an elapsed request returns504 GatewayTimeout. - Server-wide concurrency limit with load-shedding —
CONCURRENCY_LIMIT(default 64); at capacity the server sheds with503 ServiceUnavailable+Retry-After: 1rather than queueing. An invalid value fails startup. - Server-wide database timeouts —
DB_STATEMENT_TIMEOUT_MS(default 10000) andDB_ACQUIRE_TIMEOUT_SECS(default 5). A cancelled statement (Postgres57014) returns504; pool exhaustion returns503+Retry-After: 1. The cost query inherits the server-wide budget (never loosens a tighter global) and keeps its own app-level "too expensive"400. The startup schema preflight is exempt from the runtime timeout. $filtersize cap — over 2 KiB or 200 tokens is rejected400before parsing, on both the resource-list and roleAssignments$filtersurfaces./_simsearch-term cap — over 200 characters is rejected400before the tenant-wide scan.- Cost query body-size cap — bounded at 64 KiB, enforced while streaming (chunked-safe); oversized body →
413, non-JSONContent-Type→415, malformed JSON →400.
The operational budgets are environment-tunable; the structural caps are fixed safety bounds.
Docker image: ghcr.io/code30-oss/tenantless/mock-server:1.2.0
v1.1.12
Security-hardening patch. No API, CLI-flag, or profile-schema changes -- only the published mock-server container image changes. Completes the container-hardening work (base images were already digest-pinned; the generator/demo-builder images already ran non-root).
Changed
- The
mock-serverruntime image runs as a non-root user (uid 10001,nologin). The binary is world-executable and binds8080(> 1024, no privileged-port capability needed); serving the ARM API reads only env + the embedded SPA/SQL and writes nothing to the filesystem, so an unprivileged UID suffices. CI asserts the built image's effectiveid -uis non-zero, so a regression to root fails the build.
Documented
- The slim runtime image cannot run the control plane (
generate/analyze/snapshot/restore), which shell out touv run tenantlessandpg_dump/pg_restore-- a deliberate limitation, now explicit in the Dockerfile and changelog, that keeps the served-API image toolchain-free.
Full Changelog: v1.1.11...v1.1.12
v1.1.11
Bug-fix patch. No CLI-flag or profile-schema changes, and no change to the shape of a roleAssignments item. Brings the Microsoft.Authorization/roleAssignments list endpoint in line with the other ARM list endpoints.
Fixed
- Keyset pagination. The role-assignment listing now honors
$top(clamped1..=1000), an opaque$skiptokencontinuation over the assignment id, and emits an absolutenextLinkwhen another page exists, instead of returning every assignment for a subscription in one unbounded response. $filterhonored, not silently ignored. The ARMatScope()andprincipalId eq '{guid}'forms (and theirand-composition) are applied as bound$Npredicates; any other or malformed form, including a non-GUIDprincipalId, returns an explicit400 InvalidRequestContent("invalid $filter"). Azure definesatScope()as "assignments at or above the given scope"; because this simulator models no management-group or tenant-root assignments, at a subscription scope that reduces to exactly/subscriptions/{sub}. The$filteris echoed innextLink.- Index-backed keyset. A composite
idx_ra_sub_assignment (subscription_id, assignment_id)turns the seek +ORDER BY assignment_idinto an index range scan. It ships in the existing idempotentsql/005_identity.sqlmigration (applied unconditionally bygenerate/init-db), so a database provisioned before this release gains the index automatically on its next run.
Full Changelog: v1.1.10...v1.1.11
v1.1.10 - index the case-insensitive resource-group predicate
Bug-fix patch (performance). No API, CLI-flag, or profile-schema changes. Follow-up to 1.1.8.
Fixed
- The case-insensitive resource-group predicate is now index-backed. The existing
idx_res_rg (subscription_id, resource_group_name)can serve only the subscription prefix for alower(resource_group_name)match — Postgres cannot use its second key for the functional predicate — so at scale a resource-group-scoped listing could scan every resource in the subscription, and the cost query could process a broader join before filtering. A new functional indexidx_res_rg_lower (subscription_id, lower(resource_group_name), id)backs both paths; the trailingidserves the listing'sORDER BY id/ keyset pagination. This mirrors the existinglower(id)functional index for the resource-detail lookup. - Existing databases upgrade automatically. The index ships as a new idempotent migration (
sql/008_rg_lower_index.sql) applied unconditionally bygenerateandinit-db— the same twin-migration mechanism as the cost/identity/drift/web-metadata schemas. A database provisioned before this release gains the index on its nextgenerate/init-dbwith no manual step, and it is deliberately not part of the base-schema completeness check, so an additive performance index never makes a healthy install read as "incomplete" or demand a re-provision.
v1.1.9 - bound the control-plane job store memory
Bug-fix patch. No API, CLI-flag, or profile-schema changes — the 1.x public surface is unchanged. Affects only the in-memory control-plane job registry (present when the control plane is armed).
Fixed
- The job registry no longer grows without bound. Every armed control-plane job (generate / analyze / reset / snapshot / restore) was inserted into an in-memory map and never removed, so a long-running server accumulated job history for the life of the process. A fresh job now first evicts the oldest already-terminal jobs down to a retention bound (100), so completed history stays bounded; in-flight (queued/running) jobs are never evicted.
- A single captured log line can no longer grow memory without bound. The per-job log kept only the last N lines, but each line was read with an unbounded reader (
next_line()reads the whole physical line into memory first), so a child emitting an enormous newline-free line could allocate it in full. Captured lines are now byte-capped (8 KiB retained per line, with a truncation marker) and the physical remainder is drained without being retained, so total per-job log memory is bounded.