Skip to main content

Apiome RC4

RC4 closed on 2026-08-20. It brings the app notes below and REST API 1.215.1 to 1.263.0 (40 versions). See every issue in the RC4 milestone.

What's new in the app​

From the in-app What's new for Apiome 07-2026 RC4.

Features/Improvements​

  • Import: Arazzo workflow documents now import as first-class Workflow and Workflow Step entities; each step's operationRef/operationId links to the matching operation when that OpenAPI spec was imported in the same scan, and an unresolved reference keeps its raw value with a warning instead of being dropped
  • Repository: specs that reference schemas on external hosts are now governed by a per-tenant policy — block (the default; nothing is fetched and the file is flagged with exactly which references are missing), inline (permitted references are fetched once and snapshotted into the scanned spec), or proxy-fetch (the same, restricted to an allowlist of hostnames, wildcards like *.acme.com included). Every fetch is recorded in the audit trail
  • Repository: registered repositories now accept signed webhook deliveries, so a push to a branch you import from makes the repository due for a refresh immediately instead of at the end of its polling interval. Pull-request events can additionally index the PR's head branch so you can inspect the specs a review touches before it merges. Each repository gets its own signing secret, and the delivery history — including anything that failed to verify — is visible per repository
  • Repository: a repository's webhook signing secret can now be rotated without a break in service — the new secret is installed at the provider, the old one keeps working for a grace window (24 hours by default) so deliveries already in flight still arrive, and it then expires on its own. If the provider could not be updated, the repository says so, and how long is left before deliveries start failing
  • Repository: a new Spec catalog (Repositories → Spec catalog) lists every discovered spec across all your repositories in one searchable table. Search by path, format, repository or project; filter by format, repository, project, or status (needs attention / imported / mapped / discovered); sort by any of them. Each row links straight to that spec's detail view on its own repository, and the whole view lives in the URL, so a filtered catalog is a link you can paste to a colleague. Paging is server-side and stays fast on workspaces with tens of thousands of files
  • Repository: every repository now carries a health badge — healthy, warnings or error — on the repositories list and on the repository detail header. It rolls up how many scans succeeded over the last 30 days, how many discovered specs failed to parse, and whether the linked account's access token is still good. Hover it and the tooltip leads with the most recent thing that went wrong, so you can see what changed without opening the repository. A credential problem never shows as healthy, however clean everything else is
  • Repository: your webhook channels are now told when a repository needs attention — when auto-refresh pauses itself after repeated failures, when a sync introduces a breaking change, and when a repository has been failing for a while but has not paused yet. Each repository can opt out of each of those individually, and any one of them is sent at most once an hour per repository, so a repository stuck in a failure loop cannot flood the channel. A channel pointed at a Slack incoming webhook receives a proper Slack message rather than raw JSON
  • Repository: repository polling now has a per-tenant hourly ceiling (60 by default, 600 on the elevated plan), so one busy workspace can no longer crowd everyone else out of the refresh scheduler. Repositories over the ceiling are simply picked up on a later pass — they are never marked as failed, never backed off, and never paused — and manual "Refresh Now" is never limited
  • Repository: tenant administrators can now download the complete repository audit trail — refresh cycles, webhook activity, secret rotations, external-reference fetches and more — as a dated CSV or JSON file for SOC 2 / ISO 27001 reviews. Pick a date range and a format and the export streams no matter how large the ledger is; every export (even one that was cut off mid-download) is itself recorded in the audit trail, so the evidence includes who exported the evidence
  • Repository: a new Quota & limits page (Repositories → Quota & limits) shows what the polling ceiling and the scanner have actually been doing over the last 7, 30 or 90 days — polls, scans and content scanned, with the repositories and files the quota deferred charted separately so "we were throttled" is never mistaken for "we were quiet". The panel leads with how much of the current hour's budget is spent and warns as you approach the ceiling, rather than after work has already been postponed. Counters survive restarts and are combined across servers, and if they cannot be read the page says so instead of showing you a flat line
  • Repository: webhook deliveries can now be filtered by source address before their signature is ever checked (Repositories → Webhook IPs). The provider's own published ranges — GitHub's meta endpoint, Atlassian's for Bitbucket — are fetched daily and cached, so the list stays right as the providers move; your workspace can add its own ranges for a self-hosted runner or an egress gateway, and each one records why it exists. The page states in a sentence whether the filter is actually protecting anything right now, rather than leaving you to combine three switches, and flags a provider whose range list has stopped refreshing. Turning the filter off for your workspace is a tenant-administrator action and asks for a reason, which goes into the audit trail
  • Repository: you can now choose what an auto-refresh does when it finds a spec you edited in Apiome after it was first imported (repository detail → Settings → Refresh conflicts). Hold for review stays the default and changes nothing: the refresh is skipped and the file is flagged, so nothing is overwritten until someone looks. Overwrite lets the repository win — the divergence is still recorded, so you can always see what was replaced. New branch keeps both, landing the refresh on a new branch instead of touching your edited version. Set it once for the repository, and override it for the one file that needs to differ; clearing an override puts that file back on whatever the repository is set to next, not on a stale copy of today's choice
  • UI/UX
    • The Apiome bee is now drawn as vector art rather than a picture, so the logo stays sharp at every size and its outline lightens on the dark themes instead of disappearing into them. The browser-tab, home-screen and installed-app icons are all generated from that same mark
    • Major updates look and feel
    • Added tabbed sections to Style Guides
    • Softened the font of the entire application
    • Moved Preferences to the bottom of the left-hand sidebar
    • Changed "Themes" to "Preferences" in the upper right-hand profile button
  • Primitives
    • Major UX improvements in the import functionality
    • Shows unregistered namespaces that were detected
    • Import now cautions when a type declares no "type" of its own, since it will accept any value
    • Now shows unassigned/unspecified namespaces in JSON Type definitions
    • Grouping primitives in a namespace now works logically as expected
    • Primitives are now clickable inside the reference graph
    • Example form now builds inputs from the schema and allows for testing
    • Cards for reference resolution and base chain details now include traversable $refs if any apply
    • Clarifies language when importing and creating $ref for a system type based on "format" in a property
    • Documentation-only schemas that contain no type still get imported, but are treated as warnings
    • Review section of import for primitives now classifies unresolved $ref as a warning, so now shows warning counts
    • Changed "Test this type" to be expand/collapse with a chevron for testing
    • Now shows any warnings generated during import

Bug Fixes​

  • Primitives
    • Added $ref lookups during primitive import, warning of unresolved $refs if any exist
    • Now shows the JSON Schema using monaco-editor
    • Added the ability to test a primitive by presenting a usable form that represents the content of the JSON Schema
    • Duplicate names are no longer treated as duplicates unless the namespace is identical
    • Removed invalid previously created primitives
    • Corrected resolution for $ref values in native system types
    • Updated import so that names with dashes are imported properly
    • Schemas that carry only documentation now import as the empty object type it describes instead of being rejected
    • Dependents card now shows properly
    • Added clarifying verbiage on unresolved $refs at import
    • $ref resolution is now local-only: references resolve to types by their place in this registry (namespace + name), never to a remote URL — imported documents' foreign $ids are ignored for resolution, and the review agrees with the import screen's preview
    • "Test this type" now handles additionalProperties: map objects offer named add/remove rows, each value validated live against the entry schema
  • Repositories: Fixed file listing and scanning issues

REST API​

1.263.0 — 2026-08-17​

Added​

  • Re-issue an outstanding member invitation (#5305) — POST /v1/access/{tenant_slug}/members/{user_id}/resend-invite. Apiome does not mail invitations: an invitee already holds an account and their pending membership flips to active the next time they sign in. Re-issuing therefore renews the invitation rather than re-sending a message — the membership row is re-stamped (touch_pending_membership, scoped to status = 'pending' in SQL so an invitation accepted in between cannot be un-accepted) and the renewal is written to the access ledger as member.invite_resent, which the existing ?filter=member audit tab already covers. Gated on members:create; consumes no seat, because the pending membership already holds one. Answers 404 when the user has no membership in the tenant and 409 when their membership is not pending.

Changed​

  • GET /v1/access/{tenant_slug}/members carries three more facts per member (#5305) — joined_at (tenant_users.created_at, when the membership was first written, as distinct from the existing member_since = updated_at, which moves with every status change), last_active (users.last_login_at, V070) and two_factor_enabled (the Better Auth users."twoFactorEnabled" flag, V201). All three read columns that already existed; the response is additive, so existing clients are unaffected.

1.262.0 — 2026-08-15​

Fixed​

  • Project versions were re-linted when listed (#5259) — the Versions screen rendered a lint badge per row that called GET .../{version}/lint for every revision, and for any revision without a stored report (hand-authored, forked, pushed, pre-V160) that endpoint rebuilt the OpenAPI document, ran the linter and the external validation pack — on every list render, N times over. Fifty revisions meant fifty concurrent lints.

    The report now lives on the version record and is served from there. GET /v1/versions/{tenant_slug}/{project_id} carries each row's stored qualityScore / qualityGrade (VersionSchema; get_versions_for_project / get_version_by_id select the V124 columns), so the list renders from the record and issues no lint requests. GET .../lint serves the stored quality_report whenever it is current; the persisted report now carries a source_fingerprint (sha256 of the reconstructed OpenAPI document, app.version_quality_capture.openapi_source_fingerprint), and freshness is decided by rebuilding the document and comparing — never by re-linting. A revision with no stored report, or whose content changed since capture, is linted once and the result persisted (persist_version_lint_report), so the next read is a plain read; a baseRevisionId comparison is computed live and never stored. Reports without a fingerprint (pre-#5259, canonical-model imports) are served as-is; a legacy native import re-linted from its canonical model is stored on first open too.

    Linting runs on version changes and imports only. Push (POST /v1/versions/{t}/{p}) and fork schedule the shared capture (capture_version_quality_score, moved out of spec_import_engine) as a background task; the publish precheck stores the report it already computed; import/conversion captures gained the fingerprint + guide context (persistable_lint_report). Stored reports echo the guide they were scored under (guideId / guideName / guideSource) so the read path has the same context as a live run.

1.261.0 — 2026-08-07​

Added​

  • Bounded primitives search (DWX-3.1, private-suite#2683) — GET /v1/primitives/{tenant_slug} has always answered with every primitive a tenant can see. A tenant that has imported a standard library has thousands of rows, and the unified workspace's type picker — a 320px rail — cannot be built on a read like that. The endpoint now takes q, scope, namespace, limit and cursor, and answers those with at most limit rows plus the four type-picker tab counts (app.primitives_search_store, PrimitiveSearchPage).

    Both shapes, one path. A caller that asks none of the five bounded parameters gets the classic JSON array, from the same get_primitives_for_tenant read, with category working exactly as before — the classic property dialogs are unaffected and keep working until private-suite's DWX-8.3 retires their callers. Asking any one of the five switches the response to the paged envelope. The two shapes list the same catalog: the same visibility scope (is_system ∪ the caller's own rows) and the same (namespace, name) deduplication, so a primitive is never reachable through one and not the other.

    The scope classification is the client's rule, in three places that must agree. The four tabs — Standard, Core, Tenant, Custom — are derived in the designer today by classifyPrimitive. A server that filtered by scope while the client grouped by its own rule would silently hide types, so the rule is now written three times over one shared fixture (tests/fixtures/primitive_scope_cases.json): the TypeScript original, classify_scope in Python, and SCOPE_EXPRESSION in SQL. pytest checks Python against the fixture and SQL against Python over real rows; the designer's jest suite checks the TypeScript half against its copy of the same cases.

    The cursor is keyset, not an offset. It carries the sort key of the last row handed out, so a primitive created mid-scroll cannot shift a page boundary and make a row repeat or vanish; a live-DB test walks 5,000 rows and asserts each is visited exactly once. It is opaque, and a token this endpoint did not mint is a 400 rather than a silently ignored parameter that would restart a paging client at page one forever. An unknown scope is likewise a 400 — a misspelling that quietly listed every tab would make an unbounded read look like a bounded one.

    Tenancy is unchanged and enforced by construction: another tenant's private types are not filtered out of the result, they are never in the visibility CTE, so no query, namespace, cursor or $ref reaches one.

    apiome-db/scripts/V244__primitives_bounded_search_indexes_2683.sql adds the read-path indexes: a (namespace, name, tenant_id) b-tree for the dedupe and the cursor ordering, an (is_system, source, namespace) b-tree for the scope classification, and pg_trgm GIN indexes so a leading-wildcard ILIKE is not a sequential scan of the registry. Nothing there changes a column, a constraint or a value, and — as in V230 — the trigram block degrades to a NOTICE where the migration role cannot install contrib extensions.

1.260.0 — 2026-08-05​

Added​

  • Programmable custom palette actions (DUW-5.5, private-suite#2592) — The ⌘K palette's Actions band has been a fixed registry of five built-ins since DUW-5.4; "programmable" means a tenant defining its own rows — Open runbook for {subject} against every class whose name contains Invoice — and that needs the definitions stored somewhere durable and tenant-scoped. apiome.workspace_custom_actions (V243) and the CRUD surface under /v1/workspace/{tenant_slug}/custom-actions are that storage.

    A definition is a declaration, never a script. Its matcher is a subject kind (class, path, property, any) plus an optional case-insensitive label substring; its effects are an ordered list drawn from a closed vocabulary — hydrate-set, lens-switch, open-inspector-tab, run-consumption-query, open-url — each element validated down to exactly the fields its type declares (workspace_custom_action_rules). Unknown keys are rejected rather than ignored, so a typo cannot become an effect that silently does less than its author meant; open-url accepts absolute https:// URLs only, with no embedded credentials, because javascript: and data: are not effects, they are payloads. Anything resembling SDK-script execution stays out of scope by design and defers to the DUW-7.4 sandbox. The database independently pins the outer shape — a JSON array of 1–5 elements under 16KB — so no write path can park a script here even if it skipped the service schema.

    Tenancy comes from the token, never the URL: every statement is scoped by the caller's tenant, so another tenant's action is a 404 — whether it exists is not something this API confirms. Reads are open to any authenticated member (the palette performs one for everyone); writes require an attributable user holding VERSIONS/EDIT, the same gate the domain folders use for reorganizing what everyone sees. Deletes are soft, and a live action's name is unique per tenant case-insensitively — two rows both drawn as Open runbook… in one band would be indistinguishable to the reader. The management page that will wrap this API is DUW-8.2 (private-suite#2602); until it lands, these routes are the management surface, which is why every 422 names the offending field as a pointer (effects[1].lens).

1.258.0 — 2026-08-05​

Added​

  • Response status codes on the scoped path read (DUW-4.3, private-suite#2583) — The unified workspace's paths lens draws every operation as a lane, and a lane ends in the codes it answers with (200·400·401), coloured by method. Those codes were the one thing on the lane the scoped read did not carry: GET /v1/workspace/{tenant}/version/{version_id}/paths shipped each operation's operation_id, summary and deprecated flag and left everything about a response with the per-path /full endpoint. That is right for a response body — schemas, content types and examples are inspector-sized data for one selected operation — but a status code is a label the canvas prints on every lane it draws, and there is no number of round trips between "one" and "one per operation" that answers it.

    Each operation now carries response_codes: the status codes it declares, as strings, ascending (default sorts after the numbers), empty when it declares none. They come from a lateral aggregate over path_operation_response_link → shared_path_response on the statement that was already reading the operations, so a page of paths costs the same two statements it did before, whatever its size. The codes are per operation, not per path: responses are shared per path in this schema and linked per operation, so a read that rolled up by path would give every verb on /customers the same list. Response bodies stay exactly where they were.

1.257.0 — 2026-08-04​

Added​

  • Schema↔path consumption index (DUW-1.4, private-suite#2571) — Five surfaces of the unified workspace need to know which operations consume which classes and how: the combined lens's edges (solid amber for a schema named directly by a request or response, dashed rose for one reached through a parent class), the tree's per-path Schemas rows (Customer 200, Address nested), the palette's "find every path that consumes X" action, the inspector's Consumes list, and the status bar's N schema↔path links chip. Today's derivation is the designer's createAllEdges — O(classes×properties) over a full-catalog fetch, and schema↔schema only; it has never known that an operation consumes anything. GET /v1/workspace/{tenant}/version/{version_id}/consumption answers it from the server, in seven statements.

    Every edge names both members, how the consumption arrives (request, parameter, response.<status>) and — for a nested one — the chain of classes it hangs off, so one response drives all five surfaces. The facts arrive twice: flat in edges, the shape the canvas draws, and rolled up per path in paths, the shape the tree nests under a path with the badge each row prints. link_count counts operation↔class edges, path_link_count distinct path↔class pairs.

    Five decisions are load-bearing:

    • A reference is a $ref anywhere in a payload. The catalog stores an operation's schemas as a class_id column, an inline schema or a legacy data blob, and a class's own references as $ref, items.$ref, allOf/anyOf/oneOf, or any of those nested inside another. Enumerating the shapes would mean re-deriving the emitter's rules in reverse and losing an edge whenever they gained a case, so the resolver walks the JSON and collects every $ref — exactly the set of names the emitted document carries. Only the tables the emitter reads are indexed (shared_path_response(_content), shared_path_request_body_content, shared_path_parameter); the V028-era tables V031–V034 superseded are read by nothing, and indexing them would invent edges no exported document contains.

    • Nesting is resolved per class, not per operation, and breadth-first. Two operations returning Customer reach the same descendants through the same edges, so the walk runs once per class and is memoized — walking per operation would be the client-side derivation moved to the server and multiplied by the operation count. Breadth-first makes via the shortest parent chain, and ties break on class name, so "nested via X" is a property of the catalog rather than of row order. Cycles terminate by construction: the visited set includes the root, so a self-referencing class and a mutual pair are each walked once and the root is never nested under itself. Depth is capped at 6 hops (depth_cap) and a graph continuing past it says so through depth_capped.

    • A directly named class is never also nested. The canvas draws one line between two nodes, and the solid one is the truthful description.

    • The scope narrows paths; the graph is always whole. domain_id and path_ids are mutually exclusive path selectors; class_ids narrows the class side and composes with either, because "which of these classes does customers/ consume" is a real question. The class filter is applied after the walk — filtering the graph first would drop the very parents a nested edge is reached through, so "every path that consumes Address" would miss every path that reaches it through Customer, which is most of them. A domain-scoped answer therefore still names classes outside the domain, which is what "nested via parent" means.

    • Caching is content-addressed. The index is computed on read and never persisted (no table in v1); the response carries a strong ETag digested from the body, which keys it on version content by construction and on the scope — something a stored version-content hash would not do — so a repeat read is a 304 until the index actually changes. Same convention as the APX-3.4 agent outputs.

    Bounded and honest about it: edge_limit caps the edge list at 5000 with truncated set, and there is no cursor, because an edge means nothing without both members it connects. Unresolvable path or class ids come back in missing_ids rather than being silently absent, and the two id selectors share the DUW-1.2 cap of 200 per request. Verified against a seeded 218-path / 250-class catalog under pg_virtualenv: the mockup's Customer/Address/ContactMethod × 4 operations reproduced edge for edge including nested via parent and the status bar's six path↔class pairs, a self-referencing class and a mutual pair terminating on stored rows, seven statements whatever the scope, and p95 ≈ 5.3 ms against the epic's 300 ms budget. No migration: V242's domain_id indexes and the existing version_id indexes cover the reads. The BFF routes and typed client are DUW-1.5.

1.256.0 — 2026-08-04​

Added​

  • Domain summary & counts API (DUW-1.3, private-suite#2570) — The workspace tree draws a badge on every domain folder before anything is hydrated — customers/ 3·4, billing/ 5·9, shared/ 8 — and each lens badges the same folder with a different number (3 classes in the schemas lens, 4 ops in the paths lens). Deriving any of that in the browser would mean fetching the whole catalog first, which is the read DUW-1.2 exists to eliminate. GET /v1/workspace/{tenant}/version/{version_id}/summary answers it in one round trip: every folder with class_count, path_count, op_count and enum_count, plus shallow member lists — class rows (id, name, kind, version badge) and path rows carrying their operations (verb, operation_id, summary, deprecated) — which is every field the three tree lens panels draw.

    Counts are exhaustive; member lists are not. A badge that is only right for the first page is not a badge, so each count covers the folder's whole membership, while the rows beside it are capped per folder by member_limit (default 50, clamped to 200, 0 for badges alone) and a folder that was cut reports classes_truncated / paths_truncated, continuing through DUW-1.2's paged reads. class_count and enum_count partition a folder's classes rather than overlapping, matching the mockup's customers/ 3 classes above three objects and one enum; each row carries a kind of object/enum/union read from the stored schema column, so the Schemas and Enums & unions groups need no second pass, and objects sort first so a truncated list is cut from the enum group upward. The v2.1 badge is the version's own label repeated per class row — a class has no version of its own — so a tree row renders without consulting the envelope.

    The cost is four statements whatever the version holds: a window function carries each domain's totals onto its own member rows, so a 40-folder catalog costs what a one-folder catalog does. A per-domain count query would be exactly the N+1 this endpoint exists to prevent, and would pass every functional test. The shared/ bucket is joined with IS NOT DISTINCT FROM, because NULL = NULL would silently drop the largest folder in most catalogs, and empty folders are listed with zeroes so a newly created one cannot look like a failed write. Reads commit, matching the scoped reads: psycopg2 opens a transaction for a bare SELECT too. Verified against a seeded 218-path / 250-class catalog under pg_virtualenv — every badge checked against its own SELECT COUNT(*), the mockup's numbers reproduced, and p95 ≈ 3.5 ms against the ticket's 300 ms budget. No migration: V242's domain_id indexes and the existing version_id indexes cover the aggregates. The per-path schema rows the combined lens nests under an operation remain DUW-1.4.

1.255.0 — 2026-08-04​

Added​

  • Selection-scoped class and path reads (DUW-1.2, private-suite#2569) — The only way to read a version's classes with their properties and tags was GET /v1/classes/{tenant}/version/{version_id}/with-properties-tags, three queries with no LIMIT that return the whole catalog. Twelve designer call sites use it, /editor fires it twice on mount and again after every single-class edit, and that is the direct cause of the browser choking on a large catalog. /v1/workspace/{tenant}/version/{version_id}/classes and …/paths answer the question the canvas actually asks: these items, or this folder, never this version. A selection is mandatory — omitting both class_ids and domain_id is a 400 rather than a convenience default, because that default would be the very read this endpoint exists to replace, and supplying both is a 400 too rather than a guess about which one wins. The server cap is enforced two different ways for one reason: a page size over 200 is clamped, since a domain listing hands back a cursor and the client has a working continuation, while an id list over 200 is refused, since there is no cursor for an arbitrary id set and quietly answering a different question than the one asked would be undetectable. Both bounds are echoed in every response and documented in the OpenAPI parameter descriptions, so a client never has to trigger a 400 to discover them. A bounded read is still a bulk read: three statements hydrate a page of classes and two hydrate a page of paths, whatever the page size, so cost tracks the selection rather than the catalog. Ids that no longer resolve — a class deleted since the selection was made, one belonging to another version, one that is not a UUID at all — come back in missing_ids rather than as a silently short response that would leave an unexplained hole on the canvas. Every query is scoped by version_id even in id mode, which is the tenancy boundary: the version is resolved against the caller's tenant first, so an id from another tenant's catalog matches nothing. total is the size of the whole selection rather than of the page, which is what the workspace sizes its node budget against, and pagination reuses the same opaque cursor format the export and import manifest surfaces already speak. Paths carry their operations with each operation's operation_id, summary and deprecated flag, because the mockup's paths lens draws the operationId beside every verb; parameters, request bodies and responses stay with the per-path /full endpoint, which is inspector-sized data for one selected operation. The legacy full-version read is deliberately unchanged — exports, scoring and readiness sweeps really do want every class — but now carries a deprecation note pointing here. No migration: V242's domain_id indexes were added for exactly these reads.

1.254.0 — 2026-08-04​

Added​

  • Domain folders for schemas and paths (DUW-1.1, private-suite#2568) — The unified workspace organizes a catalog into domain folders and scopes the canvas to one of them, but classes and paths had no hierarchy at all: two flat lists read with ORDER BY name ASC. Tags and canvas groups are not that hierarchy — tags are project-scoped, many-to-many and for filtering; canvas groups are per-layout visual furniture. A folder that scopes a fetch has to be exactly one per item, version-scoped, and stored beside the item it groups, so V242 (apiome-db) adds apiome.domains plus a nullable domain_id on apiome.classes and apiome.version_path. /v1/domains lists, creates, renames and deletes them, and moves a class or a path between them. shared/ is deliberately not a row: a member with domain_id IS NULL is in it, so the bucket always exists, cannot be renamed or deleted, and is synthesized into the list response with id: null and virtual: true. The slug shared is reserved by a CHECK so no stored domain can draw the same folder. Deleting a domain never deletes its contents — the delete is a soft delete, and V242's trg_domains_soft_delete_release releases every member to shared/ in the same statement, with ON DELETE SET NULL covering a hard delete too; the response reports how many classes and paths moved. A database trigger, not a service-layer check, rejects a domain assignment that crosses versions or targets a deleted domain, because a foreign key can constrain domain_id but knows nothing about version_id on either side. Existing catalogs are backfilled: paths seed domains from their first meaningful path segment — skipping templated segments (/{customerId} names an instance) and API version prefixes (/v1/ is on every path, so it partitions nothing) — and classes follow a project tag whose name slugifies to a seeded domain. Anything unmatched stays in shared/, which is the honest outcome for a catalog with no path structure and no tags. Per-domain counts for the tree badges are DUW-1.2 / DUW-1.3, not this release.

1.253.0 — 2026-08-03​

Added​

  • Slate custom domains + DNS/TLS (Slate 10.1, private-suite#119) — The editing half of the domain inventory APX-3.1 could only report, under /v1/slate: attach a hostname to a lane and get back the exact DNS rows to publish, verify ownership against the tenant's live records, probe the host to see what certificate it is actually serving, make a host canonical, park renewal, and detach. A subdomain is delegated with one CNAME; an apex is proven with a TXT record and pointed with ALIAS/ANAME, because RFC 1034 forbids a CNAME beside the SOA and NS records every apex carries. A failed check reports what the resolver found, not merely that it failed. app.slate_dns is a dependency-free DNS client (the stdlib resolver discards the CNAME chain and every TXT record — the two things verification needs); app.slate_tls_probe completes a verified TLS handshake and reads the peer certificate, so every certificate field is an observation of the live host at a stated instant and a renewal is detected (the serial changes) rather than assumed. Nothing here issues, stores or renews a certificate: the edge does (deploy/Caddyfile, Caddy on-demand TLS against Let's Encrypt), and GET /v1/slate/tls/authorize?domain= is the gate it asks first — a single conjunction (row exists, ownership verified, renewal on), unauthenticated because the caller is a TLS handshake with no session to present. Requires V241 (apiome-db), which adds the verification/certificate lifecycle columns and CHECKs that make "verified with no timestamp" and "active with no expiry" unrepresentable. New settings: APIOME_SLATE_DOMAIN_DNS_TARGET, APIOME_SLATE_DOMAIN_RESERVED_ZONE, APIOME_SLATE_DOMAIN_VERIFICATION_SECRET (fails closed in production).

1.252.0 — 2026-08-03​

Added​

  • Snippet service (SDK-2.3, #4487) — Per-operation usage snippets (install + call code) rendered server-side from the persisted canonical model, as the single source of truth for the browse operation pages (SDK-3.3) and the Try It copy-as-code feature (SIM-3.5). Two surfaces share one pure renderer (app.snippet_render): GET /v1/versions/{tenant_slug}/{project_id}/{version_record_id}/snippets/{operation_id}?lang= (authenticated, published revisions only) and GET /v1/browse/tenants/{t}/projects/{p}/versions/{v}/snippets/{operation_id}?lang= (anonymous, published+public with uniform 404s, sharing the public-export rate limit). Languages: ts (built-in fetch), python (httpx, with a pip install httpx install line), and curl, plus browse-vocabulary aliases fetch/httpx. Output shape, escaping, and $API_KEY-style secret placeholders mirror the client-side Try It generators; request bodies are minimal valid instances synthesized deterministically from the payload schema, so responses are content-addressed (ETag / 304). The structured response carries the resolved operation, the synthesized request, and a placeholder inventory so consumers need no post-processing. Snippets derive from the canonical spec directly — the original SDK-2.1/2.2 template dependency was dropped when those tickets were cancelled.

1.251.0 — 2026-08-02​

Added​

  • WIT (WebAssembly Component Model) import (IXH-7.9, #5134) — A new wit ImportSource adapter makes WIT packages importable (file, URL, paste, or a multi-file package fileset). Worlds and interfaces normalize to canonical services on the RPC paradigm, functions to operations (a top-of-return result<ok, err> becomes the RESPONSE/ERROR message pair; option<t> maps to canonical nullability, list<t> to list nesting), and the WIT type system to canonical types: record → RECORD, enum → ENUM, variant → UNION with case payloads preserved, flags → ENUM with bitset semantics flagged, type aliases → ALIAS, resource → RECORD carrying its constructor and methods in extras.
  • Cross-file use resolution — Archive/git filesets merge every .wit member into one package, so use iface.{type} statements resolve against sibling files; a use naming another package is recorded as an external reference (inferred / source_incomplete ledger row), never fabricated or dropped.
  • Capability limits, never silent drops — Constructs the canonical model cannot hold (resources with methods, borrow<…> handle semantics, tuples, nested results, stream/future wrappers) are preserved in extras and reported on the import preview coverage ledger as partially-mapped capability limits; declared parser limits (include expansion, secondary nested package blocks) carry not-parsed-by-adapter registry entries.
  • Corpus ladder — Full six-rung WIT corpus (minimal, typical calculator, world composition, type-system stress, WASI-style key-value real-world, and a multi-file package set), a five-class negative tier, golden snapshots, round-trip matrix rows, and the lint capability matrix / catalog format registry entries.

1.250.0 — 2026-08-02​

Added​

  • Gateway configuration import (IXH-7.8, #5133) — Two new ImportSource adapters make gateway configs importable: kong (Kong declarative / deck YAML-JSON, single file or split fileset) and gateway-api (Kubernetes Gateway API HTTPRoute manifests, single document, multi-document stream, or manifest directory). Routes normalize to canonical REST operations — hosts, path patterns (regex paths become inferred {param} templates with the original pattern preserved as evidence), methods, header/query matches, and backends. Kong auth plugins map to canonical security where a mapping exists (key-auth → apiKey, jwt → bearer, oauth2, basic-auth, mtls-auth, openid-connect) and are preserved as unmapped hints otherwise; Gateway API filters are preserved verbatim in extras.
  • Schema absence as a capability limit — Gateway configs carry no request/response schemas, so both formats route to the catalog as non-publishable with the reason stated (supply schemas and convert to promote), and the import preview coverage ledger reports the missing schemas as inferred / source_incomplete — a capability limit of the source format, never a drop.
  • Credential hygiene — Kong consumer credentials (key-auth keys, basic-auth passwords, JWT secrets) and secret-shaped plugin config values are redacted at parse time (counts retained, values never imported); kong joins the always-enforced intake secret-scrub formats, and a secrets-kong.yaml adversarial fixture guards the pipeline end to end.
  • Corpus ladder — Full six-rung corpus for both formats (single-service, multi-service, and plugin-heavy Kong configs; single-route, multi-document, and filter-heavy HTTPRoute manifests, plus split-file/manifest-directory filesets), five-class negative tiers, golden snapshots, and round-trip matrix rows.

1.249.0 — 2026-08-02​

Added​

  • OpenAPI Overlay 1.0 pre-processor (IXH-7.7, #5132) — The OpenAPI adapter now resolves a base document plus one or more Overlay Specification 1.0 documents at import time, with per-value provenance (app/openapi_overlay.py).
    • Action semantics: update deep-merges into object targets (nested objects merge recursively; primitives and arrays replace), appends to array targets, and replaces primitive targets in place; remove: true deletes the selected nodes (list indices deleted highest-first so survivors never shift under the removal). Targets are JSONPath, evaluated through the custom-rule DSL's hardened Spectral-compatible parser (parse_jsonpath_expression, now public).
    • Fileset intake: the adapter accepts multi-document filesets (InputKind.FILESET) — members classified by version marker (exactly one openapi/swagger base; every overlay: 1.x member applied in member-path order, each seeing the previous one's result, so a chain's last writer wins); unclassified members (e.g. $ref targets) ride along untouched and are listed as ignored.
    • Per-value provenance: each set/replaced/appended/removed value is recorded (JSON Pointer, kind, contributing overlay, action index, target expression) on the canonical model's extras["overlay"], rendered by the import preview coverage ledger as document-scoped mapped rows — capped at 500 records with a declared-truncation row, never a silent cut.
    • Bare overlay prompt: a lone overlay document is detected (claimed at 0.9, no format pinned) and rejected with new taxonomy code INPUT_OVERLAY_BASE_MISSING, whose remediation prompts for the base document — instead of an obscure parse error. A fileset with overlays but no base gets the same code.
    • Findings, not silence: actions whose target matches nothing, or that are structurally unusable (no target, neither update nor remove, invalid JSONPath, type-mismatched update, root removal), surface as new registered warning rules intake.overlay-unmatched-target / intake.overlay-action-invalid merged into the import lint report (tenant-governable like any registered rule).
    • Corpus ladder: openapi/34-overlay-basic-set/ (add + update + remove in one overlay), openapi/35-overlay-chain-set/ (two-overlay chain with a last-writer override), and negative openapi/negative/06-bare-overlay.yaml (INPUT_OVERLAY_BASE_MISSING), with canonical goldens; the openapi multi-file rung waiver is retired.

1.248.0 — 2026-08-02​

Added​

  • GraphQL Federation supergraph and subgraph import (IXH-7.6, #5131) — The GraphQL adapter is now composition-aware: a supergraph SDL and a multi-file subgraph set both import with per-type / per-field subgraph ownership carried through the canonical model (app/graphql_federation.py, docs in docs/graphql_federation.md).
    • Ownership: supergraph ownership is read off the Apollo join-spec directives (@join__type / @join__field, external: true references excluded); a subgraph set derives ownership from file boundaries (@external stubs excluded). Recorded as extras["federation"] on the artifact and extras["subgraphs"] on every owned type/field/service/operation, so ownership participates in the fingerprint.
    • Subgraph SDL builds bare: real-world subgraph files apply @key/@shareable/ @link without defining them; the parser injects exactly the missing Federation v2 definitions before validate_sdl (author definitions never overridden).
    • Diff attribution: GraphQlDiffLabeler — the first provider on the MFI-3.x DiffLabeler SPI — labels every change with its owning subgraph(s) (owned by subgraph 'reviews', subgraph ownership: products → reviews).
    • Composition lint dimension: new composition category in the GraphQL rule pack — graphql.composition-invalid-key, graphql.composition-non-shareable-field, graphql.composition-unresolvable-selection (pure checks over the subgraph set), and graphql.composition-error surfacing the bundled rover supergraph compose verdict captured at import time (worker-loop bridge; degrades to "no verdict" when the tool or its composition plugin is unavailable). Every finding names the offending subgraph. Federation spec-machinery names (join__Graph, …) are exempt from the GraphQL naming rules.
    • Directive preservation: applied directives are no longer stripped — print_schema_with_directives restores them on the parser's canonical SDL and the normalizer's raw["sdl"], and the emitter rebuilds custom directive definitions (extras["directive_definitions"]) as real GraphQLDirectives and re-attaches the per-entity extras["directives"] applications onto the printed SDL with validation fallback. A GraphQL→GraphQL supergraph round-trip is canonical-diff clean.
    • Corpus ladder: graphql/13-federation-set/ (products/reviews/inventory, multi-file rung) and graphql/14-federation-supergraph.graphql (composition rung), with canonical goldens.

1.247.0 — 2026-08-02​

Added​

  • Protobuf descriptor set / buf image binary intake (IXH-7.5, #5130) — Real gRPC deployments distribute a serialized FileDescriptorSet (or a buf image), not a .proto source tree; the gRPC adapter now imports that artifact directly.
    • Binary parse seam (ImportSource.accepts_bytes / parse_bytes): the import pipeline consults the adapter before decoding an upload to text and routes claimed binary payloads to parse_bytes under the same IXH-6.5 size/time/memory stage guards (the raw-bytes ceiling applies before the parse ever runs). Text-only adapters are unaffected (default declines).
    • gRPC adapter: parse_bytes decodes a FileDescriptorSet / buf image with the pure MFI-9.1 read layer — dependencies resolve from within the set, with no filesystem, network, or buf toolchain access — and feeds the existing Protobuf normalizer. Payloads are claimed by content sniff (sniff_file_descriptor_set) or by conventional suffix (.binpb/.desc/.protoset), so malformed descriptor uploads fail with descriptor-specific taxonomy codes (INPUT_MALFORMED, or INPUT_TRUNCATED when the wire stream is cut off mid-element via a top-level wire walk) instead of INPUT_ENCODING_INVALID. The Connect-RPC adapter delegates the same seam.
    • Detection: DetectionInput gains optional undecoded data bytes; the gRPC adapter and the registry sniffer claim descriptor-set bytes at 0.9 confidence, so binary uploads auto-detect and pre-flight routes them to the grpc importer.
    • Paired corpus contract: protobuf/07-inventory-source.proto and the descriptor set / buf image compiled from it (08/09-*.binpb) must import to the same canonical model and fingerprint; binary negatives assert the truncated/malformed codes through the real pipeline. The corpus harness reads binary entries as bytes and drives parse_bytes.
    • gRPC server reflection discovery through the SSRF-guarded fetcher already shipped in MFI-9.3 and is unchanged; parse_bytes completes the pairing by importing the same descriptor bytes reflection returns.

1.246.0 — 2026-08-02​

Added​

  • Import/export observability — stage timings, failure reasons, metrics (IXH-6.6, #5125) — Diagnosing "imports are slow for one tenant" meant reading logs; now the two job pipelines emit aggregate metrics and a correlation id that survives from the request to every log line.
    • Metrics (app/import_export_metrics.py, in-process/per-replica like the rest of the /v1/ops/metrics plane): per-stage duration histograms + byte totals, terminal job totals keyed adapter/target × format × outcome, and failure counters keyed by the IXH-6.4 taxonomy code. Every tag comes from a closed vocabulary (registered adapters/targets, the engine stage names, the two taxonomies) with out-of-vocabulary values clamped to other — no per-tenant or per-job tags exist. Each record also emits one structured log line (import_export.stage|job|failure).
    • Stage timings: the in-process import pipeline now emits PHASE_TIMING events (the exact shape the tsx worker always emitted, including a failed outcome for interrupted stages), ingested into the metrics at the engine's event-dedupe seam so both paths feed the same aggregates; the export engine times its five stages at the _publish funnel. Timing events persist per job inside async_job.status, so durable evidence survives restarts even though the aggregates are per-replica.
    • Correlation id (additive correlation_id fields): captured from the middleware's X-Request-ID at schedule time, stamped onto every stored import/export job status and — structurally — onto SpecImportJobError/ExportJobError, bound into every job log line (explicitly on the export engine's thread loop), and therefore returned to the caller on failure: the 202's X-Request-ID equals every subsequent poll's correlation_id.
    • Operator view: new GET /v1/ops/import-export (platform-admin) rendering the aggregates plus the complete documented tag set; /v1/ops/metrics/status carry the snapshot under import_export; the ops dashboard gains Import/Export jobs and Import/Export failures cards. Documented in docs/import_export_observability.md.

1.245.0 — 2026-08-02​

Added​

  • Saved schema test suites and regression tracking (IXH-5.7, #5119) — A payload validated once is worth keeping. /v1/tenants/{tenant}/schema-suites persists named, tenant-scoped suites — payloads plus expected verdicts in the IXH-1.1 validity_class vocabulary — attached to a stable schema reference that survives revisions ({kind}/{artifact}[/{type}]; a 5.1-shaped reference's version segment is discarded; registry/… is rejected because it has no revisions to regress across).
    • Runs (POST …/{id}/runs) execute every payload through the IXH-5.1 validator against one revision — resolved once and pinned, so a moving latest cannot split a run — judge each verdict exactly like the CLI (passed/failed/error), and record the run plus per-payload results (apiome-db V240). An unresolvable reference records a status: error run: that history is the product, not an exception.
    • Regression tracking: each result is diffed by payload name against the suite's previous completed run, whatever revision it targeted. passed → failed flags the result and the run; passed → error deliberately does not (no verdict was produced), staying visible via previous_status. Listings carry each suite's newest run summary so the catalog and version detail surfaces can badge regressions from one query.
    • Corpus round trip: GET …/{id}/export produces an IXH-1.1 corpus manifest plus payload files, directly consumable by apiome schema test --suite once materialized; POST …/schema-suites/import reads the same envelope back losslessly.
    • Bounded and documented (docs/schema_test_suites.md): payloads per suite (APIOME_SCHEMA_SUITE_MAX_PAYLOADS, 50), 256 KiB per payload (V240 CHECK), findings per result (APIOME_SCHEMA_SUITE_RESULT_FINDINGS_CAP, 20); run history pruned on write beyond APIOME_SCHEMA_SUITE_RUN_MAX_PER_SUITE (200) and by age on the IXH-6.3 retention tick (APIOME_SCHEMA_SUITE_RUN_RETENTION_DAYS, 180) — always keeping each suite's newest APIOME_SCHEMA_SUITE_RUN_KEEP_MIN (20) so a rarely-run suite never loses its regression baseline.

1.244.0 — 2026-08-02​

Added​

  • Cross-format schema conformance, canonical → target (IXH-5.6, #5118) — Fidelity reporting describes structural loss; it never answered the question that actually breaks a consumer at runtime: does a payload that is valid against the source schema remain valid against the emitted target schema? app/cross_format_conformance.py answers it empirically, per emit target and per entity.
    • For every target with a validatable schema language — JSON Schema (validate_json_instance), Avro (fastavro), protobuf (buf compile + json_format.ParseDict), GraphQL input types (graphql-core input coercion), and XSD (xmllint) — the IXH-5.2 source-valid instances (minimal, full, branch; never mutants) are validated against the actually emitted schema. Failures are reported per entity with the target-side constraint that rejected the instance.
    • Wire-format transcoding is explicit (app/conformance_transcoding.py): base64 → Avro binary, canonical-model-driven JSON → XML documents mirroring the emitted XSD grammar, and the proto3 canonical JSON mapping. Transcode failures are a separate failure kind — they never masquerade as a pass or a conformance verdict.
    • Targets without a validatable schema language are reported not applicable, never passing; a missing toolchain (buf, xmllint) reports not validated with the reason, mirroring the export_validation honesty contract.
    • Feeds the IXH-2.4 readiness rank: POST …/export/preflight accepts include_conformance, attaches each target's verdict beside its structural fidelity envelope, demotes a ready target to caution when its emitted schema rejected source-valid instances, re-ranks, and refreshes the ranking fingerprint.
    • Covered across the IXH-1.7 grid: every corpus source-format representative × every production emit target asserts the applicability split and that no target ever reads as passing without instances actually judged.

1.243.0 — 2026-08-02​

Added​

  • On-demand export round-trip comparison (IXH-4.4, #5112) — The strongest possible answer to "is this export honest?" is empirical: emit the artifact, re-import it through the matching import adapter, and diff the re-imported canonical model against the source. The IXH-1.7 conformance matrix proves this in CI over the corpus; the user had no way to see it for their own document.
    • POST /v1/export/{tenant}/roundtrip runs the same loop, on demand, for one (source revision, target, options): a read-only emit via the dispatch primitive (so the verdict and the Studio's fidelity surfaces describe one snapshot), re-import through the matrix's own adapter join, canonical_diff against the source, and the matrix's reconcile against the fidelity report. Nothing is persisted — no artifact, no job row, no field-identity rows.
    • Differences come back grouped: matched (each explained difference paired with the fidelity finding covering it — expected loss), unexplained, and overclaims (ok findings reality contradicts) — the latter two flagging a fidelity bug worth reporting, with reproduction provenance (model fingerprints + emitter/apiome/registry versions, never source content) inline.
    • A target with no import adapter is skipped with the matrix's own explanation (status: unsupported), never silently; a re-import failure is reported as a fail verdict rather than a 500. app/export_roundtrip.py holds the composition; the verdict vocabulary is the 1.7 matrix's, so Studio results reconcile with the published grid for corpus entries.

1.242.0 — 2026-08-02​

Added​

  • Multi-file and archive intake explorer (IXH-3.5, #5107) — MFI-29.1/29.2 made a single import dozens of files, but the preview still showed one grade and one entity tree for all of them: which file failed, which was never read, which import could not be resolved, and whether the detected entry point was even right were all unanswerable. That is the failure mode that makes multi-file gRPC imports frustrating.
    • POST /v1/tenants/{tenant}/import/bundle-inventory unpacks the candidate through the same MFI-29.1 archive intake the commit uses and runs the same IXH-2.1 pre-flight the quality step already ran (so it rides that cached run rather than parsing the bundle twice), then returns per file: its role (entry-point / dependency / unreferenced / ignored — always with the reason — / unreadable), its verdict plus the parse diagnostic naming it, its resolved import/include edges and incoming references, and the canonical entities it appears to contribute.
    • Every unresolved reference lists the search paths that were tried, in order. Imports the format's own toolchain supplies (protobuf well-known types, Cap'n Proto builtins) resolve as provided instead of being reported missing.
    • app/intake_bundle_graph.py holds the pure half: a per-suffix directive table (proto, Thrift, FlatBuffers, Cap'n Proto, TypeSpec, GraphQL, RAML, Avro IDL, JSON/YAML $ref, XSD/WSDL/EDMX), include-root resolution, role classification, and the declaration-scan attribution — whose method is carried on the response (attribution) so its evidence quality is never overstated as parser provenance.
    • Ranked entry-point candidates come from the same ranking resolve_fileset_root decides with; overriding is a plain re-run against archive_root. An ambiguous root and a failed parse both still return the complete file list.
    • Archive unpack can now report what it skipped and why (unpack_archive_members(ignored=…)), and its skip normalisation is fixed: lstrip("./") stripped characters, so .git/ internals and a top-level .DS_Store were never actually being skipped despite both rules existing.
    • Bounded: files are cursor-paginated, unresolved references ride the first page with the full total stated, and a per-process LRU keeps the built inventory so paging never re-unpacks.
    • UI: a Bundle files tab in the import wizard's quality step (mounted only for a bundle candidate) with a windowed ARIA file tree, role legend, per-file detail, unresolved-imports list, and an entry-point picker that re-runs the whole pre-flight.

1.241.0 — 2026-08-02​

Added​

  • Quality-rank telemetry and grade drift over revisions (IXH-2.7, #5102) — scores were captured per revision but never aggregated across intake, so nobody could see that a team's imports were trending downward, or that one format consistently graded low — which is as likely to be an adapter gap as a spec problem, and a per-revision score cannot tell the two apart.
    • An append-only observation series. apiome.quality_rank_observations (V239) records one row every time a grade is produced — an import pre-flight, a committed import, an export pre-flight ranking, a delivery gate decision — keyed by tenant, format, adapter and style-guide version (the guide's content fingerprint, which is what actually moves a score), plus the policy version, the gate outcome, and the severity tally.
    • Attribution, not just grades. Every observation carries the finding split that separates what apiome's intake is answerable for from what the specification is: intake.* findings (an external $ref never resolved or refused) are adapter-attributable, everything else is spec-attributable and classed by the rule id's namespace. An unrecognised rule is spec-attributable by construction — the opposite default would blame the adapter for every new rule anybody adds. The adapter's declared parser limits (import_preview_manifest.KNOWN_PARSER_LIMITS) ride alongside as a separate count and are never folded into the finding tallies.
    • Export readiness in the same series. An export pre-flight records the readiness composite, band and rank of its top-ranked targets, so a target whose readiness is sliding shows up beside the specs feeding it rather than in a second, parallel view.
    • One read. GET /v1/lint/workspace/quality-ranks groups the window by (scope, format) and returns each group's grade distribution, average score, drift (scoreDelta), outcome tally, attribution split, style-guide versions, and a per-day point series. A day with no observation is a gap (averageScore: null), never a zero. Rendered in the lint workspace as a new Quality ranks tab with a selectable 7/30/90/180-day window.
    • Bounded by construction. Recording is best-effort everywhere (telemetry never fails an import, an export, or a pre-flight); an export pre-flight records only the head of its ranking rather than all 30-odd targets; the read caps its window at 180 days and its format count at 24, stating truncated rather than dropping rows silently; and the series is pruned by APIOME_QUALITY_RANK_RETENTION_DAYS (default 180) on the IXH-6.3 retention sweep tick, which is already the deployment's retention worker.

1.239.0 — 2026-08-02​

Added​

  • Corpus provenance, licensing and contributor guide (IXH-1.9, #5095) — real-world examples are the corpus's most valuable tier and the easiest to add carelessly: a third-party spec carries a license the repository must honor, and a payload captured from a running system carries personal data that must never reach git history. Neither was tracked.
    • Declared origin. Manifest entries gained origin (hand-authored | derived | captured, absent means hand-authored), source_url (the upstream document a derived entry came from) and anonymization (how a captured payload was scrubbed before commit). Both loaders expose them — corpus_loader.CorpusEntry.effective_origin plus a load_corpus(origin=…) filter, and the same in apiome-ui/lib/corpus/corpus.ts.
    • An enforced gate. scripts/check_corpus_provenance.py is a stdlib-only CI check: every entry with a non-empty source must declare a license, that license must be on a reviewed SPDX allowlist (copyleft, share-alike and non-commercial terms fail), origin and source must agree, derived entries must link their upstream, and captured entries must carry an anonymization statement under a license the contributor can actually grant. It runs as its own lightweight workflow (.github/workflows/corpus-provenance.yml) so a manifest-only pull request is gated even though the corpus lives under apiome-ui/.
    • The guide. docs/CORPUS_CONTRIBUTOR_GUIDE.md documents the tiers and the six-rung ladder, every manifest field, the licensing rules (including what to do when the upstream license is not acceptable — reconstruct, do not vendor), the anonymization rule for captured payloads, the end-to-end add-an-example workflow, and the reviewer checklist. The generated examples README links it and now publishes the corpus's licensing bill of materials by origin.
    • Tests. tests/test_corpus_provenance.py fires every rule against a purpose-built bad entry, asserts the committed corpus is clean, and pins the guide/README linkage.

1.238.0 — 2026-08-02​

Added​

  • Scale corpus and import/export performance budgets (IXH-1.5, #5091) — nothing in the corpus approached the size of the specs teams actually hold, so import and export timings were unmeasured and a regression in normalization or fidelity analysis would have shipped unnoticed. There is now a measured, gated scale tier.
    • The corpus. scripts/generate_scale_corpus.py is a committed spec of six large, deterministic, valid documents — one per paradigm and one for the mainframe half: a 550-path OpenAPI 3.1 spec, a 1500-method OpenRPC service, a CloudEvents envelope with a 15000-attribute payload, a 900-type Avro snapshot, a 1500-transaction-set X12 interchange, and a 7500-item COBOL copybook. The bytes are built at test time, so repository size stays flat (the IXH-1.4 rule). Every fixture stays under the 10 MiB intake ceiling and uses an adapter with no external toolchain, so none of them can silently skip.
    • Ten measured stages. tests/scale_benchmark.py drives each fixture through parse → normalize → fingerprint → lint → persist and load-source → analyze-fidelity → emit → validate → package, calling the same functions the running pipelines call, and records per-stage wall-clock plus peak allocation (tracemalloc, which is attributable per stage) alongside the process peak RSS. The two database-straddling stages are measured up to the row write — persist is the source capture and secret scrub, load-source the full re-parse/re-normalize — because a Postgres round-trip's cost cannot be attributed to a code change.
    • Budgets with a margin, not a threshold. tests/scale/scale_budgets.json is the one committed baseline, carrying the margins, the noise floors, and the machine it was measured on. A stage fails only when it is both over baseline × margin and over an absolute floor, so a 2 ms stage tripling is ignored while a 30 % slowdown of a two-second stage fails. SCALE_REGRESSION_MARGIN / SCALE_MEMORY_MARGIN override per run, and absolute ceilings (180 s, 1 GiB per stage) fail regardless of any baseline. Refresh with pytest tests/test_scale_corpus.py --update-scale-budgets.
    • Opt-in locally, scheduled in CI. tests/test_scale_corpus.py runs only under --scale / RUN_SCALE_SUITE=1; .github/workflows/apiome-rest-scale.yml runs it weekly and on dispatch, uploading reports/scale-benchmark.json — a machine-readable per-stage report with each budget, ratio, and the environment measured in. tests/test_scale_harness.py runs on every PR and keeps the spec, the baseline, and the comparison rules honest between scheduled runs.
    • First findings, for IXH-6.5. Fidelity analysis is the memory hot spot at roughly 300 KiB per canonical type (~265 MiB for the 900-type Avro snapshot); OpenAPI export validation dominates wall-clock at ~15 s for a 1.5 MiB spec; exporting a revision costs about what importing it did, because the canonical model is rebuilt from the captured source. See docs/scale_benchmarks.md.

1.237.0 — 2026-08-01​

Added​

  • Breaking-publish guardrail (CTG-3.4, #4478) — CTG-3.1 made breaking changes visible after publish, but nothing stopped a publisher from shipping one as a minor or patch bump — the semver violation that destroys consumer trust, committed with the platform already knowing the change was breaking. Publish now says so first.
    • The check. At publish time the head is classified against the previous published revision (the CTG-1.1 taxonomy through the CTG-1.3 changelog builder, so the guardrail lists exactly what the published changelog will list) and the two version labels are compared for a semver major bump. Breaking and no major bump is the only combination that triggers. The baseline is resolved independently of the request's change-report baseline mode, so selecting initial cannot dodge the guardrail, and the check runs after the allowBreaking gate — a publisher who opted into shipping breaking changes is exactly the one it exists for.
    • Tenant policy, on the guide. style_guides.breaking_publish_policy (migration V237) is off / warn (default) / block, resolved through the GOV-1.4 chain (project → tenant → default) and editable at PUT …/style-guides/{tenantSlug}/{guideId}/policy beside the CLX-1.3 gates. Under block, publish is refused with 422 carrying the full assessment; force-publish (skipPublishChecks + reason) gets past it exactly as it does for style-guide errors, per GOV-2.5. The level is frozen into each GOV-1.6 guide revision, so escalating to block is auditable history.
    • Preflight for the dialog. GET /v1/versions/{tenantSlug}/{projectId}/{versionRecordId}/breaking-publish-guardrail returns the same payload read-only: status, the breaking changes (capped at 50, with truncated and a true breakingCount), majorBumped, and the recommendedVersion a compliant bump would use.
    • Never fails closed. Version labels are free-form, so "was the major bumped?" has three answers — a non-semver label yields null, which warns but never blocks, since that tenant has not committed a semver violation. Every fault (unbuildable spec, missing baseline, DB error, unknown policy value) degrades to status: unavailable or the warn default: a guardrail that failed closed on its own bugs would be worse than the violation it guards against.
    • Audited. Every flagged publish appends a version.breaking_publish_guardrail workflow audit row with action: warned | forced, the force reason, and the full assessment. The forced case is assessed after publish, since skipPublishChecks skips the prechecks wholesale — precisely the case where the trail matters most.
    • Documented in docs/breaking_publish_guardrail.md; 50 tests in test_breaking_publish_guardrail.py plus guide-policy and revision-snapshot coverage.

1.236.0 — 2026-08-01​

Added​

  • Guide versioning & audit (GOV-1.6, #4432) — style guides were edited in place, so a lint score recorded last month named the guide that produced it but not what that guide contained: once the guide changed, the result could no longer be explained or defended. Guides now keep an immutable history, lint results pin the revision they ran against, and governance changes land in the tenant's audit ledger.
    • A revision per edit, and only per edit. apiome.style_guide_revisions (migration V236) is append-only and write-once (the shared V128 UPDATE-forbid trigger). Creating a guide, renaming it, saving the rule catalog, saving the custom-rules YAML, or changing policy gates each append one row carrying the guide's whole state — name, description, external lint profile, every rule row (enable flag, severity, custom definition) and the draft gates — plus a changeKind and the actor. A save that changed nothing appends nothing: the two fingerprints on each revision separate "the rules moved" (contentFingerprint) from "anything moved" (snapshotFingerprint). Assigning a guide changes no content and is therefore an audit event, not a revision.
    • Lint results pin their ruleset. GET …/{versionRecordId}/lint now returns guideRevisionId alongside guideId / guideName, and every immutable lint evidence row (lint_evidence_runs.guide_revision_id) records the same pin at capture time, so import-time scores are as explainable as live recomputes. The pin is exact rather than heuristic: a revision's contentFingerprint is produced by the same function that stamps the compiled guide's fingerprint, so matching content is provably the same ruleset.
    • History is readable, and self-heals. GET /v1/style-guides/{tenantSlug}/{guideId}/revisions lists the history newest-first with rule rollups; GET …/revisions/{revisionId} returns the frozen rules and gates behind any past score. Both are readable by any tenant member — compliance review is not an admin-only activity. Guides that predate this feature are captured on first read/edit/lint, and every edit path captures the pre-edit state first, so what an edit replaced is preserved rather than lost.
    • Audit events on create / edit / assign. style_guide.created, .updated, .deleted, .rules_updated, .custom_rules_updated, .policy_updated, .assigned and .unassigned append to the existing hash-chained apiome.access_audit ledger — one ledger for a reviewer to read — filterable with GET /v1/access/{tenantSlug}/audit?filter=styleGuide. Only changes that actually happened are recorded, and history/audit capture is best-effort by contract: a ledger or capture failure can never fail the guide change it describes.
    • Documented in docs/guide/style-guide-revisions.md; 48 tests across test_style_guide_revisions.py and test_style_guide_routes.py.

1.235.0 — 2026-08-01​

Added​

  • Spectral ruleset importer (GOV-1.5, #4431) — teams migrating from Stoplight/Redocly arrive with a .spectral.yaml holding years of org standards. Re-authoring every rule by hand was a real switching cost, so POST /v1/lint/custom-rules/import now reads that file — pasted/uploaded via content, or fetched from a url through the existing SSRF-guarded ingestion boundary (256 KiB cap, redirects re-validated) — and translates it into Apiome governance state. Nothing is persisted: the response is a review-then-store payload whose yaml is exactly what PUT /v1/style-guides/{tenantSlug}/{guideId}/custom-rules accepts and whose builtinRules are exactly what PUT …/rules accepts.
    • Three outcomes, no silent loss. Every rules.<id> entry of the source lands in exactly one outcome, so the report accounts for the whole document: builtin (resolved onto the GOV-1.2 rule catalog via the extends: spectral:oas map — info-description, operation-description, and the four valid-*-example rules), custom (translated into the GOV-1.3 DSL and validated by that module, so anything emitted is guaranteed storable and evaluable), or unsupported.
    • Unsupported rules say why. Ten stable reason codes — js_function, unsupported_function (Spectral's schema / alphabetical / xor / falsy / unreferencedReusableObject), unsupported_extends, unmapped_builtin, unknown_rule, unsupported_severity, invalid_definition, malformed_rule, unknown_alias, rule_limit — each with a human detail and, for DSL rejections, the pointer to the offending node (rules.my-rule.then.functionOptions.separator).
    • Lossy translations are declared, not hidden. A rule that imports while losing something carries notes: a dropped message template or formats restriction, resolved: false, severity: hint folded to info, a normalized rule id (an id that would shadow a built-in becomes imported.<id>). Rules the source turned off (off / false / recommended: false) are reported with enabled: false and deliberately left out of the emitted YAML, so applying an import never silently switches a rule on. Document-level notes cover ignored overrides, parserOptions, and unknown top-level keys.
    • Spectral dialect handling. Severity tokens (error/warn/info/hint/off, booleans, numeric DiagnosticSeverity including YAML 1.1 resolving bare off to false), extends as a scalar/list/[target, modifier] pair (off inherits everything disabled), and simple aliases (including #Alias.suffix expansion) all resolve.
    • Acceptance criterion pinned by fixture. tests/fixtures/spectral/zalando-style.spectral.yaml — a 27-rule Zalando-style ruleset — imports at 81.5% coverage, above the ≥70% bar, and its output round-trips through POST /v1/lint/custom-rules/validate. Documented in docs/guide/spectral-import.md; 84 tests across test_spectral_import.py and test_spectral_import_routes.py.

1.234.0 — 2026-08-01​

Added​

  • Protocol / format facets on public browse (MFI-6.1, #3753) — the directory now spans many API description formats, so browsing it needs more than a name search: a visitor has to be able to ask for "the event-driven ones" or "everything published as gRPC". Both facet axes read the columns MFI-7.1 put on apiome.versions (protocol, source_format), backed by that migration's partial facet indexes.
    • Two filters, one vocabulary. GET /v1/browse/tenants and GET /v1/browse/tenants/{slug}/projects accept protocol (the canonical ApiParadigm: rest, rpc, event, graph, data_schema, agent) and format (the specific source format key an adapter recorded at import: openapi-3.1, protobuf, graphql, …). Matching is case- and punctuation-insensitive — data-schema, event-driven and graphql all resolve — and an unrecognised value narrows to nothing rather than erroring, the same contract the existing search/domain filters have. The two axes compose with AND, and an entry matches when any of its listed versions carries the value.
    • Counts per facet. Both responses gained a facets block — {protocols, formats}, each a list of {value, label, count}. Counts honour the listing's other filters (search, domain) but deliberately ignore the facet selection itself, so a chip row always answers "what else could I pick" instead of collapsing to what is already selected. Protocols come back in canonical paradigm order; formats by descending count, ties broken by key.
    • Rows say what they are. Every tenant and project row now carries protocols / formats (the distinct values across its listed versions) and every version row carries its own protocol / source_format, so a listing stays readable once a facet has narrowed it.
    • Labels reuse the registries. app/browse_facets.py owns the normalization and the labelling; format labels come from the import-source registry (so a newly registered adapter labels its own chips) with a versioned-key rule that keeps openapi-3.0, openapi-3.1 and swagger-2.0 distinguishable. An unknown key still renders — as itself.
    • Note. Revisions imported before MFI-7.1 carry no protocol/format and so contribute no chip; the MFI-7.3 backfill (#3758) is what lights the facets up for pre-existing specs.

1.233.0 — 2026-08-01​

Added​

  • Per-repository / per-file refresh conflict policy (RAR-4.5, #3531) — the RAR-4.4 divergence guard gave auto-refresh one answer when it meets a version that was hand-edited after the original import: hold, do not clobber. That is the right default and it is only one of the three answers teams want. This makes the answer configurable, at the two scopes the work actually needs.
    • Three policies, stored as their wire tokens. overwrite lets the refresh supersede the hand edit — the divergence is still detected and reported, it just does not stop the refresh; hold-for-review (the default) skips the refresh and flags the file diverged; new-branch leaves the current version untouched and lands the refresh on a side branch so neither the edit nor the upstream change is lost.
    • The default does not move. tenant_repositories.refresh_conflict_policy is NOT NULL DEFAULT 'hold-for-review', so every existing repository keeps the behaviour it has today. Opting into a policy that can lose work not held in the repository is an explicit act, never a migration side-effect — and every degradation path (an unrecognised token, a missing row, a blank value) falls back toward that same safe default rather than failing a refresh.
    • Per-file overrides are exceptions, not enrolments. apiome.repository_conflict_policy_override holds one row per file that deviates, keyed on the same (repository_id, branch, path) lineage tuple as RAR-1.1's repository_import_spec. A file with no row inherits its repository's policy, so the table stays tiny and clearing an override is a delete — the file then follows whatever the repository says next, not a frozen copy of today's setting. The table is separate from tenant_repository_files deliberately: that one is rewritten by every scan, and policy must outlive the scan index.
    • One decision site. app/repository_conflict_policy.py resolves per-file → repository → default, runs the RAR-4.4 guard under the resolved policy, and returns a ConflictOutcome carrying the action (apply / hold / new-branch), whether a manual edit was detected, the reason code, the policy and where it came from. Branch names for the new-branch policy are deterministic (apiome-refresh/<branch>/<file stem>-<short sha>), so a refresh that runs twice for one commit targets one branch rather than accumulating near-duplicates. Like RAR-4.1–4.4 the module is pure and DB-free; acting on the outcome remains the EPIC-4 dispatcher's job.
    • API. GET/PUT /v1/tenants/{slug}/repositories/{id}/conflict-policy reads and sets the repository policy; PUT …/conflict-policy/file sets an override, or clears it with "policy": null. Both the read and every mutation return the same projection — policy, default, accepted tokens and overrides — so a settings panel cannot drift from stored state. An unrecognised token is a 400 that lists what is accepted, not a 500 from the column's CHECK. The repository policy is also patchable through the existing dashboard PATCH /v1/tenants/{slug}/repositories/{id} as refreshConflictPolicy.
    • See docs/repository_conflict_policy.md.

1.232.0 — 2026-08-01​

Added​

  • Source-IP allowlist for webhook ingestion (REPO-7.6, #2804) — POST /v1/repositories/webhook/{provider} is the one repository route with no bearer token, so the HMAC signature is its only authentication. That check is sound and it is reached by anyone who can open a socket: every unsigned POST on the internet buys a subscription lookup, a constant-time comparison against a real secret, and a ledger row. The allowlist filters on the source address before any of that runs.
    • A blocked delivery is a 403 and is never verified. The guard runs in the route, ahead of ingest_webhook_delivery, so a refused source never reaches verify_signature at all — the ticket's defense-in-depth requirement, and the reason this is not a branch inside the dispatcher. The response body says only that the source was refused; naming the allowlist, the tenant or the matched range would turn the endpoint into a probe for the deployment's network policy.
    • Provider ranges are fetched daily, not hard-coded. GitHub's hooks array from api.github.com/meta and Bitbucket's entries from ip-ranges.atlassian.com are refreshed on a daily cadence into apiome.webhook_provider_ip_range, shared across replicas so every process filters on the same list. Due-ness is measured from the last success, so a provider whose endpoint is failing is retried on the next hourly tick rather than tomorrow. GitLab.com publishes no machine-readable list; its ranges come from APIOME_REPOSITORY_WEBHOOK_IP_RANGES_GITLAB, and the same setting exists for the other two providers for self-hosted instances.
    • Per-tenant additional ranges, scoped to the tenant that owns the repository. A self-hosted runner or an egress gateway is added per workspace and consulted only for the tenants that registered the repository the payload names — resolved by parsing, which reaches no secret. A union across tenants would let one workspace widen the filter protecting all the others.
    • The bypass is an administrator's act, with a reason. enforcement_enabled = false on apiome.tenant_webhook_ip_policy turns the filter off for one tenant's repositories; setting it, and adding a range, both require a signed-in tenant administrator (API keys are refused) and both write to apiome.workflow_audit. Disabling without a stated reason is a 400.
    • Failure modes are chosen. Enforcement is off by default (APIOME_REPOSITORY_WEBHOOK_IP_ALLOWLIST), so an upgrade changes nothing. A provider with no cached ranges allows and logs rather than rejecting everything; ..._IP_ALLOWLIST_STRICT flips that to fail-closed. An empty provider fetch is treated as a failure and leaves the previous cache standing. An unidentifiable client address blocks — unless the owning tenant has bypassed enforcement, which is exactly the escape hatch that case calls for.
    • X-Forwarded-For is worth what the deployment says it is. APIOME_REPOSITORY_WEBHOOK_TRUSTED_PROXY_HOPS (default 0) decides how many hops in to read; at 0 the header is ignored entirely, since honouring it unverified would let any caller name its own source address. A header shorter than the configured chain is refused rather than guessed at.
    • GET|POST|PATCH|DELETE|PUT /v1/tenants/{slug}/repository-webhook-ip-allowlist[...] back the admin panel. The read needs only import-view permission — seeing the filter is how anyone diagnoses "our webhooks stopped" — while every mutation needs the admin role. Every mutation answers with the whole allowlist, so a panel can never drift from what was stored.
    • Blocked deliveries land in the existing apiome.repository_webhook_event ledger with the rejected outcome and an ip-not-allowed reason, and are audited per owning tenant as repository.webhook.ip_blocked — which carries the repository. prefix, so it appears in the REPO-7.5 compliance export with no further wiring.
    • V234 adds the four tables (provider range cache, per-provider refresh state, per-tenant entries, per-tenant policy). Tables and indexes only; no existing data is touched.

1.231.0 — 2026-07-31​

Added​

  • SOC 2 / ISO 27001 audit export (REPO-7.5, #2803) — compliance reviews need a structured, dateable artifact of everything the repository subsystem wrote to the audit ledger, not a paginated API a reviewer has to page through by hand.
    • GET /v1/tenants/{slug}/repository-audit-export?from=&to=&format=csv|json streams every repository.* row of apiome.workflow_audit (refresh cycles, webhook registrations / deliveries / secret rotations, external-ref fetches, …) in the inclusive created_at range, oldest first, served as an attachment with a range-stamped filename (repository-audit-export_20260101-20260731.csv).
    • Admin-only. The ledger names every repository and actor in the workspace, so the export requires a signed-in tenant administrator; API keys are refused outright rather than resolved to their creating user.
    • Streamed at any size. Rows are read oldest-first with a (created_at, id) keyset cursor in 1,000-row batches, so an export far beyond 10k rows holds one batch in memory and every batch costs the same — no OFFSET cliff, and rows appended mid-export cannot shift between batches.
    • CSV or JSON. CSV is a header plus one RFC-4180 row per entry with detail JSON-encoded in its cell; JSON is a single document — an export metadata envelope, the entries array streamed element by element, and a trailing rowCount — that only parses when the download ran to completion, so a truncated artifact is detectably incomplete instead of silently short.
    • The export is itself evidence. Every attempt appends a repository.audit_exported row to the same ledger: success with the exact row count on completion, failure with the partial count when the stream errors or the client disconnects mid-download. Because the action carries the repository. prefix, each export shows up in the next one. Recording is best-effort, so audit bookkeeping can never break the download it describes.
    • Ledger and endpoint only; no migration — apiome.workflow_audit is reused unchanged.

1.230.0 — 2026-07-31​

Added​

  • Quota & rate-limit telemetry (REPO-7.3, #2801) — the REPO-4.6 polling quota and the REPO-2.5 scan budget both work silently. The only record of a deferral was a log line and a per-replica in-memory counter that died with the process, so "is this workspace permanently parked against its ceiling, or was that one bad afternoon?" had no answer anyone could give. There is now a durable per-tenant counter behind it.
    • Five metrics in a rolling-window table (apiome.repository_quota_window, V233): polls, polls_deferred and files_deferred bucket hourly (matching the REPO-4.6 quota window); scans and bytes_scanned bucket daily. Aggregates, not events — a workspace polling 600 times an hour costs one row an hour, not 600.
    • A window boundary is the reset. Nothing zeroes a counter: an increment lands on the bucket its timestamp falls in, so crossing a boundary writes to a different row and the new window starts at zero. That holds across restarts, across replicas, and across a sweep tick that straddles the boundary — none of which a "reset the counter" job would survive.
    • Deferrals are counted apart from work. polls says how much refreshing happened; polls_deferred / files_deferred say how much the quota pushed into a later window. Folding them together would erase the one signal the dashboard exists to show.
    • Increments are a single atomic upsert on (tenant_id, metric, window_start), so two replicas sweeping the same tenant in the same window converge on one row rather than each creating their own and halving every subsequent read.
    • Recording can never fail a caller. Every counter write is best-effort and swallowed: telemetry that can raise would turn an observability problem into a refresh outage. A scan pass that raised records nothing at all — reporting it as scan volume would make a broken repository look like a busy one.
    • GET /v1/tenants/{slug}/repository-quota-telemetry?days=7 returns the trailing series alongside the tenant's current quota position, so a dashboard renders "42 of 600 used this hour" and "here is the last week" from one request. Every metric is present and zero-filled across the whole range, so a workspace that has never been deferred sees a flat line rather than a missing panel. A counter read that fails comes back available: false with zeros — the flag is what stops "we could not read this" being shown as "nothing happened".
    • Counter rows are pruned by the existing async-job retention sweep after APIOME_REPOSITORY_QUOTA_WINDOW_RETENTION_DAYS (default 120, comfortably longer than the 90-day maximum range the API serves). 0 keeps them forever.
    • V233 adds the counter table. Table and indexes only; no existing data is touched.

1.229.0 — 2026-07-31​

Added​

  • Notifications on scan / sync events (REPO-7.2, #2800) — RAR-5.4 gave the auto-refresh loop a voice, but no off switch and no volume control: a repository stuck in a failure loop could page the same on-call every sweep tick, and nobody could ask it to stop. Repository notifications now go through a policy layer before a single channel is resolved.
    • Three operator-facing events, all inside the repository.refresh.* namespace subscribers already route on: auto_paused (scheduled refresh has stopped and must be resumed by hand), breaking_change (a sync produced a version whose change report classifies as breaking), and repeated_failures (the warning shot — failing repeatedly but not paused yet).
    • Per-repository, per-event-type opt-out. apiome.repository_notification_preference holds exceptions, not enrolments: a repository with no rows is subscribed to everything, and only an explicit enabled = FALSE mutes an event, so a partial preference set fails open. A preference read that errors mutes nothing — failing closed would silence a repository during the incident that broke the read.
    • At most one notification per repository per event type per hour. The slot claim is a single conditional upsert on apiome.repository_notification_throttle, so the decision and the timestamp write are the same statement and two sweep workers racing on one repository cannot both win. A losing claim bumps suppressed_count instead, which is how "quiet because nothing happened" is later told apart from "quiet because we muffled 400 of them". A tenant with no channels does not burn its hourly slot.
    • Channels are resolved per tenant from the existing push-webhook subscriptions, with their retry and dead-letter semantics unchanged, and each is shaped for its destination: a Slack incoming webhook receives a Slack text/blocks message (Slack rejects a body without text), every other endpoint receives the structured JSON. One dead channel never takes the rest of the fan-out down with it.
    • Wired into the refresh sweep. An auto-pause transition sends the auto-pause event; an unpaused repository past three consecutive failures sends the repeated-failures warning on every tick, which the hourly throttle makes safe. A newly paused repository sends only the pause — pairing it with a warning about the same failures is just noise.
    • GET/PUT /v1/tenants/{slug}/repositories/{id}/notification-preferences report and set the opt-outs. The read always lists every event type, with a one-sentence description of what muting it would cost and the throttle state for that pair. The write is partial (events it does not mention keep their state) and rejects unknown or repeated event types with a 400 rather than returning 200 for an opt-out that mutes nothing.
    • V232 adds the two policy tables. Indexes and tables only; no existing data is touched.

1.228.0 — 2026-07-31​

Added​

  • Per-repository health badge (REPO-6.5, #2798) — the repository surface exposed plenty of individual signals (scan job outcomes, per-spec quality attempts, the linked account a private repository authenticates with) but nothing that answered an operator's first question at a glance: is this repository fine? Every repository now carries one three-valued badge, on the repositories list rows (REPO-6.1) and the repository detail header (REPO-6.2).
    • Three inputs, three levels. healthy / warnings / error is rolled up from the scan success rate over the last 30 days, the count of discovered specs on the default branch whose REPO-2.8 quality attempt errored or could not parse, and the health of the linked account's access token (REPO-7.4). Below 50% of scans succeeding is an error, below 90% a warnings; parse errors are a warnings until ten of them, at which point the repository is not usable as an import source; a disconnected account, a missing token or an expired one is an error, and a token expiring within seven days is a warnings.
    • Token issues always demote to at least warnings. A repository Apiome can no longer authenticate to never reads as healthy, however spotless its scan history. Every token factor is emitted at warnings-or-worse and the roll-up clamps as well, so the guarantee survives a future factor being added at the wrong level.
    • A tooltip that says what changed. Each contributing factor carries a stable machine code, a level, a one-sentence operator-facing summary and — where an event lies behind it — when it was last observed. primary_factor is the most recently observed factor, which is what the badge tooltip leads with; the full list is ordered most severe first. A standing condition with no event behind it ("this token expires soon") never displaces something that actually happened.
    • No signal is not a problem. A repository registered a minute ago has no scans, no scored files and (for a public clone URL) no token to expire; it reads as healthy rather than manufacturing an alarm out of missing data. Public-URL repositories are read anonymously and so always have perfect token health.
    • One query per page, not one per row. Database.get_repository_health_signals answers the whole batch with two correlated laterals, and V231 indexes both: (repository_id, created_at DESC) INCLUDE (status, finished_at) on the scan queue turns the trailing window into a bounded range scan, and a partial index on the file table holding only rows that actually failed to parse — empty on a healthy monorepo. The migration adds indexes only.
    • Decoration, never a point of failure. The badge is computed by a pure, side-effect-free module that cannot raise; if the signal query itself fails, the affected rows carry no badge and the listing is unaffected. The access token's value is never selected — only whether one exists — so a credential cannot leak through a listing response.
    • GET /v1/tenants/{slug}/repositories and GET /v1/tenants/{slug}/repositories/{id} (and the PATCH / refresh-resume reads that return the same record) gain a health object.

1.227.0 — 2026-07-31​

Added​

  • Cross-repository discovered-specs catalog (REPO-6.4, #2797) — the repository surface could only answer "what specs are in this repo on this branch" (REPO-6.2). An operator running more than a handful of repositories had no way to ask "where does this spec live", short of opening each repository in turn.
    • GET /v1/tenants/{slug}/repository-files. One tenant-wide, server-paginated listing of every discovered spec across every registered repository. Free-text q matches the file path, its detected kind, the repository's full name and the mapped project's name; format, repository_id, project_id and status narrow it further; sort orders by repository, path, format, status or recent activity. Search, filtering, ordering and pagination all evaluate in SQL, so the response carries only the requested page.
    • Derived per-spec status. Each row resolves to exactly one of needs_attention (quality scoring errored, or the last scan left external $refs unresolved), imported, mapped (bound to a project, no import yet) or discovered, in that precedence. The status is projected and filtered by the same SQL expression, so a row can never be listed under a value it cannot be filtered by.
    • Project and version context per row. Each spec carries the project it is mapped to (or was last imported into) and the version its most recent import produced, resolved through a lateral join so a file imported fifty times still contributes one catalog row.
    • Opt-in facets. include_facets=true returns the filter dropdown options — formats, statuses, repositories and projects, each with a catalog-wide count. Facets are computed over the whole catalog rather than the filtered page, so picking one filter never hides the others. The catalog page requests them once on mount.
    • Scoped for signal, not volume. Vendored trees (node_modules, vendor, .git) and dot-directories are always excluded; only each repository's default branch is listed unless all_branches=true; only classified spec types unless importable_only=false.
    • Indexes for the 10k-file bar (V230). A pg_trgm GIN index makes the substring search indexable, and a (repository_id, branch, path, created_at DESC) index answers the latest-import lookup with one backwards scan. The trigram block degrades to a notice where the migration role cannot install contrib extensions — the catalog stays correct, just sequential.

1.222.0 — 2026-07-31​

Added​

  • Arazzo 1.x workflow importer (REPO-3.4, #2773) — Arazzo describes orchestrated multi-step API workflows and is a sibling specification to OpenAPI, but Apiome could only read one (detect → parse → normalize → lint → emit → diff, MFI-30.2). An imported Arazzo document landed as a store-raw catalog item and its orchestration was invisible. It is now a first-class entity.
    • Workflow + WorkflowStep entities (V225). apiome.api_workflows and apiome.api_workflow_steps hang off the version's api_artifacts row like every other canonical child, so a re-import replaces the previous orchestration instead of accumulating duplicates. Each workflows[] entry becomes one workflow row (workflowId, summary, description, inputs, outputs) plus N step rows in source order.
    • operationRef resolution. A step that points at an OpenAPI operation imported in the same scan resolves to that internal path_operation id. "The same scan" is concrete: with git provenance it is every project repository_import_spec links to the same repository and branch; otherwise it is the importing project. Every reference spelling in the wild is handled — operationId, $sourceDescriptions.<name>.<operationId>, an operationRef JSON-pointer (…#/paths/~1pets~1{petId}/get), and Arazzo 1.0.0's operationPath route pointer, which resolves only when the route carries exactly one operation.
    • A miss is not a failure. An unresolved reference keeps its raw string verbatim, leaves the FK NULL, and records a stable resolution_reason (unknown-operation, ambiguous-operation, no-operation-target, …) plus a human-readable warning. A step that calls a sibling workflow is not_applicable rather than "unknown"; a step that cannot be read at all is isolated as parse_error and its siblings still import. Workflow persistence is an enrichment over the catalog item, so a failure there never fails an import whose source bytes are already stored.
    • Verbatim step payloads. parameters, successCriteria, onFailure, outputs and dependsOn are stored exactly as written — Arazzo's runtime-expression grammar ($response.body#/id, $steps.foo.outputs.bar) is never re-parsed, which keeps round-trip honest.
    • Verified against the official bundles. tests/fixtures/arazzo carries the OAI example documents verbatim (pet-coupons, LoginAndRetrievePets, oauth); they round-trip normalize → map → persist → load with identical workflows and steps.

1.218.0 — 2026-07-31​

Added​

  • Quality scoring per discovered spec (REPO-2.8, #2769) — the repository scanner classified a discovered file by filename but said nothing about whether it was any good, so triaging a repository's specs meant opening each candidate by hand. Every classified spec now carries a rough 0–100 quality score.
    • Reuses the existing engines, not new scoring. A discovered spec is scored through its import-source adapter — parse → normalize → lint — which for OpenAPI is the native path/schema linter (schema_lint.lint_openapi_spec: the PATH-QUALITY and SCHEMA-QUALITY rule groups) and for every other format the canonical-model rule packs behind ImportSource.lint. A repository file and an imported revision therefore land on one comparable scale, and a rule added to either engine shows up here for free.
    • Only classified specs. unknown_spec files — no detected_kind, or a generic container kind like json-candidate on a package.json — are never scored, selected by the same importable predicate the Files browser filters on. A classified format with no adapter yet (Prisma, SQL DDL, DBML) is skipped for its own distinct, labelled reason.
    • Persisted on the file row. apiome.tenant_repository_files gains quality_score, quality_grade, quality_status (scored | skipped | error), quality_reason, quality_scored_at and quality_scored_blob_sha (V222). All nullable, so every already-indexed repository reads as "not scored yet" and no re-scan is required.
    • Bounded background pass. Scoring runs in its own sweep (repository_quality_sweep.process_repository_spec_quality_batch), not inside the REPO-2.5 tree walk, so a monorepo scan keeps its current cost. Each tick claims at most APIOME_REPOSITORY_QUALITY_BATCH_SIZE (default 10) due files every APIOME_REPOSITORY_QUALITY_INTERVAL seconds (default 30); set APIOME_REPOSITORY_QUALITY_SCORING=false to disable it entirely.
    • One download per revision. Every attempt stamps the blob sha it read, success or not, so an unscorable file settles instead of being re-fetched each tick, and editing the file makes it due again. Private repositories are read only with their linked-account token, and files above the 900 KB content cap are skipped without being downloaded.
    • Informational only. No scoring path can raise: an unparseable document, a missing toolchain, a provider failure, or an adapter bug is recorded on the row as a stable machine reason. Nothing gates a scan, a refresh, or an import on the score — spec promotion gating remains REPO-5.6's job.
    • GET /v1/tenants/{slug}/repositories/{id}/files returns quality_score, quality_grade, quality_status and quality_reason per row, and the Repository detail Files tab renders them in a new Quality column (REPO-6.2).

1.217.0 — 2026-07-30​

Added​

  • Large-monorepo support: sparse / paged repository walk (REPO-2.5, #2766) — a repository with more than ~25k entries could not be indexed at all: the walker pulled the whole branch tree in one recursive=1 GitHub Trees call, buffered every blob in memory, and turned GitHub's own truncated: true size signal into the hard failure "repository too large for this scan pass". The walk is now bounded and resumable.
    • Streams in chunks. walk_github_tree_in_chunks hands entries to its sink in batches of at most 1000 (repository_scan_budget.MAX_WALK_CHUNK_SIZE, capped regardless of APIOME_REPOSITORY_SCAN_CHUNK_SIZE), written through the new upserting Database.append_tenant_repository_files instead of one whole-branch statement.
    • Provider-side sparse primitive. A truncated recursive response now switches the walk to a breadth-first per-directory descent over the non-recursive Trees API, so only a queue of unvisited directories is ever held in memory.
    • Per-tenant wall-clock budget. tenants.repository_scan_budget_seconds (default 300 = 5 min) bounds one scan pass, clamped at read time into [APIOME_REPOSITORY_SCAN_BUDGET_MIN, APIOME_REPOSITORY_SCAN_BUDGET_MAX]. A pass that spends its budget stores its position and comes back incomplete rather than being killed.
    • Resume via stored cursor. apiome.tenant_repository_scan_cursors holds one cursor per (repository, branch) — the pinned tree SHA, the RAR-2.1 branch-tip anchors, the walk mode and the pending sub-tree queue. A paused pass, or one interrupted by a transient provider failure (network error, 429, 5xx), re-queues its scan job instead of failing it, and the next sweep tick continues from the cursor. The tree SHA is pinned so a resumed pass keeps indexing the same snapshot even if the branch moves.
    • Fair queueing. A resumed job is claimed on COALESCE(requeued_at, created_at), so a monorepo needing twenty passes yields to other repositories between them instead of owning the scan worker for the whole walk.
    • Safeguards: a cursor older than 24h is discarded and the branch rewalked; a walk that indexes nothing new for too many consecutive passes is abandoned with an error (progress resets that counter, so a long walk is never penalized for being long); a transient failure with no stored position still fails the job; and a completed scan re-reads its counts from the persisted rows so a re-emitted chunk cannot inflate them.

1.216.0 — 2026-07-29​

Fixed​

  • Dependents of a registry type (#3477) — opening number from decimal's base chain showed "No types reference this primitive yet" while decimal plainly referenced it. apiome.primitives.refs records only a type's outgoing edges, so nothing ever answered the reverse question; the field was declared on the detail page and never populated. GET /v1/primitives/{tenant}/{id} now returns dependents, the reverse index built by scanning the visible types' edge lists for the viewed type's $id (Database.get_dependent_primitives).
    • Matching is on the stored absolute resolved_target (the form V218 normalized to), so a dependent is found however its relative $ref was written, and an edge still flagged unresolved by a stale resolver run is still listed — the target exists, so the dependency is real.
    • One entry per referencing edge, each labelled with the property carrying it (ref_location_label over the new iter_ref_locations walk): money shows up under decimal as amount, while decimal under number carries no property because the $ref is the whole type. Read scope is unchanged — system-core ∪ the caller's own (#3453) — and per-tenant seeded copies of a core dependent collapse to one row.
    • OpenAPI 1.80.1 → 1.81.0 (PrimitiveSchema.dependents added; existing fields unchanged).

1.215.1 — 2026-07-28​

Added​

  • CPDO user guide and format-detail documentation (#4806, CPDO-4.3) — the user-facing guides docs/guide/catalog-format-details.md (Format details tab: status vocabulary, value-visibility/redaction, analysis bounds, X12 and copybook inspector boundaries, the absence-category table) and docs/guide/convert-to-openapi.md (conversion walkthrough: projection-graph legend, status and reason-code vocabulary with remediations, safe defaults, acknowledgement gating, historical-vs-fresh evidence, CLI/REST surfaces), with authoritative X12 and IBM COBOL references.
    • tests/test_cpdo_docs_guide.py couples the prose to the code registries: every analysis status/reason, value-visibility level, conversion status, projection reason code, and absence-category label must appear in the guides, the required primary references must stay linked, and every external link must be https. The UI-side twin (apiome-ui/tests/cpdo-guide-terminology.test.ts) holds the guides to the exact labels and symbols the UI renders.
    • OpenAPI 1.80.0 → 1.80.1 (no contract shape changes).