Skip to content

Vista Platform Core 1.0.0 — full list of changes

Released 2026-09-21. The release summary — highlights, what breaks, what each edition includes, and how to verify the signatures — is in CHANGELOG.md. This page is the complete, per-change record behind it.

1.0.0 replaced the asset model, so its entry is far longer than a release entry normally is; keeping it here rather than in the changelog is what lets the changelog stay readable.

Trustworthy discovery RC rollout

The staged RC enhancements distinguish established, operator-confirmed and legacy identity from monitoring approval, type and assessment coverage. Weak observations are retained outside Inventory, with source evidence, explicit confirmation/link/ dismiss decisions and separate counts. Reviewed merges select a survivor and sources, check a current server preview and preserve associations and audit history. There is no merge undo or split workflow.

The coupled enrichment release queries configured sources before bounded DNS and TLS/SSH checks through a suitable observing collector. Existing credentials, network exclusions, protocol policy and sensitive-device restrictions still apply. The tenant's Trustworthy discovery settings control prospective activation and pause; existing records remain visible and keep their identities and associations. Once activated, pause retains incoming evidence instead of restoring permissive asset creation. See the operator guide.

Admission-enabled acceptance passed all 310 required checks across the three estates, every reference stage, restart/resume and two distinct certificates on one asset. Canary baseline reconciliation, the required 24-hour/two-cycle canary and live Windows deployment acceptance remain separate operational gates. Historical failed acceptance runs remain preserved.

Added

  • Inventory shows a device heard advertised on another, configured tenant network as a provisional item — visible, unverified, excluded from the asset allowance until a collector on the device's own network corroborates it in place (same asset id, same history). (#1898, #1900, #1901)

  • Tickets can be raised directly from Remediation → Queue, for any subject, without starting from a finding or an alert. Filing a ticket no longer requires the permission to resolve one — a viewer who spots a problem can report it; editing, commenting and deleting stay privileged. (#1911)

  • Ticket categories now follow the finding producer one for one — compliance, cryptography, post-quantum, certificate, vulnerability, lifecycle, inventory, configuration and drift — so the work queue's category filter separates them. Every new ticket also carries a default due date derived from its priority, which is what the overdue and due-soon views and notifications act on. (#1911)
  • Remediation → Progress: opened against resolved over time, average resolution time, per-category backlog and post-quantum readiness. (#1911)
  • Tickets can be edited in place — assignee, category, priority, severity, due date, tags, resolution notes and an external ticket link — and deleted, with the deletion and the ticket's content recorded in the audit trail. (#1911)
  • Three further dashboards under one Dashboard section — Assets, Compliance and post-quantum readiness — alongside the existing Overview, which is unchanged. The post-quantum page surfaces the per-algorithm-family migration worklist and the catalogue's recommended replacement. (#1907)

  • Device agents can report bounded outbound connections on segments without a sensor. Deploy the updated device-agent binary first, then opt in locally with HOST_INVENTORY_CONNECTIONS_ENABLED=true, or set collect_connections: true on a remote host-inventory job. Existing agents and default settings do not begin collecting peers after a platform upgrade. Each snapshot is coalesced and capped at 256 peers; local ephemeral ports do not create new identities, and accepted TCP connections are excluded. Public destinations become external connections attributed to the reporting host. Private destinations follow existing network ownership and approval rules; registering an auto-approving network promotes the existing pending asset on a subsequent collection. Windows peer collection is TCP-only; Linux and macOS also report connected UDP peers when the OS exposes them. Peerless UDP bindings remain unknown-role facts, rather than claimed listening services. The support command --host-inventory-once excludes outbound peers. See Host inventory. (#1876)

  • An endpoint that has gone quiet is now marked stale. asset_endpoints.status has allowed active / stale / closed since the table was written and nothing ever wrote stale, so a filter on it returned nothing. The hygiene pass now moves an endpoint to stale once nothing has observed it for 30 days — the same boundary as the Stale finding — and back to active when something sees it again. closed is untouched: that is an authoritative "the host says this socket is gone", and only a host inventory writes it. The exposure rules stop judging an endpoint once it goes stale — an unobserved socket is not a measured exposure — but they no longer resolve its findings either, and port-profile drift still counts the port as present: "nothing has looked" must not reach a user as "the port closed" or as "the exposure is fixed".

  • Port-profile drift findings name the endpoints that changed. A port_profile_changed finding listed the ports that opened and closed but not which face they appeared on; it now carries each endpoint's address and id, so a host with several addresses says which one moved.

  • Vulnerable to quantum attack findings now name the KEY, not only the service and the certificate. The finding registry has always declared pqc_vulnerable as being about a crypto configuration, a certificate or a key, and nothing produced the third. The key is usually the subject a migration is planned against: a public key is recorded once in the key inventory and shared by every certificate and service presenting it, so one RSA key across forty hosts is one migration rather than forty. The crypto producer now classifies each key in use against the same NIST IR 8547 denylist of Shor-breakable primitives it applies everywhere else — symmetric keys raise nothing, post-quantum keys raise nothing, and a key whose algorithm resolves to no catalogue row is unclassified rather than assumed either way. Only keys actually linked to an asset are judged. Key findings are reachable from every asset using the key — finding:(…), the has_findings facet, the asset page's Findings tab and the risk roll-up all resolve through the one subject walk, which learned the key path in the same change.

  • A sortable "Last checked" column on Inventory → Software, and a "least recently checked" sort. When the lifecycle catalogue last had an answer for a product was already recorded but reached the UI only as a tooltip, so "which of my products has nobody looked at lately?" could not be answered without hovering every row. Products no completed pass has ever recorded an answer for sort FIRST, ahead of long-ago-checked ones: that is the weaker state, not a missing value. GET /software/products gains sort=eol_checked.

  • The command palette's ask mode now renders asset-class and relationship rows too, like its search mode — it rendered asset rows alone, so the same question offered different answers depending on whether it was typed as a search or asked in words.

  • A Class history panel on every asset, recording every class it has held. Core. An asset's class is an input to most of the platform — which findings a producer writes, which approval rules match, which compliance measurements apply — and it lived in a single mutable column. Change it and the previous answer was gone: nothing anywhere could say a printer had ever been a printer, so a finding written before the change was indistinguishable from one written after it, and "was this thing reclassified?" had no answer. A new asset_class_history table records one row per transition, with both ends named, the mechanism that moved it (set at discovery, accepted in Approvals, edited by a person, stated by an import), the person when there was one, and the rule ids or model id behind it. Written at all three places a class can move, inside the same transaction as the change. Read it on Inventory → an asset → History, above the general change log. An empty panel says "no recorded class change", not "never changed" — an asset created before this record exists has no entry, which is a different claim.

  • An interrogated neighbour's class is now argued from what it advertised, not from its MAC prefix alone. Core. A device reported over LLDP or CDP — by a Cisco switch, a Palo Alto firewall or a UniFi controller — reached the classifier as a MAC address and nothing else. An OUI names a manufacturer, and every manufacturer that matters makes switches, access points and desk phones alike, so almost every neighbour came back "Unknown host" while the interrogators were parsing, and then discarding, the device's own statement of what it is. The advertised product, software version and 802.1AB/CDP capability bits now travel with the peer and reach the classification rules. Each is a strict projection, never a copy: the product is the segment before the first comma of a system description and the version is the version token, because a system description is free text an operator can put a secret into. And a segment that names a credential at all — "snmp community …", a password, a key — is dropped whole rather than kept, so the defence does not rest on the operator having typed the product first.

  • A "Changed since baseline" alert. Core. The drift producer has been writing findings — a device class never seen on a segment before, a protocol an asset has not spoken, a changed set of listening ports, a certificate from a new issuer — with nothing on the Alerts page ever mentioning them. There is now a drift_detected alert type, on by default and switchable under Settings → Notifications & Alerts → Alert Rules, raising one alert per subject and grading it by the worst open drift finding. Its severity is the finding's own: drift has no second scale (no CVSS, no deadline) to grade on, so inventing one would be a second opinion about a judgement already made. It auto-resolves when the findings stop being detected — which includes somebody resolving one because the change was planned.

  • The subject of an alert is a link. Core. Remediation → Alerts rendered the Subject column as plain text, so the page that exists to be triaged told you which asset and then left you to go and find it. Asset, software install, crypto configuration, control and certificate subjects now drill straight through. Subject types with no id-addressable destination yet (sensor, agent, discovery job, framework, and the platform-track subjects) stay plain text deliberately: a link that lands on a page which cannot resolve the subject reads as "nothing wrong here". The install and configuration links name the findings lens explicitly, because the Findings page's crypto-risk lenses cannot honour a subject filter — landing on one would have shown an unfiltered list under a banner claiming it was filtered.

  • The End of life column on the Software tab and the Software lens now gives a supported package its credit. Core. It used to read "Not assessed" for every install without an end-of-life finding, because nothing on the install said whether the lifecycle catalogue had resolved it to a supported cycle, had it with no published date, or had never heard of it — so a supported package and an unknown one looked identical. The eol producer now records, per install and in the same transaction as its findings, what the catalogue resolved, and the column reads that record: Supported until 2028-04-01, No date published, Not in catalogue, or Not assessed — the last now meaning only that no completed check has recorded an answer (the check has not run since the install was seen, the install is no longer present, or its finding was closed). Hover a cell for the date the catalogue was last checked. Nothing re-runs the resolution in a list query; a closed finding does not turn into "Supported". GET …/software and GET /software/products carry the widened eol_state and a new eol_assessed_at.

  • The auto-accept threshold now covers every way a sighting reaches us (asset-inventory workstream 4.6a, ADR-0002 D3). Core.

Setting a matcher score at or above which the platform may merge two of your assets without asking used to govern inventory intakes only. Device interrogation, the cloud collectors, and the neighbours an interrogated device reports went through a different identification engine that did not read the setting — so those sightings were never auto-accepted, whatever you had set. That failed safe, and it was disclosed rather than hidden, but the same host reaches the platform both ways: which collector happened to see it decided whether your threshold applied.

It applies to all of them now. The threshold is read per sighting, on the transaction that resolves it, so turning it off takes effect on the very next observation. Nothing else about it changed: the same four conditions still have to hold — you set a threshold above zero, the score is at or above it, no serial / cloud id / agent id / CMDB sys_id disagrees, and every asset involved is already in service, not still waiting in Approvals — and every auto-accepted merge still writes an audit event and appears on Discovery → Approvals under "Auto-merged by the matcher (last 30 days)", whichever collector's sighting caused it.

The scope sentence on Settings → Policies → Identification rules and in the Asset approval guide has been corrected to match.

  • Interrogated neighbours can now be classified through Approvals (asset-inventory workstreams 4.6a, closing a 2.10b/4.2 limit). Core.

When a switch or firewall reports its neighbours, the platform runs your classification rules over what it learned about each one. Where a neighbour was a host the inventory already held, the answer was computed and then thrown away: a device your rules clearly identify stayed Unknown host for ever, and nothing said an answer had been discarded.

Those answers now appear as class proposals on Discovery → Approvals, exactly as they do for a host a sensor discovers — with the rule they came from cited, and the class the asset holds today shown beside the proposed one. A class is still never overwritten, and a class the learned classifier proposed is still marked as inferred rather than as a rule, so what argued for a class stays auditable.

  • A model provider can now be configured through the chart (ADR-0008, gate 4). A new ai: block in values.yaml — provider, baseUrl, model, allowPrivateEndpoints, maxTokens, timeout, and an apiKey block naming the environment variable and an operator-supplied Secret — renders the AI_* settings into the shared app ConfigMap and injects the credential on every backend. Before this there was no AI_* value anywhere in the chart, so on the shipped Helm path no generative capability could ever answer, whatever edition was installed.

It is off by default and the product is complete with it off: every AI capability has a rule-based behaviour behind it, and Settings → AI assistant states what that behaviour is per capability. When ai.provider is empty the block renders nothing at all, so an existing release's ConfigMap is byte-identical across the upgrade and the checksum/config annotation does not roll every backend.

The credential reaches every backend rather than only the four that own a seam, deliberately: GET /tenant/ai is the single availability answer both UIs read before rendering any AI control, auth-service serves it and owns no seam, and the Anthropic client refuses to construct without a key — so a narrower injection would hide every AI control in the product while the capability underneath it worked. A misspelled provider is refused at install time by values.schema.json rather than degrading to "no provider" with a warning in a pod log. New operator guide: docsv4/core/operate/configuration/ai-provider.md.

  • The drift finding producer (asset-inventory workstream 4.7, ADR-0005 D3). Four kinds, judged against the tenant's own recent history rather than against any catalogue: new_class_in_segment, unexpected_protocol, port_profile_changed and new_issuer. All four write through the shared shared/findings/producer writer and run in the nightly finding-producer job beside eol and vulnerability.

The baseline is a window, and the window is the tenant's. tenant_admin_settings.config.drift.baseline_days (default 30, bounds 7–365) is read on every pass and never cached, and is edited under Settings → Asset Lifecycle → Drift baseline. Everything first observed inside the window is a candidate; everything older is the baseline. No new table: every baseline is derived from timestamps the inventory already carries (assets.first_discovered_at, asset_endpoints.first_seen_at/last_seen_at, crypto_implementations.first_discovered_at).

Nothing is drift without something to compare against. A tenant observed for less than one full window raises nothing and sweeps nothing — "we have not been watching long enough to tell" is not "the condition went away", and a new organization is not told that every asset it owns is new. The same rule applies per subject: an asset with no protocol, port or certificate history older than the window, and a network segment newer than the window, are reported as un-evaluable rather than as changed.

A drift finding closes when the change ages INTO the baseline, as well as when the condition disappears. And an observation_key on every finding means a person's decision survives: resolving "started speaking SSH" is not undone by the next pass, while a genuinely different observation on the same host re-opens the finding rather than being swallowed by the earlier resolve. Suppression, being a standing decision about the condition, is never re-opened.

Coverage is what the pass could compare. Every completed pass records its coverage through Writer.MarkAssessed, in the same transaction as its findings and its sweep (workstream 3.2), so drift joins an asset's risk_assessed_by — but only where that asset had a history of its own to be compared against, plus any asset a finding was raised on. An asset first seen inside the window, and every asset of an organization still warming up, are deliberately NOT claimed: "we could not compare yet" must not read as "we compared and it was fine".

Evidence carries the baseline, the observation, the window bounds and the timestamps — and for new_issuer, the issuer DN and the certificate fingerprint, never the certificate body. Loopback-bound sockets (bound_local = true) are excluded from the port profile; sockets whose binding is unknown are kept, because that is every endpoint a network scan found. The Findings inspector renders the two sides of the comparison side by side.

  • The configuration and hygiene finding producers (asset-inventory workstream 3.5, ADR-0005 D3), on the shared writer the eol and vulnerability producers landed with.

Configuration judges how a device is managed and what it exposes. Two sources, both already collected: the mgmt.plaintext / mgmt.protocol facts device interrogation writes — the SNMP v2c seed is the first plaintext_management finding — and a reviewed Go rule table over asset_endpoints covering Telnet, FTP and SNMP v1/v2c management, plus unauthenticated datastores (Redis, memcached, MongoDB, Elasticsearch, etcd, CouchDB), the plaintext Docker API and kubelet read-only port, rsh/rlogin, and NFS/rpcbind/TFTP. Three restraints are the substance: a port rule is suppressed the moment something MEASURED the service and it was not that one (the host naming HAProxy on 6379 beats the port); no rule fires on an endpoint measured behind TLS or SSH; and a loopback-bound socket is not an exposure, with bound_local kept three-valued so "nobody established it" never renders as "reachable". Every finding records which signal fired and cites the rule id.

Inventory hygiene judges the record rather than the thing: no_owner, no_class, no_location, duplicate_suspected (raised on BOTH halves of a pending merge proposal, and resolved when Approvals decides it), stale on a 30/90/180-day ladder across assets, endpoints and software installs, and orphan_relationship on edges whose other end was archived or deleted. Every kind is feeds_risk: false and scores 0 — data quality is reported beside risk, never inside it.

Both producers record what they examined in the coverage record, which is what turns the controls that COUNT their findings — Inventory Hygiene IH-005 and IH-006, and the new mgmt_plaintext — from "not assessed" into a real number, including a real zero. Configuration claims only assets it had something to look at: a host with no management fact and no active endpoint is read and deliberately NOT claimed, so "nothing was collected here" cannot read as "we looked and it is fine". Hygiene never reaches the risk-assessed array — it feeds no score, and an asset whose only evaluation was a missing owner has not had its risk assessed.

default_credentials_exposed stays registered and unproduced, deliberately: nothing in the product records that a vendor default was ACCEPTED, and raising the kind from anything weaker would report an inference as a measurement.

New measurement type mgmt_plaintext over the finding shape, with a new via: asset_or_endpoint so a control counts a Telnet listener found on an endpoint as well as one recorded on the asset.

  • Ask about your inventory in words, and get back a query you can edit (asset-inventory workstream 4.4b, ADR-0006 D2/D9, ADR-0008 D1). Enterprise.

The Query seam had a constructor and no caller. It has three now: a POST /api/v{1,2}/inventory-service/ask endpoint, the vistaplatform_ask MCP tool, and an Ask mode in the ⌘K command palette.

What comes back is a query, not a chat reply. A question becomes ONE line of the query language, validated against the production catalogue before anything runs; the response is the canonical predicate that ran — the platform's own default scope AND-ed in — then the assets it selected, then a summary in which every sentence cites one of those assets. "Open in Inventory" takes the predicate into the query editor, where it can be changed, re-run and saved as a view. A client rendering only the query and the rows has the whole answer.

A refusal is an answer. When the model cannot write a query that validates, the endpoint returns 422 carrying the validator's own {code, message, span, suggestion} diagnostics, in the same wire shape a hand-typed query's 400 uses, and the palette renders them through the query editor's own renderer with a button to open the attempt for editing. "No field hostnaem on assets — did you mean hostname?" is a better answer to a mistyped question than a confidently wrong list of servers.

Nothing matched is a fact about the query. Zero rows gets a fixed sentence the platform writes itself and no narration turn at all: an asset nobody has recorded and an asset that does not exist look identical from here.

The route is mounted in every edition and a Core build answers 402 — the availability question already has one deployment-wide answer at GET /tenant/ai, and a 404 would be indistinguishable from a broken route. 403 for the tenant kill switch, 503 for no provider. Gated on assets.read, the permission the MCP asset tools already declare for the same rows, pinned both in the middleware and in the routing table.

The MCP tool refuses honestly. On a deployment that cannot answer it returns {"available": false, "reason": …} as a result, naming which of edition / provider / a tenant switch is in the way and what to use instead — never a fabricated answer, and never a tool error for something a retry cannot fix. A permission failure stays a real error: the kill-switch 403 identifies itself with reason: assistant_disabled, and every other 403 — a role without assets.read, a scope-narrowed API token, a missing CSRF token — falls through to a real error carrying the platform's own sentence, rather than being told the organization switched something off.

Its bounds are the endpoint's own. A question past the published 4096-byte cap is a 400 naming the cap, not a 500, and one question is bounded end to end by a budget that fits inside the connection's write timeout — so a slow answer arrives as an error somebody can read instead of as a dropped connection.

The question is never written to the MCP audit rail. It is the one argument on that surface that is a prompt, and ADR-0008 D4.7's opt-in is enforced at the provider boundary; a copy elsewhere would be an opt-in honoured in one place and bypassed in another. The canonical query is recorded either way — it is the platform's own output, and it is what answers "which assets did that call select".

The palette also gains ADR-0006 D9's two missing result kinds in ordinary search: asset classes (from the generated taxonomy, no request, opening the whole subtree) and relationships of the best asset match, which is the shortest route to "this switch is being replaced on Friday — what is behind it?". Both modes now share one result model, so an asset row cannot look different depending on how it was found.

  • Per-asset risk is recomputed across every producer, and says who has looked (asset-inventory workstream 3.2, ADR-0005 D4).

The crypto producer. The cryptographic judgements the product has always made are now findings like everyone else's: crypto/weak_configuration on a configuration whose components the algorithm catalogue rates weak (worst component wins, with every component and its rating carried as evidence), crypto/weak_certificate on a certificate whose key algorithm, key size or signature is weak — the SP 800-131A floor applied per algorithm FAMILY, so a healthy 256-bit elliptic-curve key is not measured against the RSA floor — and crypto/pqc_vulnerable on either, from the same NIST IR 8547 denylist /pqc/progress counts with. Nothing here holds a second opinion: the catalogue roles, the PQC classification and the key-size and broken-hash rules all moved to one place each (internal/cryptoassess, shared/cryptoparse) and both callers read them.

One rollup, over findings. assets.risk_score is now MAX over the asset's OPEN risk-feeding findings — the asset itself plus its endpoints, configurations, certificates and software installs — filtered by the registry's feeds_risk flags rather than a list in code, so a hygiene finding can never inflate a security score. It replaces a statement that could only see crypto configurations, which meant an end-of-life OS or a critical CVE moved no asset's number. Parity with the old statement is PROVEN rather than asserted: both are computed over the same rows in one test before the old one was deleted.

Coverage is recorded, not inferred. A new producer_assessments table records which producer has completed a pass over which asset, written inside the pass's own write transaction — so a failed pass claims nothing. assets.risk_assessed_by is derived from it as its RISK-feeding subset: the record covers every producer, the array carries only the ones whose findings can move the score, so an asset only inventory hygiene has visited still reads NOT ASSESSED for risk. Score 0 with producers named is "assessed, nothing found"; score 0 with an empty array is NOT ASSESSED, and the asset page says which in those words. The asset's Overview tab also names the single finding its score came from, and clicking it opens the Findings tab with that finding highlighted. Compliance's finding measurement shape now gates on the full coverage record rather than the array, which is what makes an assessed_by: hygiene control answerable. A subject reachable from several assets covers all of them — a wildcard certificate served by four hosts raises four assets' risk, so it marks four.

Every producer pass now ends with one generic set-based recompute, so the configuration, hygiene and drift producers get it without knowing it exists. Discovery that writes a crypto configuration triggers a pass for that tenant rather than computing risk in the ingest path.

  • Three new alert types: vulnerable software, end of life, and an inventory hygiene score drop (asset-inventory workstream 3.9, ADR-0005 D7).

Vulnerable software. An installed package matching a published advisory now opens ONE alert for that installation — not one per CVE, because the fix is the same either way — graded on the CVSS qualitative severity bands (Medium from 4.0, High from 7.0, Critical from 9.0) by the worst advisory that matched, escalating in place when a worse one turns up. Advisories below CVSS 4.0 and advisories the feed has not scored stay visible as findings on the asset and open no alert: a notification threshold, never a claim of cleanliness.

End of life. An operating system, a package or a piece of hardware approaching or past the date its vendor stops shipping fixes opens ONE alert for the SUBJECT — an asset whose OS and whose hardware are both affected is one piece of work, graded by whichever is worse and naming both — on a ladder in days: 180 low, 90 medium, past high, past by more than a year critical. The alert grades the deadline; the finding behind it still grades the blast radius, and it is the finding that moves the asset's risk score.

Both are driven by the eol and vulnerability producers' OPEN findings, read through the ONE generated open-finding predicate (shared/findings.OpenSQL), and both auto-resolve when nothing on the subject crosses a rung any more. A pass raises only when the alert is new or the rung has risen.

Inventory hygiene score drop. The Inventory Hygiene framework's score falling more than 10 points in 24 hours is now its own alert type rather than a compliance_score_drop, so it can be routed — or silenced — separately from security posture. It is the SAME detector, routed by framework code; the two types gate independently.

The alert registry gained a second ladder shape to carry these: a fixed ladder, declared whole in standards/alert-registry.yaml as rungs: [{threshold, severity}], for types whose boundaries come from a published standard or from the finding ladder the alert is driven by. Nothing in a fixed ladder is tenant-tunable, so Settings → Notifications & Alerts → Alert Rules shows its rungs with no editor beside them.

  • Dashboard: an Inventory health hero beside cryptographic posture (asset-inventory workstream 3.8, ADR-0006 D5). Assets by top-level class, pending-approval and stale counts, and the free Inventory Hygiene framework's score with its passing / failing / not-assessed coverage. On the same page as the posture hero rather than on a dashboard of its own: the ops reader takes the left half and the compliance reader the right, and both are looking at one estate.

Three-valued throughout. The class bars are ROOT classes only — the class facet is hierarchical by design, so summing every bucket counts most of an estate two or three times. A facet level that failed renders "—" and "Couldn't load", never 0, because zero is the most reassuring thing several of these tiles can say. A hygiene score of null reads Not assessed — it is null both before the first evaluation and after one in which no control could be assessed — and never 100%.

  • The map's second view: Topology (workstream 3.8, ADR-0006 D4). A tenant-wide site → segment → class tree with counts and cross-segment connection summaries, behind a ?view= switch on the Map lens and linked from the dashboard hero. Not a graph of every asset: D4 rules that shape out, and the tree is an index into the inventory list instead — every count links through to exactly the rows it counted. A class chip uses class=<key>, the exact form: the tree groups by class, and plenty of classes are not leaves, so the subtree form would open a list three times the size of the chip that was clicked. The unsegmented bucket becomes not exists(segment_id) because the query language has no null literal.

Nothing is dropped. Assets with no site land under an explicit Unassigned node and assets with no segment under Unsegmented, which on a fresh inventory is most of them; the total is counted independently of the tree, and the view says so when the two disagree. Pending relationships and containment edges are excluded from the connection counts, with the page saying which.

New: GET /api/v2/inventory-service/infrastructure-assets/topology.

  • End-of-life and vulnerability columns on both software surfaces (workstream 3.8). The asset page's Software tab and the Inventory Software lens now show what the eol and vulnerability producers found, rolled up onto the list responses so the tab stays one request rather than one per row. Each cell with a finding behind it links to Findings narrowed to that install.

None known and Not assessed are deliberately different answers: the producer matches on Package URL and CPE only, so a product carrying neither was never checked, and rendering that as "0" would claim a check that never ran. An ungraded CVE reads "not scored", never 0.0. A missing end-of-life finding reads Not assessed, never "Supported" — it collapses a catalogue miss, a dateless catalogue row, a date beyond the warning window and a skipped non-active install, and nothing on the row tells them apart.

The catalogue rollup folds every install a tenant has, so it joins the findings plainly rather than once per install — the per-row form cost 702ms against 101ms on a 60,000-install tenant, on every page of the lens. One row per install is still guaranteed, by the findings_open_subject_uniq constraint rather than by a LIMIT.

  • GET /findings can be filtered to one subject. subject_type + subject_id, together or not at all — either alone is a 400, because a lone id can collide across subject vocabularies and a lone type is the producer filter under a worse name. Applied server-side so a subject past the page cap still returns its findings, and the Findings page says when it is filtered and offers a way out.

  • A learned classifier, underneath the rules and never in front of them (asset-inventory workstream 4.2, ADR-0008 D1/D2/D3/D4.3).

Most OUI rows in the classification catalogue are vendor-only, because most manufacturers sell across several classes under one assignment. So an HP MAC on a box with 9100 open and an embedded web server is obviously a printer to a person and unknown_host to the catalogue. That population — the rules were silent, or the rules contradicted each other — is what this is for, and it is all it is for.

The Classifier seam is now a chain. The curated rules run first and are unchanged: a rule that decides gets the answer, with rule provenance, and the model is not consulted, cannot outrank it and cannot soften its confidence. The model speaks only where they could not, and even then it must clear four further refusals — a 0.80 probability floor, a class the LIVE rule vocabulary contains, and, on a rules conflict, one of the classes the rules themselves named. Each refusal was mutation-checked by deleting it and watching a named test fail.

It proposes; it never sets a class. Not on an existing asset, and — the half that is easy to get wrong — not on a new one either. A rule-derived class IS written at creation, because the asset waits in Approvals and approving it approves the class; a model's answer always becomes an Approvals row instead, stamped class_source_kind: inferred with class_source_ref: model:<id> rather than rule. Both spellings satisfy every database constraint, so nothing but that distinction stands on the one column a reviewer uses to audit a class.

The row says "proposed by model (P=0.87)" and lists the three feature contributions the score is literally made of — "port 9100 is open", "the MAC prefix is registered to Hewlett Packard". A model cannot link to a source the way a rule can, so those reasons are the whole of its argument; and they name features and evidence, never a hostname or an address, because the classifier's entire input carries neither.

shared/classify/model: a multinomial logistic regression over hashed categorical features — OUI vendor, SNMP enterprise arc, product-id family, Server: banner tokens, 64 well-known ports chosen for what they separate, mDNS service types, LLDP/CDP capability bits, tokenised cloud resource type, and the rule engine's own output — a ~1,000-name space of which 260 carry a trained weight. Weights embedded in the binary, evaluated in pure Go. No ONNX runtime, no CGO, no new dependency — which closes the "in-process inference runtime" question the build plan had left open. Trainer at cmd/train-classifier over 184 committed fixtures, 131 of which the shipped rules are silent on, including the traps: a VM with a Cisco product id, a printer serving Apache, one Ubiquiti prefix that is both an access point and a switch, and ten class_rejected rejections trained as constraints rather than invented labels. Weights are reproducible from the fixtures by test, and the temperature is calibrated on held-out logits — fitted in-sample it is a sharpener, not a calibration, and would have made every probability read 0.999 and the floor meaningless. 1.0000 in-sample, 0.9310 five-fold held out, and — the number a reviewer actually experiences — 0.9640 precision above the 0.80 floor on the inputs the chain would raise a proposal for. The floor bounds the wrong proposals rather than eliminating them, which is what a calibrated threshold can do; a person accepting the row is what stops one reaching an asset. Both the per-class calibration table and the held-out log-loss are measured at the temperature that ships.

Operators can turn the model half off with CLASSIFIER_MODEL_ENABLED=false and keep the rules; Classifier: "none" still turns the whole seam off.

  • The eol and vulnerability finding producers, on a shared producer writer (asset-inventory workstreams 3.3 / 3.4 part 2, ADR-0005 D3).

One writer, not seven. shared/findings/producer is the single implementation of the finding lifecycle: Upsert creates the finding or reuses the open row — bumping occurrence_count, merging evidence, and bringing an INACTIVE row back to ACTIVE with resurfaced_at stamped — Resolve inactivates one subject, and Sweep inactivates everything the producer no longer asserts. Severity and score are validated against standards/findings-registry.yaml: a ladder kind must write one of its rungs verbatim, and a kind whose feeds_risk is false must write score = 0. producertest is the contract suite each producer runs with its own key and kinds against a real Postgres, so what it proves is that those findings obey the lifecycle.

End of life. Each asset's os.name/os.version and hw.vendor/hw.model facts, and every active software install, resolve against eol_catalogue. Findings escalate through the registry's rungs as the date recedes; a product whose support ends beyond the warning window gets the eol.* fact and no finding. Every finding cites the catalogue row id and the page that published it, and a lookup the catalogue cannot answer is counted on the gap list — bounded per run, and reported as bounded rather than as complete.

Vulnerability. Active software installs match vulnerability_matches by CPE and Package URL, comparing versions through ast.VersionSortKey so 1.10 is above 1.9 and never below it. One finding per install carries every matched CVE, worst-first, each with its NVD link; severity is the worst CVSS base score times ten, the convention models.RiskBands is anchored to. A CVE the catalogue never scored still raises a finding, marked not scored. A product with neither a CPE nor a PURL is counted as unidentifiable, never as clean.

The catalogue lookup moved to shared/catalogs so the producer and the Enricher seam's rule default resolve the same subjects against the same rows through one ResolveEOL — the eol.os.date fact on an asset and the end-of-life finding beside it cannot come from different catalogue rows.

Both producers run nightly over every tenant and are triggered after an SBOM upload, coalesced per tenant.

  • Risk & Compliance → Findings lists every producer. GET /findings carried a compliance-only scope, so the page the product points a person at for triage would have been the one surface omitting end-of-life and vulnerability findings — the asset page's Findings tab, finding:(…) and the has_findings facet all showed them.

A By Producer lens groups them by who judged what, and producer chips filter any lens. Each chip carries a count computed by the server under the same filters as the list minus the producer one, so a facet's number and the rows it leads to cannot describe different sets. The framework and control lenses still group by framework control, which only compliance findings have, and the page says so instead of rendering a blank list.

The inspector renders what each producer wrote: cycle, dates, days past and the catalogue citation for eol; every matched CVE with its CVSS and NVD link for vulnerability — all of them, since one finding per install carries the lot. An unscored CVE reads "not scored", never 0.0. "Create ticket" on a software-install finding links the host it is installed on, and the ticket's category follows the producer instead of filing every CVE under compliance.

The framework ACTIVATION gate now applies to compliance rows only: it asks whether a finding's CONTROL belongs to an activated framework, and a finding with no control has none — applied unconditionally it hid every end-of-life finding behind a framework subscription.

  • Remediation guidance on every finding, and — with Enterprise — a drafted plan built from it (asset-inventory workstream 4.8, ADR-0008 D1/D4/D5).

The Core half, and the larger one. Every finding kind in standards/findings-registry.yaml now carries guidance: customer-facing text saying what a person does about a finding of that kind. It renders in the Findings inspector in every edition, resolved from the generated registry with no model and no endpoint involved. It is deliberately separate from the description beside it, which is written for us and carries table names and workstream numbers — the generator refuses a kind whose guidance is a copy of it, and make audit catches drift in either registry.

The Enterprise half. Risk & Compliance → Findings → a finding → Remediation → "Draft a remediation plan" turns that generic guidance into a numbered plan for the thing actually in front of you — the version that was measured, the algorithm that was negotiated, the vendor whose command it is. Each step cites the evidence it rests on ([ev:<key>]) or the guidance ([guide]), and a step that cites nothing, or cites a measurement this finding does not have, is discarded before you see it; the panel says how many were. Every step that changes a system is tagged Manual step: the platform executes nothing, and the tag says so out loud. The classifier that applies it runs independently of the model's own flag and can only ever raise it.

It never replaces the guidance with an error. No provider, provider down, rate limit, rejected credential, refusal, unreadable answer, or an answer whose every step failed citation validation — all return the finding's own guidance with a reason naming which it was. The one exception is a tenant who has turned the AI assistant off in Settings, who is told that plainly (403) rather than handed a plan labelled like any other.

Accepting is the approval. Accept as plan writes one remediation_plan_items row with the steps as its notes, carrying source_kind: inferred, source_ref: remediator:<model id> and the accepting user — and Remediation → Plans badges it AI drafted and now renders those notes, which it never did — an accepted draft is an ordinary plan item there, managed like any other. POST /findings/{id}/remediation/draft persists nothing at all.

What crosses the boundary is an allowlist a reviewer can read in one function: the finding's kind, severity, subject label, summary and evidence, the registry guidance, and the subject's class, OS name/version and hardware vendor/model. No asset id, no hostname, no address, no serial. Evidence is redacted before it is bounded (a truncated PEM stops matching by shape), and the redacted form is the one thing sent, cited against and — only behind the tenant's Record the questions we send opt-in — audited.

Both endpoints are Core code mounted in every edition and answer 402 in Core, 503 on Enterprise with no reachable provider, and 403 for the tenant kill switch. GET /api/v1/auth-service/tenant/ai now reports the remediator seam as built and live-per-edition, and the Findings inspector asks it before offering the button, so Core shows the guidance and nothing else.

  • Four kinds of bill of materials on one artifact pipeline, and an OCSF export (asset-inventory workstream 3.7, ADR-0005 D6).

The CBOM was never a format — it was a subset. POST /cbom/generate gains kind: cbom (unchanged, and the default), sbom, hbom or inventory. CycloneDX 1.7 already models the union — device, operating-system, platform, application, library and data components, services for network faces, dependencies for edges, vulnerabilities for CVEs — so all four share one format, one content hash, one signature and one comparison rather than each inventing a serialisation. A request that omits kind is unchanged in meaning as well as in shape, and a CBOM's canonical bytes are byte-for-byte what they were: every new CycloneDX field is omitempty and appended, never inserted, because those bytes are what every stored artifact's hash and signature refer to.

What each kind holds. An SBOM is one component per software product — a fleet of 500 hosts running the same OpenSSL is one thing to patch, with the hosts underneath it as evidence.occurrences — and carries no dependencies graph, because an install record says a product is present, not that anything depends on it. An HBOM is one device per hardware-class asset with its hw.* facts, membership decided by the class registry so it cannot drift from the taxonomy. An inventory is every asset typed by its class, endpoints as services, approved relationships as the dependencies graph, facts and identifiers as properties, and open vulnerabilities.

Only the vulnerability producer's findings become CycloneDX vulnerabilities. Compliance failures, EOL, hygiene, configuration and drift are findings; none is a CVE, and every tool reading CycloneDX treats that array as CVEs. A missing asset owner listed there would be read as a remote code execution. A CVE the catalogue has not scored carries no rating at all — a 0.0 reads as "scored, and harmless".

OCSF 1.9.0 export, Core, at ?format=ocsf on an inventory artifact: JSON Lines, one Device Inventory Info (class 5001) per asset and one Vulnerability Finding (2002) per vulnerability. It is a projection of the stored artifact, so what a SIEM ingests is exactly what the content hash covers, stamped with the artifact's own generation time rather than the download's. Where OCSF defines a field nothing measured, the field is absent — not "", 0 or "Unknown" — and an asset class with no OCSF counterpart is type_id: 0 (Unknown), never 99 (Other): "we do not know" is a claim about us and true, "OCSF does not model this" is a claim about OCSF we are not entitled to make. This reaches parity with runZero, the only other product in the category exporting CycloneDX CBOM or OCSF.

Refusals rather than plausible-looking output. Comparison is same-kind only (400): two kinds identify contents in disjoint ways, so a cross-kind diff would report the entire base removed and the entire head added — precise, confident, meaningless. OCSF is defined only for inventory, and other kinds get a 400 naming the kind rather than an empty stream that reads as "no assets". SPDX and PDF stay CBOM-only, after the existing 402 edition gate so Core's answer is unchanged.

UI: Risk & Compliance → Bills of Materials (was "CBOM" — a user wanting an SBOM would never have looked there; the route is unchanged). The Generate dialog gains a Kind picker with one line per kind saying what it contains; the list gains a Kind column and filter; the download control offers only the formats that artifact's kind can produce; Compare restricts each side to the other's kind and says why.

Two bugs the tests caught before shipping: Contains(os_name, "ios") reported FortiOS, IOS-XE and Cisco IOS as Apple mobile devices, and a fact with two sources is stored per-source, so the OCSF lookup found nothing for exactly the assets carrying the most evidence.

  • Registry-driven compliance measurement, and two new Core frameworks — Inventory Hygiene and Lifecycle (asset-inventory workstream 3.6, ADR-0005 D5).

What a control can measure is declared, not coded. It used to live twice: as a switch of nineteen hand-written SQL blocks in the compliance-engine, and as nineteen INSERTed rows in seed.sql, with nothing holding the two together — a control could name a measurement type the extractor did not implement (it evaluated to nothing and read as "not assessed" forever), and the extractor could implement one no seed row offered. Both halves now come from standards/measurement-types.yaml. A row declares a whitelisted shape (one of five reviewed FROM/JOIN skeletons), a value that shape offers, and a query-language predicate compiled by shared/query; one executor runs all of them. No SQL comes from the registry, and the statement each row compiles to is pinned in a golden file, so a registry change is reviewable as the query it will actually run.

The refactor changed nothing, and that was proven rather than asserted. The old extractor was kept for exactly one commit as a test oracle, and both implementations were run over one deliberately awkward fixture — a soft-deleted asset, a soft-deleted configuration, a certificate with no algorithm at all, a CA certificate, a post-quantum key belonging to neither key-size family, an endpoint with no address, a configuration with no endpoint — and required to agree on all 74 measurements, tenant-wide and per-subject. They did. The oracle is gone; the golden it recorded stays, so every future change to a shape, a transform or a predicate is still measured against what the original extractor produced.

Inventory Hygiene (6 controls) scores the inventory record: is every asset owned, classified, located and recently seen, and are there suspected duplicates or relationships pointing at deleted assets? Nothing here feeds an asset's risk score — a host with no owner is not less secure, it is less manageable. Lifecycle (4 controls) tracks what has outlived its vendor: operating systems past end of life and within 90 days of it, software past end of life, hardware past end of support. Both are Core, both activatable like Certificate Hygiene, and both ship in the free seed rather than the Enterprise content bundle.

Both are three-valued, which is the point. A lifecycle control over an asset whose operating system the end-of-life catalogue never resolved a date for reads not assessed, never "supported": "we could not find out" and "it is fine" are different answers, and a report that conflates them is worse than none. The two counting hygiene controls read not assessed until the platform's hygiene checks have actually evaluated the asset, so a count of zero from a check that never ran is not shown as a clean bill of health.

  • Merge proposals are now ranked, and every score shows its working. When the identifiers disagree about whether a sighting is something you already have, Discovery → Approvals has always asked you rather than guessing. It now puts the strongest candidate first and tells you why.

  • A score and its reasons on every candidate card. A percentage bar, and under it the three signals that moved that score most, each with an arrow saying whether it counted for the merge or against it — "a one-per-asset identifier matches", "different network segments", "the names differ only in a trailing number, like web01 and web02". The reasons that count against are shown too: a proposal is a question, and hiding the evidence against would make the panel a case for merging rather than a summary of what was weighed.

  • 50% means "as likely as not". The score is a calibrated probability from a small model that ships inside the platform — it runs locally, sends nothing anywhere, and looks at nothing but comparisons between the two records: which identifier kinds agree and how many, how alike the names are, the network segment, the vendor and model, how close in time the sightings are, and whether two different kinds of source agree. It never sees key material, raw captures or configuration text.
  • 0% still means "nothing scored this", not "certainly wrong", and shows as an absence rather than an empty bar.
  • Optionally, a high enough score can settle it for you. Settings → Policies → Identification rules gains an auto-accept threshold. The default is Never, which is not a low bar — it is off, and no score bypasses it. The page states plainly that an auto-accepted merge cannot be undone from the UI.
  • Two things are never auto-accepted, whatever the score. A sighting whose serial number, cloud resource id, agent id or CMDB sys_id disagrees with the candidate's — two different serials are two different machines — and anything involving an asset still waiting for approval, because admitting an asset and merging two assets are separate decisions.
  • Everything it does is listed. Discovery → Approvals gains "Auto-merged by the matcher (last 30 days)", with the score, the reasons and a link to the asset, so an unattended decision is still one a person can check. The section is absent entirely while the threshold is Never. Each auto-accept also writes an audit event naming the model and the score.

  • NetBox connector, and a Settings → Integrations page driven by the connector registry (asset-inventory workstream 2.7, ADR-0001 D3 Q3, ADR-0004 D5).

The catalogue is the registry. Settings → Integrations grew an Available connectors panel that answers from standards/connectors.yaml (new Core endpoint GET /api/v2/inventory-service/connectors), grouped by kind, with each connector's status, edition and whether this tenant may add it. The page used to carry a hand-kept list of platform names in a modal file, which nothing could see drift.

Four connectors were claiming to be shipped and were not. hashicorp_vault, github, gitlab and bitbucket sat in the platform_integrations CHECK marked live with no collector anywhere — a tenant could save one and it would do nothing, forever, with no error. Neither guard could catch it, because the CHECK and the registry AGREED; what neither knew was whether any Go file dispatches on the key. The registry gained a third status, registered (the schema accepts it, nothing implements it), those four moved to it, and shared/connectors/implementations.go now names the package behind every live key with a test that fails when one is missing or names a package that does not exist. The UI renders a registered connector greyed out and not selectable — and deliberately not as an upgrade prompt, because offering to sell something that does not exist is the worse of the two mistakes.

The NetBox connector (Enterprise, connector_netbox) pulls sites, prefixes, VLANs, device types and devices. Prefixes become network_segments with imported provenance; devices become observations through the identification engine, so a device a sensor also found is one asset. Sites become site/region on the asset; hw.vendor/hw.model land as facts with provenance, so a later interrogation disagrees visibly instead of overwriting. Class comes from the NetBox device role through a mapping table (extensible per connection); an unrecognised role gives unknown_host, never a nearest guess, and the run reports how many it could not map. Imported devices wait in Approvals unless the segment auto-approves.

It never writes to NetBox. The token needs read access only, and drift is a read-only view: what NetBox lists and we do not, and what we hold and NetBox does not, each row linking into NetBox. Filling a source of truth with our own guesses would destroy the only thing that makes comparing the two worth doing. Run-now and a real scheduler (hourly/daily/weekly) both go through one code path, and every run records its counts — zeros included, so a run that imported nothing is distinguishable from one that never happened.

Private endpoints, deliberately. A NetBox is on-premises by construction, so this connector kind permits an RFC1918 target by default, per connection, with an audit entry on save and a platform-wide operator switch (CONNECTOR_ALLOW_PRIVATE_ENDPOINTS). The other half of the SSRF guard is not negotiable: loopback, link-local and the cloud metadata endpoints are refused at connect time whatever the setting says. Pointing the connector inside the customer's network is the point; pointing it inside ours never is.

The connector also stays where it was pointed: it refuses a redirect that leaves the NetBox URL the tenant gave it, including a same-host downgrade to http://. A redirect is a destination the server picks, and the API token travels with it.

Custom fields and config contexts — where a real NetBox keeps SNMP communities, TACACS keys and RADIUS secrets — are never collected: devices are fetched with exclude=config_context, and every response is projected onto a typed shape that discards what it does not name.

Where your roles have local names, Settings → Integrations → the connection → Device-role mapping maps them onto asset classes. A mapping naming a class that does not exist is refused on save rather than ignored at import time.

Customer doc: docsv4/enterprise/integrations/netbox.md.

  • Vista Platform now works out what a thing IS, and shows you why. The classification rules a platform administrator curates — MAC prefix to manufacturer, SNMP object id to product family, advertised service to device type, and now CDP/LLDP capabilities and mDNS service types — finally reach the inventory.

A newly discovered device arrives already classed. A printer that advertises its print service is a Printer in Discovery → Approvals, not an "Unknown host", and approving the asset approves the class with it. The asset's History records which rule decided it, so six months later the answer traces back to a specific row rather than to "the system".

For a device already in your inventory, a rule that disagrees raises a class proposal — the fourth kind of row on the one Approvals queue, beside discovered assets, merge proposals and relationship proposals. The row shows what the asset is classed as now, what the rules say, and which rules matched, with a link to the source each mapping comes from. A rule you cannot check is one you can only rubber-stamp.

Four things it will not do:

  • It never changes a class you set yourself. A class a person chose is never proposed against, at any confidence.
  • When two rules disagree it proposes nothing, and shows you both candidates to pick between. Two rules arguing is a problem with the rules, and that is a thing to fix rather than a coin to flip.
  • A rejection sticks. Say no once and the same class is not proposed again for that asset — otherwise a printer announcing itself every few minutes would put the same question back in your queue all day.
  • It never guesses. A device nothing recognises stays "Unknown host".

  • Rules added in the admin console are live within about five minutes. Until now, Catalog ▸ Classification rules stored, validated and audited a new rule and then changed no classification until the next release — the page said so. The services read the table at runtime and rebuild on an interval (CLASSIFICATION_RULES_REFRESH, default 5m). A rule that fails validation is skipped and logged by name; the rest of the table keeps working.

  • Three new kinds of classification rule. mdns_service (what a host advertises about itself on the LAN — _ipp._tcp and friends), lldp_capability and cdp_capabilities (what a device declares it DOES, over the two neighbour-discovery protocols). These are the strongest passive signals on a wired segment and nothing could use them before.

The restraint is deliberate: 802.1AB bridge on its own proposes nothing, because access points and desk phones bridge too; telephone proposes nothing because the taxonomy has no phone class to map it to; and a device advertising that it BOTH routes and switches is proposed as a Network device rather than guessed at, because a layer-3 switch and a router with a switch module advertise exactly the same thing. A wrong class is worse than none.

  • The host agent's inventory of its own machine now becomes an asset. Since the last release the agent has been able to describe the host it runs on — OS and kernel, hardware identity, installed packages, listening sockets, trust stores — and the platform stored every collection and did nothing with it. It now materialises.

After a collection you get one asset per host, waiting in Discovery → Approvals, carrying:

  • its facts — OS, kernel, hardware vendor/model/serial, interfaces, and two new ones summarising its trust stores (how many certificates, and how many things in them are not certificates at all);
  • one endpoint per listening socket, with the process holding it and an Exposure column reading "bound to localhost" or "reachable" — the half of a host's exposure a network scan structurally cannot see. That column is blank for everything a scan found, deliberately: a scan sees what answers, not what a socket is bound to, and showing those as "reachable" would be an exposure claim nobody measured. It is filterable too (endpoint.bound_local = false);
  • its installed packages, in the asset's Software tab and the Inventory Software lens, beside anything an SBOM upload put there.

Things it deliberately does not do:

  • It does not guess what kind of machine it is. The class stays Unknown host until a classification rule decides otherwise. An OS name and a hardware model are evidence; calling that a server is a judgement, and a plausible-looking guess is the kind that gets approved without being read.
  • It does not merge two machines that share an agent id. A restored image or a cloned VM carries the agent's identity onto different hardware; the platform opens a merge proposal naming both rather than quietly giving one asset two hardware serials.
  • It does not report a failed step as an empty answer. A host whose package database could not be read records no package count and leaves its software list exactly as the last good collection left it — rather than marking everything on it as uninstalled. The same holds for its listening sockets.
  • Nothing is deleted. A package that is gone is marked removed and a socket that has stopped listening is marked closed; both keep their first-seen date, so "this was here in March" stays answerable. Endpoints that an active scan or a cloud connector found are never retired by an agent — it is only authoritative about its own host.

Where to see it: Discovery → Sensors & Agents now shows "Last host inventory: 2h ago — 412 packages, 18 listeners" on each agent's row (absent when an agent has never reported one, which usually means local collection is switched off), and Discovery → Job Logs shows the same counts per run.

Collections taken before this release are not back-filled — they were collected under an older projection and replaying them would produce assets identified by rules that no longer apply. Local agents collect on a timer, so every host appears within one interval (24 hours by default) of the upgrade.

  • Devices your sensor only ever overhears are now in the inventory. A sensor has been decoding host announcements since the last release — ARP, DHCP, mDNS, NetBIOS, and the LLDP/CDP advertisements a switch or a phone makes about itself — but those observations stopped at the inventory boundary and went no further. They now become assets, through the same identification engine every other intake path uses, and wait in Discovery → Approvals like any other discovery.

This is how the inventory finds what the crypto pipeline structurally cannot: printers, cameras, controllers, anything on the network that never opens a TLS connection past the sensor. An uploaded PCAP is decoded the same way, which is often the only practical way to inventory a segment that cannot host a sensor.

What the platform will and will not say about such a device:

  • A hardware address alone is enough. An ARP frame from a camera that answers no name and holds no address still says the camera is there. It becomes an asset keyed on its MAC, and the next sighting — with a name, or a new address — updates that asset rather than adding a second one.
  • Its name is what it called itself. No reverse lookup is performed on these devices: internal host names stay internal. That is now enforced by a test that drives the real ingest with the resolver replaced and fails if anything reaches it.
  • Its type comes from the classification rules, or stays "Unknown host". A device advertising printing is a printer, and saying so is a rule's conclusion with a citation behind it — see the classification entry above. A device no rule recognises stays "Unknown host", which reads as "we have not worked this out" rather than as an answer.
  • A randomised (locally administered) MAC is not used as identity. Phones and laptops rotate those, and keying on one would add a new asset every rotation.
  • It has no ports, services or certificates, because nothing connected to it. A blank crypto section on one of these means nobody looked, not that it came back clean.

Auto-approval rules can name these rows specifically — kind:host_observation and network.segment_id=… — so a tenant can accept the devices a sensor sees on a segment they own without also auto-approving every TLS endpoint that sensor reports. A segment is matched from the device's address, so a device seen without one (an LLDP frame carrying only a chassis ID, an ARP probe from a host that has not been given an address) still waits for a person. We would rather queue it than place it on a segment nothing measured.

  • A sensor's host-observation counters are visible. The sensor has been reporting eight of them on every heartbeat, and sensor-manager mapped a fixed set of columns onto the metrics table and dropped the rest — so they were documented, sent, and stored nowhere. Discovery → Sensors & Agents → a sensor → Health now shows how many devices the sensor has seen this way over the selected range, how many observations it had to shed, and how many subjects are accumulating right now. A non-zero shed count means the segment is busier than the sensor is sized for, and devices on it may be missing.

  • The inventory has a map. Inventory → Map goes live (it was a phase-2 placeholder): the neighbourhood around one asset, drawn as a graph rather than listed as two tables. Reached from the lens, from an asset's Relationships tab ("View on map"), from ⌘K, or full-screen at its own URL.

  • Class and status are two separate channels. A node's colour and icon say what kind of thing it is; a ring around it says whether it is monitored, waiting for approval, or archived. An asset awaiting approval is still a server and still looks like one.

  • Pending relationships draw dashed, and rejected ones never draw at all. Somebody decided a rejected relationship was wrong; redrawing it would put a settled decision back up for grabs. Pending edges are off by default and come in behind a toggle.
  • "Show impact of focus" tints the blast radius by distance and dims — rather than hides — everything outside it, because "nothing else is affected" is only readable if the something-else is still on screen. It reads the same closure as the Relationships tab, so it keeps the same rules: no connects_to, no unconfirmed proposals, and a capped answer reported as "at least N" rather than as a total.
  • A truncated graph says so, with both counts. The map draws at most 500 assets and 2,000 relationships; past that a banner reads "Showing 500 of 812 assets and 2000 of 3104 relationships", because a picture that silently caps is the thing the endpoint's cap bit exists to prevent.
  • Deterministic layout. The same neighbourhood is drawn the same way every time (dagre, hierarchical), so a screenshot pasted into a ticket matches what the person reading it sees. A force layout settles somewhere different on every run and would not.
  • GraphML and Cytoscape JSON export of the graph on screen, for Visio, yEd, Gephi and Cytoscape. Documented as convenience, not evidence: written in the browser with no provenance or content hash, and truncated when the map is. Audit-grade output is still a CBOM artifact.
  • The graph library is code-split. @xyflow/react and dagre (both MIT) load only when a map is opened. The bundle every user downloads in order to look at a table grows by 0.8 kB gzipped — most of that ten new icon glyphs, not the graph — while the 85.2 kB the map itself costs is paid only by somebody who opens one.
  • Docs: The Map.

  • Assets now record how they are connected, and the platform can tell you what breaks if one changes. The asset page's Relationships tab goes live (it was a phase-2 placeholder), and relationship proposals join Discovery → Approvals as a third row kind beside discovered assets and merge proposals.

  • What depends on this. The tab leads with the impact question — how many assets are affected if this one changes, how far away each is, and which ones. It counts the nine dependency relationship types and deliberately not connects_to: two machines exchanging packets says nothing about whether one stops working when the other does, and because nearly every asset connects to something, counting those would make almost every answer "the whole estate", which is the same as no answer. The vocabulary actually walked is reported alongside the number.

  • Each relationship is followed the way its own meaning points. Ask what breaks if a wireless controller dies and you get the access points it manages; ask it of a virtual network and you get its subnets; ask it of a server and you get the applications running on it. A business service you have declared an asset to impact is included, and the answer stops there rather than continuing into everything that service touches.
  • Only confirmed relationships count. A proposal nobody has accepted is shown on the tab but excluded from impact. Nobody should plan a maintenance window around a guess.
  • Where each relationship came from is on the row. Measured, Declared, Imported or Inferred — the four are not worth the same, and Inferred, the one nothing has confirmed, is the one that is highlighted and the one that always waits for a person.
  • One relationship, two readings. An edge is stored once in its canonical direction, so the application's page reads "runs on" and the server's page reads "runs" — from the same row, with no second copy to fall out of step.
  • Declaring one shows you the sentence first. Direction is the easiest thing to get backwards, and a relationship pointing the wrong way is not obviously wrong on the page — it quietly inverts every impact answer built on it. The modal previews payments-api runs on app-server-04 before you save.
  • Measured relationships cannot be deleted, and the platform says why. A collector saw it, so the next run would re-create it: the delete would appear to work and then silently undo itself. Those retire by going stale, the same way a stale asset does. Relationships you declared delete normally.
  • The caps report honestly. Impact reaches 10 hops and 500 assets, the neighbourhood 3 hops, 500 assets and 2000 relationships. Past either, the answer says it is a floor and gives the real totals rather than quietly returning less than it found.
  • Three read-only MCP tools — relationships, neighbourhood and impact — so an AI agent can answer these questions with the same caveats a person gets: that inferred is not confirmed, that impact covers nine types and not ten, and that an empty result means nothing is recorded, which is not the same as nothing depending on the asset.

Customer documentation: docsv4/core/features/relationships.md.

  • Settings → AI assistant: what this deployment has turned on, and the two switches that are yours. A new Core page under Settings → Integrations (Tenant Administrator), behind one endpoint, GET/PUT /api/v1/auth-service/tenant/ai.

The page carries no entitlement lock, deliberately: it explains the edition rather than being gated by it. A Core tenant opening it sees each generative capability marked "Enterprise" — or "Not yet built", where nothing implements it in any edition, which an upgrade would not change — beside the rule-based behaviour that answers instead. The product is AI-native and never AI-dependent (asset-inventory ADR-0008), and an upgrade card would hide the settings a Core tenant genuinely has.

  • A capabilities table with one row per AI seam: where you meet it, what answers when no model does, and a four-valued status that keeps "not part of this edition" separate from "nobody has configured a provider" — one is a purchase, the other is ten minutes of an administrator's time.
  • The AI assistant kill switch. Off, and no generative capability runs for that organization, whatever the deployment has configured. Everything falls back to the deterministic path it already has; nothing errors. Enforced at each call site and again inside the provider boundary, so a capability added later inherits it rather than having to remember it.
  • The question-recording opt-in (ADR-0008 D4.7). Off by default: the audit trail records that a call happened, who made it, which model answered and a fingerprint of the request, but not the text anyone typed. On, and the redacted prompt is stored beside the fingerprint, bounded and marked when truncated. Implemented once at the boundary rather than per seam.
  • No credential and no endpoint address ever crosses: the response carries the provider kind and the model id only, pinned by a test that configures a provider and greps the raw response body.

The tenant's two controls live in the existing tenant_admin_settings.config blob — no new table, and the settings-audit trigger records every change against the acting user, the first save included. tenant.authoring_disabled is reported read-only alongside them: it is the interim platform-wide pause on custom-policy authoring (#1439), not a tenant decision, and a request trying to write it is refused rather than accepted and ignored.

Reading the status needs only a signed-in tenant user — it carries no credential and no endpoint address, and any surface can use it to explain why a generative capability is absent. Changing either switch needs settings.update, which is also what the Settings rail entry is gated on.

  • Cloud discovery now inventories your compute and network estate, not just its cryptography. A run against AWS, Azure or GCP records the account's compute instances, virtual networks, subnets and security-group membership alongside the load balancers, key stores and buckets it already found — the thing an ops buyer expects from a cloud collector and the frame the rest of a run hangs on. A TLS listener on a machine that is not in your inventory is half an answer.

  • One switch, on by default. Discovery → Cloud → Edit integration gains Inventory compute, networks and subnets, with the exact extra API calls and the IAM role that covers them stated next to it, per provider. An integration created before this release carries no setting and enumerates; turning it off stops the calls.

  • It is the same identity as everything else. Each resource goes through the identification engine on its provider resource id — an ARN, an Azure resource id, a GCP partial resource name — so an instance found here and the same host seen by the sensor or named in a CMDB pull are ONE asset, new assets land in Pending approval like any other discovery, and a re-run updates rather than duplicating. A name that is not globally unique is not used as one: EC2's IP-derived private DNS name repeats wherever a private address repeats, so it is recorded as a description rather than as the instance's hostname.
  • Two machines that share a private address stay two machines. A CIDR is not unique in a cloud account — two VPCs from one Terraform module both get 10.0.0.0/16 — so a network segment now carries the VPC, VNet or network it belongs to and two subnets with the same CIDR are two segments rather than one. And where an address genuinely is reused inside ONE subnet, the replacement is recorded as its own asset with a review item in Approvals asking whether it is the machine that used to answer to that address: from outside, an address that changed hands and a machine that was rebuilt look identical, and the platform does not guess.

    Underneath, agent_id, cloud_resource_id, serial_number and cmdb_sys_id are now singleton identifier kinds — an asset holds at most one of each — and an observation that disagrees with the one an asset already carries opens a merge proposal instead of quietly adding a second. This closed a real auto-merge: two EC2 instances sharing a private address became one asset carrying two ARNs, inheriting the first one's approval state and findings, with nothing in Approvals to say so. The same rule covers the LAN twin (two chassis behind one address are two serials, so two assets). - Containment is drawn from the provider. VPC contains subnet and subnet contains instance, from the provider's own placement rather than inferred, and a subnet's CIDR becomes a network segment — which is what lets an instance's private address resolve to the same asset when an agent inside it, or the sensor on the wire, reports the same address. - Instances are not crypto findings, and are not recorded as any. An instance negotiates no protocol and states no at-rest encryption, so it writes no discovery row at all rather than the TLS-on-443 the findings pipeline would otherwise have invented for it. - Security groups are membership, never rules. The group a resource is IN is recorded; IpPermissions, securityRules and a GCP firewall's allowed/sourceRanges are not read. On GCP, where there is no security-group object, membership is computed from firewall tag targeting — and a rule targeted by service account is not claimed to apply, because the platform does not collect an instance's service account and a false membership is worse than a missing one. - User data is never requested. Not projected away, not redacted: DescribeInstanceAttribute(userData), Azure's osProfile.customData and GCP's metadata.items are the three richest sources of secrets in a cloud account, and none of them is asked for. Neither are EC2 block-device mappings, GCP disks[] (which carries a customer-supplied disk key), or service-account scopes. EC2 key pairs are recorded by name only. - No guessed operating system. None of the three providers states a guest OS product on a list call — a billing string, an OS family and an image reference are not what the machine runs — so the OS fields stay empty rather than carrying a value that would silently mismatch the end-of-life catalogue. - The Job Logs row says what it found, and distinguishes a run that enumerated an empty account from one that did not enumerate.

  • Upload a bill of materials and it becomes software inventory. Discovery → Sources → SBOM Upload takes a CycloneDX (JSON 1.4–1.7) or SPDX (JSON 2.2/2.3) document, up to 32 MB, and turns its components into a per-tenant software catalogue and a record of which asset carries what. The platform already emits CycloneDX; this makes it an input as well (ADR-0004 D3, workstream 2.6b — the parser shipped in #1642).

  • The format is read from inside the document, never from the filename or the Content-Type — both are supplied by whoever is uploading and neither is evidence. The endpoint takes a form upload or a raw body, so a build pipeline can curl --data-binary @bom.json without a multipart encoder.

  • Where it shows up. An asset's Software tab; Inventory → Software for the whole catalogue, with a click through to the assets that carry each product; software:(name:openssl and version < 3.0) in the query language; and vistaplatform_list_asset_software over MCP. The tab and the lens were phase-3 placeholders waiting on the host agent — SBOM ingestion reaches the same rows first.
  • The result says what it did, against what you gave it. "412 of 480 components ingested · 68 excluded by the document", plus every warning the parse produced. A bare "412 imported" is a number you cannot check against your own build output.
  • An empty software list is not "no software". It means nothing has enumerated any, and the page says that in those words rather than showing a clean table that reads as a clean bill of health.
  • Re-uploading replaces the imported list for that asset, and what the new document does not list is marked no longer listed rather than deleted — so its first-seen date survives, "this library was here in March" stays answerable, and a later document that lists it again brings it back. Nothing a sensor or agent measured is touched.
  • Products deduplicate on the strongest identifier the document gave: purl, else CPE, else name and version. CPE attribute values are stored lowercase, because CPE 2.3 makes them case-insensitive and NVD publishes them that way — so OpenSSL and openssl are one product line and one vulnerability match, not two. Versions order on a normalised key, and a version with no numeric part has none at all rather than a wrong one.
  • A document with no target asset can create one from its own subject, and uploading the same artefact again lands on the same asset. It arrives in Approvals labelled Declared from SBOM upload and is not in your inventory until someone accepts it: an asset you create in the UI is approved because a person decided in front of the inventory, and an upload is a file arriving at an endpoint. Only an application subject can become an asset; a library or container subject is refused by name, with the remedy in the message.
  • The dependency graph is not stored, and says so. A document's dependencies are edges between components, and the platform's relationships are between assets. They are counted on the result card ("56 dependency edges not kept") rather than dropped in silence.

Not yet: vulnerability matching and end-of-life dates. The identifiers are stored so both become possible; neither is claimed today. Guide.

  • Host inventory: one collector, local and remote, held at intake. The device agent can now inventory a host as well as interrogate a network device — its OS and kernel, hardware identity, installed packages, listening sockets, and installed certificate stores (asset-inventory ADR-0004 D3, workstream 2.11a).

One collector library, two ways in. shared/hostinventory runs the same command set through a Runner: locally via os/exec, or remotely over SSH with the device's stored credentials. The parsers take command output or file bytes and nothing else — they never touch os/exec, read a local path, or consult runtime.GOOS — so local and remote parity is a property of the shape rather than something anyone has to maintain. A test drives one fixture through both transports and requires byte-identical reports.

  • Local mode is off by default (HOST_INVENTORY_ENABLED, HOST_INVENTORY_INTERVAL, default 24h with a 1h floor). It is not a job: the agent describes its own host on its own schedule, with no credentials, and posts to a new agent-authenticated intake route. An agent installed to interrogate firewalls does not start enumerating its own host because it was upgraded.
  • Remote mode is a host_inventory job queued against a device that already has SSH credentials, and requires mode: "remote" plus an agent_id — the collection runs from an agent that can reach the target.
  • --host-inventory-once collects this host, prints the report as JSON and exits, contacting nothing. It is how an operator sees exactly what would be sent before enabling the schedule.

Windows is reached by PowerShell over SSH, not WinRM. The intended client (github.com/masterzen/winrm) is Apache-2.0 itself and its graph carries no GPL or AGPL — but two MPL-2.0 modules and an ISC one, none on this project's MIT/Apache/BSD list, and it would add sixteen modules including a Kerberos stack to a cross-compiled agent to run one PowerShell command. Windows Server 2019+ ships OpenSSH Server, so transport: "ssh" reaches Windows too. "winrm" is a recognised-and-refused value whose error names the reason and the alternative.

Posture, never key material. Certificate stores are read for certificates only: a PEM block that is not a CERTIFICATE is skipped before being decoded, and the stored shape has four fields — subject, issuer, SHA-256 fingerprint, expiry — with nowhere for key bytes to sit. The Windows script asks Get-ChildItem Cert:\LocalMachine\… for three properties rather than taking the certificate objects, which can carry a private key. A private key found loose in a trust directory is reported as a count and nothing else. No command transcripts, no process command lines, and no PID inside the durable socket fact. A fixture carrying a real private key appended to a certificate proves none of it reaches the report.

Three-valued honesty throughout. Each section reports ok, failed or unsupported, and a failed section emits no fact — an absent fact means "not measured", where a fact with an empty value would mean "measured, and the answer is nothing". sw.package_count is emitted for an enumeration that genuinely found zero packages and never for one that failed. Hardware serial and UUID are absent rather than blank when DMI is unreadable, because a blank serial shared across an estate merges every host into one asset.

A report too large to accept is its own answer. Intake refuses an over-cap submission with 413 naming the cap and what the agent declared it was sending, and the agent logs the size it actually measured and says plainly that waiting will not help — an over-cap report is the same size on the next run. Previously this and a malformed body both answered 400, so a host too big to report retried forever and never appeared in the inventory. The cap is derived from the collector's own ceilings rather than picked: at 20,000 packages and 1,500 trust-store certificates the worst legal submission is ~9.3 MiB, and the cap sits at 32 MiB, with a test pinning the relationship so the two cannot drift apart.

Results are stored and HELD. Nothing yet creates an asset, an endpoint, a finding or a fact row — that is workstream 2.11b. The same reasoning as #1633's hold on sensor host observations: a host-inventory result carries one endpoint per listening socket with no crypto posture and an identity made from the host's own MACs and serial, and through the crypto ingest that is a finding per socket, a loopback endpoint routed to external_connections, and a fresh nameless server every collection. The held run is visible with an honest headline (materialized: 0, fully_materialized: false, and a line saying why) rather than absent, and the whole payload stays on the job row.

Operator guide: docsv4/core/operate/deployment/device-agent-deployment.md — including the exact command list per OS. Developer contract: docsv4/internal/developer/standards/HOST_INVENTORY.md, which also carries the osquery input-adapter query list (documented, deliberately not embedded).

  • What a device is is now argued from a curated rule table, not from constants in Go. The fingerprint rules behind every class proposal — MAC OUI, SNMP sysObjectID, EtherNet/IP vendor id, cloud resource type, service banner, port profile, model prefix, and the management platform the device answered — live in standards/classification-rules.yaml, generate into both a compiled-in Go table and seeded classification_rules rows, and are curated by platform admins at Catalog ▸ Classification rules (catalogs.manage, full CRUD, audited). This is asset-inventory ADR-0004 D6: the catalogue grows without a release.

shared/classify is the engine — pure Go, no database, no CGO, so the sensor and the device agent classify identically to a service. It is also the Classifier seam's default implementation (ADR-0008 D1's "rule-based default" column, not the null one); Classifier: "none" still selects a classifier that proposes nothing.

The posture is the feature:

  • A wrong class is worse than no class. Two thirds of the shipped OUI rules name a vendor and deliberately no class, because Dell, HP, NETGEAR, Ubiquiti, Cisco Meraki and Zebra each sell three or four different kinds of thing under one IEEE assignment. class_key is nullable so that is expressible, and a test fails if the ratio ever inverts.
  • Two rules that disagree produce nothing. Unrelated classes within 0.10 confidence of each other yield no class and report both — a contradiction in the catalogue, named, rather than a coin flip. A refinement (switch under network_device) is not a disagreement and resolves to the specific class.
  • Every rule cites a source, and a prefix IEEE never assigned does not get to cite IEEE: the locally-administered virtualisation markers (FA:16:3E, 02:42:AC, 52:54:00) cite the platform that documents the convention, and make audit fails if one does not.
  • One manufacturer, one name. OUI → vendor has a single owner (standards/oui-vendors.csv, the same table the sensor compiles in), the OUI rules are derived from it, and the generator refuses any hand-written rule that spells a manufacturer differently.

shared/deviceinterrogation/classhint.go is now a thin adapter over the engine; a parity test transcribes every pre-existing hint and fails by name if one disappears. Rules added in the console are stored, validated and audited but not yet consulted at run time — the classifier reads the shipped set, and both the page and the operator guide say so. (#1646)

  • End-of-life answers now come with the row that said them — and where the catalogue is silent, AI proposes and a person decides. Catalog → End-of-life gains two views beside the catalogue table: Proposals and Gaps.

This is ADR-0008 D1's Enricher seam, and unlike the narrator and the author its Core path genuinely answers:

  • Ask what the platform knows. "Check a product" on the Gaps view resolves a vendor/product/version against eol_catalogue and against the CPE names advisories already use, and shows each fact with its source_kind, the row id that stated it, and the page that cites it.
  • It will not answer approximately. A version whose release train is not in the catalogue resolves to nothing, never to the neighbouring cycle — that would be a support date wrong in the direction that reassures. A row that publishes no end-of-life date, or carries no source URL, produces no fact.
  • And it does not round its answer down to "nothing". "The catalogue has never heard of this" and "the catalogue has it and the vendor has not announced a date" are different answers with different fixes, and only the first is a gap — so the second says so and adds nothing to the gap list.
  • Every gap is counted. A lookup that resolves nothing lands on catalog_lookup_misses with a count, so the Gaps view is ordered by what is actually costing you answers rather than by what happened to be logged.
  • AI proposals are Enterprise, and they are proposals. With a provider configured, a run over the top gaps asks a model for an end-of-life row with a vendor page to cite. Proposals with no usable citation, an unparseable or self-contradicting date, or a URL pointing at a private address are discarded before anyone sees them; "unknown" is an expected answer and costs nothing. The pass also runs after each nightly mirror pass, in that order, so nothing is asked about a gap the feed has just filled.
  • Accepting is the only thing that writes the catalogue. A proposal is reviewed beside its cited URL; accept writes the row and marks the proposal in one transaction, with source_kind = inferred, the citation, and the reviewer's name. The citation is re-checked at that moment rather than trusted from write time, because that is when a claim becomes data every tenant is evaluated against. The table badges such a row AI-proposed permanently, so it never becomes indistinguishable from a mirrored fact. Rejecting writes nothing and keeps the proposal as a record of what was wrong.
  • No provider, no button. A new availability endpoint answers in every edition — {"available":false,"reason":"edition"} in Core, "no_provider" where nothing is configured — so the action is never offered where it would answer 503, and the page says which of the two is missing.

Subjects are redacted at the provider boundary before they are sent, and every call is audited (seam, provider, model id, prompt hash, tokens, invoker — never the prompt). Accept, reject and run-now land in the platform audit trail with the subject, the dates, the model and the cited URL. (#1644)

  • Ask the inventory a question and get back a query you can edit, plus the rows it selected. ADR-0008 D1's Query seam (shared/ai/ee/query), Enterprise, behind the provider boundary. This release ships the seam; the surfaces that call it — the MCP ask tool, the inventory endpoint and the command-palette mode — follow.

The design is that a model never returns rows. It returns one line of the query language, which is parsed and validated against the catalogue, run by the read-only MCP tools under your own row-level security, and shown to you, editable. So an answer you disagree with is a predicate you can correct, and an answer you like is a view you can save.

  • It refuses rather than guesses. A query that does not validate gets one repair turn carrying the validator's own diagnostics, and is then refused with those diagnostics attached — "no field hostnaem on assets, did you mean hostname?" — instead of returning confidently wrong rows.
  • Every sentence cites a row, by asset id, and a sentence citing a row the model was not given is discarded. If nothing survives, the rows are still returned with a plain note saying the summary was discarded and why.
  • Nothing matched is not nothing is there. A query that selects no assets gets a fixed sentence saying exactly that, and the model is not asked to describe an absence it cannot cite.
  • Bounded, in code: two translation turns, three tool calls, three narration turns. After the rows are in, the model may ask for one of three read-only tools — asset detail, the class list, facet counts — and nothing else.
  • Your question is redacted before it is sent (a pasted private key never reaches a provider) and the call is audited: seam, provider, model id, prompt hash, tokens, invoker, plus the canonical query that ran. The question itself is recorded only if the tenant opts in.

Core is unaffected: it has the whole query language, the facet rail and saved views, and the seam is null. (#1643)

  • SBOM parsing: CycloneDX and SPDX documents into a normalised software model. Two new shared packages — shared/software and shared/sbom — turn a bill of materials into the shape software_products and software_installs rows have. This is ADR-0004 D3's "SBOM ingestion first", and it makes CycloneDX an input to the platform as well as an output.

This is the parser library (workstream 2.6a). The upload endpoint, the Discovery → Sources page and the asset Software tab are 2.6b; nothing here is reachable by a user yet.

  • CycloneDX JSON 1.4–1.7 and SPDX JSON 2.2/2.3, detected from the document's own declaration rather than from a filename or Content-Type, neither of which is evidence. Nested CycloneDX components are flattened with containment recorded; SPDX primaryPackagePurpose is mapped onto the CycloneDX type vocabulary so a consumer reads one vocabulary whichever format arrived; cpe22Type external references are converted to CPE 2.3 so the two spellings of one product dedupe to one row.
  • One identity rule, matching the database. Product.Identity() is coalesce(purl, cpe, name || '@' || coalesce(version, '')) — including the inner coalesce, without which an unversioned product produces a NULL key and NULLs do not conflict, so every re-observation would insert another row. VersionSort() delegates to ast.VersionSortKey, the definition of version_sort and of the query language's version < 3.0; a second implementation would have the writer and the translator disagree silently.
  • Cryptographic components are skipped, with a counted warning. They are the CBOM path's business and an algorithm does not belong in a software catalogue. The fixture set includes the cbom-service formatter's own output, so a document this platform emits is one it accepts.
  • Nothing is silent. A malformed component, licence, edge or relationship is skipped with a warning rather than failing the document; a dropped purl or CPE names itself; a declared subject that cannot be read or resolves to no package says so rather than simply coming back absent; the two caps (32 MiB, 100,000 components) refuse the document rather than truncating it, because a half-ingested SBOM shows a software list that looks complete. An SPDX LicenseRef-… is dropped with a warning: it is an id its own document defines and nothing else, so two unrelated build systems both emitting LicenseRef-0 would merge two licences under one catalogue value.
  • No key material. redact.TextPEM runs over every string returned, warnings included — a warning quoting a malformed field is a leak path too, and it is the one nobody inspects. CERTIFICATE blocks survive: they are posture, and an over-strict scrubber is the same bug pointed the other way.
  • FuzzParse ships a committed seed corpus and asserts, on every input, no panic, a writable component with a non-empty identity, resolvable edges and no string that redaction would still change.

Contract and the mutation table for every guard: docsv4/internal/developer/standards/SBOM_INGESTION.md.

  • Draft compliance controls from a pasted standard — cited, unpublished, and evaluated by the engine, never by the model. Both compliance-authoring surfaces gain a Draft from a standard… action: Catalog → Frameworks for platform admins and Settings → Policies → Custom Policies for Enterprise tenants. Paste the text of a standard, or one section of it, and the platform proposes controls with measurement rules for you to review.

This is ADR-0008 D1's Author seam (services/compliance-engine/ee/author), and the honesty rules are the feature:

  • Every draft cites the passage it came from, by byte span, highlighted in your pasted text beside the draft. A draft whose citation does not resolve against the text we sent is discarded before anyone sees it.
  • Rules use only the measurement catalogue this deployment has, validated with the same MeasurementValidator the create-measurement endpoint runs. A rule naming an unknown measurement, an illegal operator, or a pattern that could never match the values its measurement can take is dropped and the control kept without it — with a note saying the control will score Not assessed, which is not a pass.
  • Everything discarded is reported, with the reason and the subject, so a model that cannot count bytes is distinguishable from a catalogue that is missing the measurement your standard is about.
  • Nothing is written by the drafting endpoints. Accepting a draft calls the ordinary create-control endpoint — same route, same validation, same permission — with source_kind: inferred and source_ref: author:<model>. Both control tables gained those two nullable columns and both control lists badge a drafted control. Publishing the framework is still the approval step, and the platform endpoint refuses a framework that is already published.
  • No provider, no button. A new availability endpoint on each plane answers in every edition — {"available":false,"reason":"edition"} in Core, "no_provider" where nothing is configured — so neither UI offers an action that would answer 503. The tenant endpoint is additionally gated on the custom_policies entitlement.

The pasted text is redacted at the provider boundary before it is sent, and the call is audited (seam, provider, model id, prompt hash, tokens, invoker — never the prompt). (#1639)

  • The CBOM comparison summary is now the Narrator seam, and it cites its sources. The prose above a CBOM diff has always been rule-written; it is now an implementation of seams.Narrator (ADR-0008 D1), alongside a second implementation that asks a configured model. Every deployment gets the deterministic one by default — nothing leaves the process, and the wording is unchanged.

Both narrators cite. DiffChange gains a row_id (r1, r2, …) and the summary carries inline [row:<row_id>] markers, which the compare page renders as links that jump to the change being counted. A new narrative_detail on the comparison response carries the marked-up text, the citation list, ADR-0008 D4.1 provenance, and source: rules | model plus model_id — so the panel can say "Summarised by rules" or "Summarised by \<model>" rather than leaving a reader to guess which they are reading. narrative itself is unchanged: the same plain sentence, markers stripped.

With AI_PROVIDER configured, the model is shown only the categorised rows — category, component name, and the one-phrase reason — never the CBOM documents, capped at 40 rows chosen worst-first, with the prompt stating explicitly how many rows were withheld. It goes through ai.Boundary, so it is redacted and audited. Every sentence whose citations do not resolve, and every sentence with no citation, is discarded before the user sees it; if nothing survives — or the provider is unreachable, unauthorised, rate-limited, slow, or returns no model id — the rule narrative answers. The narrator never returns an error to the user and never leaves an empty panel. (#1632)

  • audit.NewAISink adapts ai.AuditSink onto the platform audit rail every backend already wires, so an ADR-0008 D4.7 record (seam, provider, model id, prompt hash, tokens, invoker — never the prompt) lands in the same activity log as every other audited action. Reusable by the next service that wires a provider.

  • End-of-life and vulnerability catalogues, with their mirror feeds and an offline bundle. Platform admins get two new pages under Catalog beside Algorithms and Frameworks — End-of-life (eol_catalogue, mirrored from endoflife.date) and Vulnerability feed (vulnerability_catalogue + vulnerability_matches, mirrored from the NVD 2.0 API and osv.dev's bulk exports). Each page is a searchable, paginated table plus a feed-status card showing the last run, its outcome, the rows written and the bookmark, with a Sync now button and an Import bundle upload.

The feeds run daily inside admin-service, are incremental (NVD by modification window, OSV by per-ecosystem watermark), take a Postgres advisory lock so scaling the service does not multiply the requests going to NVD, and record every outcome in a new catalog_feed_state table. A feed error is recorded, never fatal, and the bookmark only advances on success — so a failed run retries its window instead of skipping it.

For air-gapped installs, make build-catalog-bundle DATABASE_URL=… exports both catalogues as a tarball with a SHA-256 and row count per file; importing it verifies every file before writing a single row, so a corrupt transfer cannot leave a half-imported catalogue. Integrity rather than a signature: these catalogues are Core, so there is nothing to license.

New platform permission catalogs.manage (granted to super_admin and platform_admin), kill switch CATALOG_FEEDS_ENABLED, optional NVD_API_KEY to raise NVD's rate limit from 5 to 50 requests per 30 seconds, and OSV_ECOSYSTEMS to choose which osv.dev buckets to mirror. Operator guide: docsv4/core/operate/catalogs.md, including the sources' attribution terms.

Two limits stated plainly rather than papered over. The catalogue is keyed on CVE id, so an OSV advisory with no CVE assigned is not mirrored (the skip count is logged and the page says so); and a CVE mirrored only from OSV carries a CVSS vector with no base score, which the console shows as "vector only" rather than a 0.0 that would read as "not serious".

This fills the catalogues. Nothing evaluates them against tenant inventory yet — the end-of-life and vulnerability producers are the next slice and depend on the asset-model rewrite. (#1605)

  • AI model providers behind the existing boundary (Enterprise). The two generative endpoints ADR-0008 specified now exist: Anthropic (Messages API) and an OpenAI-compatible client that covers hosted OpenAI, vLLM, Ollama, LM Studio and the gateways that imitate that shape. Both are pure net/http with no new dependencies, and both sit behind the redaction and audit decorators that shipped in 0.5 — a prompt that has not been through the redactor is refused at the provider itself, not only at the boundary.

Configuration is AI_PROVIDER / AI_BASE_URL / AI_MODEL / AI_API_KEY_ENV / AI_ALLOW_PRIVATE_ENDPOINTS (plus AI_MAX_TOKENS and AI_TIMEOUT), all documented in env.example. The API key is never stored in configuration — only the name of the environment variable holding it, read at the moment of use — so a configuration row that leaks is a configuration row.

AI_ALLOW_PRIVATE_ENDPOINTS (default false) is the knob worth knowing about: pointing the platform at your own Ollama or vLLM on a private address is a legitimate setup and an SSRF target wearing the same clothes, so it is an explicit operator decision rather than a guard quietly dropped. With it off, a private endpoint is refused twice — at configuration time for a literal IP, and at connect time for a hostname that resolves to one.

Unchanged for Core and for anyone who configures nothing: AI_PROVIDER still defaults to none, every seam still returns its documented null answer, and nothing is sent anywhere. A Core build does not contain the providers at all and says so — naming one yields the no-AI provider plus a logged warning, never a silent half-configured state.

Streaming is deliberately not implemented yet; the seams that need it arrive with the grounded-query work.

  • The has_findings inventory facet is back (withdrawn in Gate 1), as a tri-state control on the filter rail — has open findings / has none / any. It counted compliance_findings while the rail's click wrote a finding:(…) term over the unified findings table, so the number described a different set from the list it led to. The repair is not a second expression pointed somewhere else: the facet now produces its count by COMPILING the exact query text the rail writes, through the same translator the asset list compiles, and that text is generated into Go (findings.OpenQuery) and TypeScript (OPEN_FINDINGS_QUERY) from one line of standards/findings-registry.yaml. One integration test drives the facet and the list against a single fixture and fails if they diverge. "No open findings" still means producers looked and found nothing — for assets nobody has looked at, Risk → Not assessed is the honest third answer.

  • Release-candidate verification runs itself. scripts/rc-verify.sh installs a published chart version on a cluster the way a customer does (helm upgrade --install from the OCI registry, polling until the version exists), waits for the rollout, ensures a verification tenant exists through self-service signup (scripts/rc-verify-tenant.sh), runs rc-smoke.sh and helm test, and writes a one-page Markdown summary. It refuses to run unless every node name matches --expect-nodes, so a wrong kubeconfig fails before helm is called. Internal only for now — both scripts, and the rc-verify.yml workflow that drives them on the dev cluster, are dropped by the public-tree export, because rc-verify.sh cannot start without rc-smoke.sh and that has never shipped. The workflow runs after an -rc.N publish only while the repository variable RC_VERIFY_AUTO is true (absent = off: a shared cluster is not upgraded because a tag was pushed, and a re-run of an older publish is refused outright); it can be dispatched for any version in upgrade or fresh (no-migration, destructive, dispatch-only) mode. Guarded by scripts/test-rc-verify.mjs in make standards-check, which executes the workflow's resolve step rather than reading it.

  • The data path verifies itself too. tools/qa-platform/cmd/rc-verify-data registers a sensor exactly as a real one does (CSR-issued certificate, agent-mTLS passthrough), pushes a committed golden dataset through the sensor plane, and asserts what the tenant API reports: hosts → exactly that many monitoring assets, endpoints, crypto configurations, certificates and their expired state, legacy-TLS risk banding, risk-band sums, PQC summary/progress agreement, Core framework scores, findings. Counts are derived from the dataset; product behaviours are pinned in expected.json. Runs in rc-verify.yml after the install smoke; tested against a fake platform with one failing check per mutation.

  • The UI journeys verify themselves too. A new e2e/ workspace (Playwright, pinned to the browser build the lab runners already hold) logs into the deployed tenant UI through the real two-step login as the verification-data tenant and drives ledger journeys J3, J5, J9, J11, J13, J14 and J15 — dashboard counts and risk index against /risk/summary, PQC readiness against /pqc/progress, framework cards and "not assessed" against /frameworks/available, the Certificates and Configuration lenses against their APIs, Sensors & Agents rows, and a CBOM generated, listed with its hash, opened and verified from the page. Every number on screen is compared with the API the page itself calls. Runs in rc-verify.yml after the data path; ci.yml type-checks the package on every PR.

  • rc-verify can seed a fresh verification-data tenant. The workflow's dispatch takes a data_tenant suffix; the data path and the browser journeys then run against rc-verify-data-<suffix>@rc-verify.invalid, a tenant nothing has ever approved by hand, so "exactly N monitoring assets" proves segment auto-approval itself rather than a prior manual approval. Empty (the default) reuses the standing tenant.

  • Discovery → Discovery Jobs now lists every scan, not just device interrogations. discovery_jobs — Active Scans, Discover wizard runs, and the automatic-scan sweep — had no listing page anywhere; a scan sent to a tenant sensor was visible only as a feedback panel on the page that started it, or a stub run list under Settings → Active Scanning. The existing Discovery Jobs table (no new route, no new nav entry) now merges those jobs with device-interrogation jobs into one list, with Kind (Interrogation / Discovery / Automatic scan), Status and Executor filters. Clicking a discovery or automatic-scan row opens a detail panel with the dispatch timeline (queued → dispatched to \<sensor> at t → picked up → completed/ failed), its targets, and the findings/materialization split; Cancel is offered wherever cluster-sensor-service already supports it. (#1858)

  • Sensors report their own host, so it's a named, classed asset the platform never scans. The machine a sensor runs ON used to be visible only if some OTHER sensor happened to see it passively (ARP/mDNS), landing as an anonymous unknown_host carrying nothing but a MAC and an IP — even though the sensors row for that same host already knew its name, platform and profile. A sensor now reports its own hostname, FQDN (cached, never a blocking DNS call on the heartbeat path), OS/arch and per-interface MACs at registration and (throttled — at most once an hour unless it changed) on heartbeat. sensor-manager turns that into a host observation through the same ingest path every passive capture already uses, tagged with agent_id — the strongest identifier kind there is, so it can never mis-merge with an unrelated host sharing an address — plus a class hint from the sensor's platform/profile. An asset already seen passively (a real-deployment case: another sensor's capture created an anonymous MAC/IP-only unknown_host before this sensor ever self-reported) is retro-matched and upgraded with a name and a class, never overwritten if a rule or a human already classified it. The Sensors & Agents drawer shows a Host line linking to the resolved asset once one exists. Active-scan auto-routing (#1839) now excludes a sensor's own addresses from its targets — a sensor is never dispatched to scan the host it runs on. sensors.asset_id is nullable and absent (not blanked) for an older sensor build that sends no host block.

  • Active scans can run from the sensor that actually sees the host. A host reachable only from inside an organization's network used to get a passive partial observation forever, because every active scan ran from the platform sensor inside the cluster. Discovery → Active Scan gains a Run from control — Auto (observing sensor), Platform sensor, or a named tenant sensor — and automatic scans choose the same way behind a new Prefer the observing sensor switch (default on) on Settings → Discovery → Active Scanning. The observing sensor is the one whose passive capture last recorded the host; failing that a sensor on the same segment; failing that the platform. A scan sent to a sensor that is offline fails immediately and says so — it is never run from the platform instead — and a sensor that goes quiet after being handed a job fails it, naming the sensor and its last check-in, once the time its own reporting cadence allows has passed. Where scans are visible they now say who ran them and in what state — the Active Scan page keeps a Scans started from this page panel (executor, Queued · Awaiting · Running on · Completed · Failed: sensor offline with the sensor's last check-in, and the dispatch timeline, updating while the scan runs), and the Active Scanning settings run list shows the same for automatic scans. The permission follows the action: running from a sensor needs the same assets.update as running from the platform. A dispatched job's results take the sensor's ordinary discovery route and land on the asset as active observations. The legacy tenant-JWT POST /discovery/jobs/{id}/results intake, which a sensor could never authenticate to and which fed nothing inventory read, is removed. (#1839)

  • Automatic active scanning. The platform now builds the inventory without being asked: a new internal host is actively scanned for its cryptography as soon as it appears — from a sensor, an interrogated device, a scan or an import — and every internal host is scanned again once the organization's rescan interval (24 hours by default) has elapsed. Tenant admins control all of it, including turning it off, at Settings → Discovery → Active Scanning, which also shows what the platform has been scanning. Only internal addresses are ever scanned automatically: private ranges, or a network segment the organization registered as its own and typed private, VPN or cloud. Public addresses, segments the organization typed public, third-party-owned assets, shared carrier space and the platform's own addresses are never probed; scanning stops for an organization whose account is cancelled or suspended; and the default port set is narrower than the Discover wizard's, leaving file-sharing and industrial-control ports out of a scan that repeats unattended. (#1809) Upgrading to this release starts scanning your existing internal inventory on the first sweep — every internal host already in the inventory is due, and is scanned again every 24 hours by default. Tenant admins can turn it off at Settings → Discovery → Active Scanning; operators who would rather stage it can hold the worker off cluster-wide with DISCOVERY_AUTO_SCAN_WORKER_ENABLED=false and release it later.

  • The Active Scanning page now says what it did not scan. A Not scanned panel lists, by reason, the hosts the last pass looked at and refused — public addresses, carrier-grade NAT space, the platform's own addresses, link-local and the rest — with a count, and for the first two a link to register the range as a network segment. Carrier-grade NAT (100.64.0.0/10, RFC 6598) is now named as its own reason rather than folded into "public", because Tailscale, ZeroTier and mobile carriers hand out that range and an estate built on one of them was seeing "Automatic scanning: on" over a sweep that scanned nothing. What is refused has not changed.
  • Platform admins can set a tenant's email-verification and onboarding overrides again. admin-ui-v2 gains a Settings tab on the tenant drawer (Tenants → a tenant → Settings), where a platform admin with tenants.manage can require or waive email verification, and require or waive the onboarding walkthrough, for one customer organization. Each control is three-way — Platform default / Required / Not required — and the first option names what the platform default currently resolves to, so the choice is not made blind; leaving a setting on the platform default keeps that tenant tracking later changes to it rather than freezing today's value. auth-service has read both of these overrides all along, but the UI that set them lived in the v1 admin console and was dropped in the v1→v2 rebuild, so for the whole v2 era the per-tenant tier of both settings could not be reached. Operators without tenants.manage see the same panel read-only.

  • Automatic scans now count as scans on the Active Scan coverage list. An automatic scan that completes and records a finding for an endpoint stamps that endpoint as scanned, so a host the platform has already probed no longer sits on Discovery → Active Scan as never scanned. Only assets in monitoring are stamped — a host still waiting in Approvals has its findings held, so it stays on the list until it is approved or scanned by hand.

  • Processed sensor_discoveries rows are now purged after a retention window instead of accumulating forever. discovery-processor-service's raw ingestion queue kept every row indefinitely once it had been materialized, suppressed, deferred or routed — a one-sensor lab accumulated 13,779 rows in 24 hours with nothing bounding the total. A new bounded sweep (its own ticker, a cross-replica advisory lock so only one pod runs it at a time, deletes in 5,000-row batches) purges rows where processed_at IS NOT NULL and older than the retention window; an unprocessed row — still awaiting the poller — is never touched, however old. Default window 7 days (SENSOR_DISCOVERY_RETENTION_HOURS, default 168); kill switch SENSOR_DISCOVERY_RETENTION_ENABLED (default on). A supporting partial index (idx_sensor_discoveries_part_processed_at) keeps each sweep from sequentially scanning the table.

Changed

  • The dashboard's overdue-ticket card opens the work queue rather than the remediation plans grid, which lists a different object. (#1911)
  • A ticket's resolution time is stamped once, when it first reaches a terminal status, instead of being rewritten when it is later closed. Reopening a resolved ticket clears it. Average resolution time and the resolved-per-day trend were both inflated by the rewrite. (#1911)
  • A ticket created with a category the server does not accept now answers 400 naming the field and the value, instead of a generic 500. (#1911)
  • A host the tenant's own device agent is installed on is approved into inventory by its first local host inventory, instead of waiting in Discovery → Approvals. Installing and enrolling the agent is the decision the queue was asking for; the history row names the agent as the approver. Remotely collected hosts, and hosts a person has denied, are unaffected.
  • Identity enrichment's executor selection is no longer tied to the collector that heard a device advertised: any of the tenant's eligible collectors on the observed network can execute enrichment, while the observing collector is preserved as provenance. (#1898, #1901)
  • Existing retained observations that never produced an asset are automatically re-checked and materialized into provisional inventory items after upgrade, with no re-registration required. (#1898, #1901)
  • Automatic enrichment can refresh an explicitly configured source through a durable, deduplicated job, with tenant policy and executor checks and honest completion after ingestion. Admission and enrichment remain separately gated.
  • Observation details explain retained host, peer, cloud and cryptographic evidence with original source/times and safe certificate validity summaries.
  • Identity enrichment records durable source-first work, collector capability blockers and bounded scoped DNS/TLS/SSH attempts. Replays retain job identities and observation clocks; admission pause also pauses automatic asset scans.

  • Inventory distinguishes unevaluated identity records from established and operator-confirmed identities, independently of approval and assessment.

  • Discovery observations have durable source evidence and replay-safe receipt tracking, with an observation browser and identity coverage counts. Prospective activation is tenant-scoped and requires the coupled release acceptance gates.
  • Retained observations support reasoned confirmation, explicit linking, and dismissal. Confirmation preserves separate monitoring approval and checks the asset allowance. Deferred certificate evidence survives processing restarts.

  • Existing-asset reconciliation supports explicitly selected candidates, server previews, stale-evidence refresh, field decisions and durable merge audit. Archived sources redirect to the survivor; merge undo remains unavailable.

  • Trustworthy discovery settings separate admission from automatic enrichment, require a reason and current version, and retain incoming evidence while paused. Existing records stay visible as unevaluated without a bulk rewrite.

  • "Stale" means one thing now: 30 days. The Inventory → Stale lens cut at 14 days while the Stale finding's first rung, the Inventory Hygiene control IH-004 and everything else that judges freshness started at 30 — so a record could be listed as stale with no finding, no failing control and nothing anywhere agreeing that it was. The lens now reads the same ladder the finding does, so it lists fewer records than before. Both sides come from one constant and a test fails if they part company again.

  • The hierarchical class facet — the dashboard's costliest query — is about nineteen times faster (243 ms → 13 ms on a seeded 50,000-asset tenant, identical rows). It counts the distinct class paths before expanding them into ancestor prefixes rather than after. The cost was never the work: the expansion itself was 76 ms, but generate_series(1, <expr>) carries no row estimate, so the planner costed the join at fifty million rows and crossed the JIT threshold — two thirds of the time went on compiling a plan for a query that touches 1,389 pages.

  • A class argued from a rule is now recorded as such. class_source_kind on an asset gained a fifth value, rule, alongside measured, declared, imported and inferred, and class_source_ref names the rule row. Calling a rule-derived class "measured" would claim we measured the mapping (we measured the MAC); calling it "inferred" would call a deterministic, citable rule a model. Both are false provenance on the one field used to audit a class.

  • The Approvals count and the Inventory banner include class proposals. Approvals is one queue, and a count that covered three of its four row kinds pointed at a page with more work on it than the number admitted.

  • proposed_by in the query language now finds rule-derived classes. The Inventory facet listed them and filtering on one returned nothing, because the predicate behind it named only the older machine-proposed kind. Filtering on a facet bucket and getting an empty page is a bug that reads as an answer.

  • An unknown host is now recognised by its serial number. "Unknown host" is the class for something in your own space that nothing has identified further — and, now, for a serial nothing has classified yet. A serial names one chassis globally, so two sightings carrying the same one are the same machine whatever either sighting managed to work out about it; withholding that from the least-identified class did not make it more honest, it made it unable to recognise something it had already seen. This affects every way assets arrive, not only the NetBox connector.

Nothing about conflicting evidence changed: when a sighting's serial disagrees with the one an asset already carries — a restored image or a cloned machine carrying an agent's identity onto different hardware — you are still asked, in Discovery → Approvals, rather than told.

  • A host-inventory submission is about half the size it was. The package list used to be sent twice in every report — once in the collector's own record and once in the observations the platform materialises. It is now sent once, with the omission marked explicitly so an empty list cannot be misread as a host with no packages, and --host-inventory-once still prints the full local report. On a 20,000-package host the worst-case submission falls from ~9.3 MiB to ~5.1 MiB, against an unchanged 32 MiB cap.

  • An asset is a thing, not a listening port. The inventory's unit is now a configuration item with a class, identifiers and endpoints: a host exposing five services is ONE asset with five endpoints, where it used to be five assets. network_assets and devices are replaced by assets, asset_endpoints, asset_management and asset_credentials; the four-value asset_type enum is replaced by the class registry; and the address, port and identified service move from the asset to the endpoint. What that removes, and why nothing is carried forward, is under Removed below. Phase 1 of the general asset inventory initiative (ADR-0002, ADR-0007); no public release is cut until core-v1.0.0.

  • One identification engine behind every intake. Sensor ingest, active scan, cloud discovery, PCAP, spreadsheet import, CMDB pull and manual create all build an observation and resolve it through shared/identity instead of the four different dedupe keys they used before (manual create did not dedupe at all). A hostname or an IP now identifies only within its network SEGMENT, a serial or a cloud resource id identifies globally, and identifiers that resolve to more than one asset open a merge proposal in Approvals rather than merging anything.

  • Asset risk can go down. The per-asset score is recomputed as the maximum over the asset's scored crypto configurations. The previous write was GREATEST(old, new), so removing the last weak configuration left the asset reporting its old maximum forever. risk_assessed_by records which producers have looked, so a score of 0 with an empty array (not assessed) is distinguishable from 0 with {crypto} (assessed clean).

  • asset_history has a writer. Every asset creation, match, merge proposal, context change and status change records an entry with its provenance. The table has existed since the beginning with no writer at all — not Go, not a trigger.

  • BREAKING (API): a finding names its SUBJECT, and severity is one lowercase ladder. With the unified findings table (see Removed), every finding shape on the wire changed. A client reading the old names silently gets undefined rather than an error, so this list is the upgrade:

was is
asset_id on a finding subject_id
asset_type on a finding (network_asset/certificate/crypto_implementation) subject_type (asset/certificate)
severity Low/Med/High/Critical info/low/medium/high/critical
severity_counts.med severity_counts.medium
finding_asset_type / finding_asset_id on a remediation plan item finding_subject_type / finding_subject_id

Findings additionally gained producer, kind, score and subject_label. GET /assets/{assetId}/findings now answers for the asset and its descendants, from every producer, and returns only OPEN findings — detection_state = ACTIVE and workflow_status NOT IN (RESOLVED, SUPPRESSED). The MCP vistaplatform_get_control_findings tool description changed with it.

  • network_segments records its provenance. New nullable source_kind / source_ref columns (ADR-0005's vocabulary). NULL means "drawn before this column existed" and is deliberately distinct from declared — a segment somebody drew two years ago has no recorded provenance, and labelling it would invent a fact. A NetBox prefix that matches an existing segment stamps the provenance and leaves the tenant's name, environment, tags and auto-approval settings alone.

  • Two unused search indexes on the findings table are gone. They were trigram indexes meant to speed up the Findings page's free-text search, and Postgres could never use them: the search matches a term against the summary, the subject label, the finding kind OR the host, and a disjunction that includes the host lookup cannot be served from an index at all. Measured on a 50,000-finding tenant, the query plan is identical with them and without them, while the two indexes cost ~18 MB and were rewritten on every producer pass. Removing them changes no result and no timing; it removes write cost and the impression that the search is indexed.

  • Risk & Compliance → Findings now opens on By Producer. The page's nav home was the By Severity lens, which reads inventory-service's crypto-risk stream — a different data set from the one the page is documented to show ("every open finding for your organization, from every producer"). End-of-life, vulnerability, configuration, hygiene and drift findings were therefore invisible until the reader changed lenses, on the one surface the product points a person at for triage. The Dashboard's "Critical findings" tile links here explicitly rather than riding the default, and counts the Criticals in the same every-producer set this lens groups.

Fixed

  • Sensor self-reports use a separate installation identifier from device agents, preventing false conflicts when both run on one host. Existing sensor identifiers retain their asset association on upgrade.
  • Enrichment distinguishes offline collectors from online collectors without an eligible interface in the target network, including reflected advertisements.

  • New password and social signups start with identity admission and automatic enrichment enabled, with an audited initial policy. Existing tenant policies are preserved. Inventory describes unevaluated identities without the misleading “legacy” label and explains inactive or paused assessment.

  • Sensors report a restart requirement only for capture settings that differ from the running capture configuration. Changes are saved before acceptance, persistence errors are surfaced, and pending values are not reported as running.
  • The Linux sensor installer checks startup dependencies before installation and explains libpcap and loader failures; --check-dependencies supports a check without root access or registration.

  • Cloud discoveries retain encrypted provider context while identity is unresolved, then replay facts and crypto evidence with original observation timestamps. Cloud job logs distinguish retained evidence, created/matched assets and approval.

  • Approving an asset no longer refreshes its observation time or clears its stale state. Existing observation timestamps are not rewritten.

  • The platform interrogation worker uses one fleet identity. The unused device-agent auto-registration route, token, bootstrap certificate and synthetic heartbeat have been retired. The existing system sensor continues to identify the worker and attribute discovery results. Operator-installed agents retain enrollment, certificate rotation and heartbeats. Historical duplicate device-agent rows may appear offline until an operator deletes them through the normal fleet lifecycle; the platform-managed system sensor remains protected. (#1875)

  • An asset is re-classified when a host inventory arrives, and the classifier can finally read an operating system. Classification ran exactly once, when an asset was created, and nothing re-asked the question when better evidence turned up minutes later: a laptop first seen passively was created unknown_host from an address and a MAC, and a device agent's full host inventory for the same asset three minutes later — OS, vendor, model, serial, 106 software installs, 83 listening sockets — changed nothing. It also could not have: a general-purpose computer matches no rule kind the catalogue had, since its OUI is vendor-only by design and its model is a consumer product line. Two halves, both needed:

  • A new os_name classification-rule kind (RE2 over the reported os.name), seeded with rules for client Windows and macOS → computer and Windows Server → server. Linux and the BSDs get no rule on purpose — a distribution name is not a statement of function. Curated like every other kind under Catalog ▸ Classification rules.
  • The host-inventory intake now applies the rules' class to an asset it creates, and PROMOTES an existing asset that is still on the unknown_host floor, writing the move to asset_class_history. A human-declared class is never touched, an asset already holding a real class gets a proposal in Approvals instead, and a class the learned model proposed is never applied. Only first-hand evidence may promote: an interrogated peer, which is described by a third party, still proposes.
  • A matched asset is also named. The identification engine writes display_name only at creation, so an asset the platform knew the hostname of went on being displayed as a bare IP address; a display name that is an address is now replaced by the hostname, and one that is a real name is left alone.
  • A reverse-DNS guess no longer overwrites the hostname a sensor measured. Third-party connections in Inventory → 3rd Party showed a generic cloud PTR name (ec2-54-163-235-119.compute-1.amazonaws.com) in place of the vhost the client actually asked for (slack.com), because the active TLS enricher's row carries no SNI of its own and fell through to reverse DNS. Two fixes: a discovery now inherits the SNI its own passive sibling captured in the same batch (refusing to guess when one address was contacted under two names), and external_connections.dest_hostname gained a provenance (dest_hostname_source_kind, the ADR-0005 measured/inferred vocabulary) that the upsert enforces — an inference can never replace a measurement, and a measurement can always correct one. Existing rows are unlabelled and keep their names; a real SNI now repairs them on the next observation.
  • A sensor or discovery agent that never reported can now go offline. The offline detector measured the dwell from last_heartbeat alone, behind an AND last_heartbeat IS NOT NULL guard, so a subject that registered and then never checked in — the ordinary shape of a failed install, where registration succeeds and the service then fails to start or is firewalled — showed active in the console indefinitely and raised no sensor_offline or discovery_agent_offline alert, ever. It now measures from COALESCE(last_heartbeat, created_at), the form sensor-manager's reaper has always used; the two code paths had disagreed, so such sensors were flipped to offline while the alert path stayed silent about them. An alert for a subject that has never reported says so rather than rendering a zero timestamp, and clears on the subject's first heartbeat. Sensors an operator marked air-gapped are now excluded from the detector — the flag means the sensor is not expected to check in at all.
  • Certificate controls could report a clean pass on a certificate nothing had evaluated. The per-asset compliance reconcile behind every asset-change event folded certificate-subject controls against the asset's id, which matches no certificate, so those controls produced no measurement and no finding — and the score rollup, which derives a control's verdict from the absence of an active finding, read that silence as a pass. A tenant whose only certificate had 56 days of validity left scored 100/100 on "Certificate expires within 90 days"; the same pass also cleared the PQC-readiness certificate controls. An asset-change reconcile now covers the certificates bound to the asset, mirroring the fan-out a certificate change already performs in the other direction, and the stale-finding sweep is scoped to the subjects the pass actually evaluated.
  • An automatic active scan whose sensor refused the job no longer counts as a scan. The stamp that takes an asset off the rescan queue is written at dispatch — right for a probe that went unanswered, wrong for a sensor that never ran the job at all — so an asset whose sensor rejected the command sat out a full rescan interval on the strength of a scan that never happened. The sweep now hands those assets back on its next pass.
  • A discovery job that ends no longer leaves its targets queued. Only the in-cluster executor and the sensor's own completion report ever settled discovery_targets, so a job that failed before either ran left its target rows at pending indefinitely and the Jobs page showed work still queued under a job that had finished. Unfinished targets are now settled with the job, carrying its reason; targets that already reached a terminal state keep the outcome they earned.

  • Certificate expiry controls refresh as time passes, even when no asset or certificate is changed. The platform checks the expiry window three minutes after startup and every 12 hours thereafter, reconciling affected tenants through the existing evaluation path. Zero-day, negative and fractional policy thresholds are included. This covers published platform frameworks; tenant-authored custom-policy evaluation remains disabled. See Compliance refresh. (#1872)

  • The platform interrogation agent appears under Discovery agents on Discovery → Sensors & Agents, using its existing system-sensor identity and health status. It remains platform-managed and cannot be deleted. (#1864)

  • A crypto configuration's risk score could go up but never down to clean. Ingest wrote crypto_implementations.risk_score only when the computed score was above zero, to keep a persisted 0 from reading as "assessed and safe" when nobody had looked. The column is a last-write, not an accumulator, so the guard also meant a configuration first seen negotiating TLS 1.0 kept that score for ever: every later observation that resolved to a clean set computed 0, and the 0 was never written. Remediation was invisible in the number, and the stale score went on feeding the finding, the asset roll-up and the severity band. The write is now gated on whether the configuration was ASSESSED rather than on the arithmetic, so a re-observation can lower the score, including to zero; a pass that recognised nothing still writes nothing and leaves an earlier verdict intact. The honest-zero distinction is kept where it already lived: a configuration is assessed when it has at least one catalogue component linked, which is the same predicate the crypto producer reads.

  • Two approval tests deadlocked intermittently under the parallel integration runner. TestIntegration_AssetIngestionPendingApproval and TestIntegration_ApproveAssets failed with pq: deadlock detected against a neighbouring package binary applying scripts/database/schema.sql — a harness race with nothing to do with approvals, reported as a failure of the approval logic. The exposure belongs to the shared getTestDBAndTenant fixture rather than to the approval tests, so every test in both files that use it — the approval tests and the network-space tests — now holds the same shared schema advisory lock, and two source-level guards fail if one is added without it or if a third file starts using the fixture. shared/query's three DB-backed tests took the same cure, for the same deadlock reported as "Postgres refused the generated clause". Test-only.

  • A network device running Cisco IOS could be exported to a SIEM as an Apple handset. Core. The OCSF export maps an operating-system name onto OCSF's os.type_id, and it treated a bare "iOS" or "iOS 15.2" as Apple's. show version on a Catalyst prints exactly "IOS 15.2(4)E10" with no vendor word in it, and Cisco IOS 12.x–15.x overlaps iOS numerically, so name-only the two are identical. The mapping now requires an Apple signal — iPhone, iPad, iPadOS or an Apple hardware vendor beside it — and answers Unknown without one, which for a SIEM correlating on platform is the difference between "not identified" and a false assertion about a core switch.

  • A second, different class disagreement about the same asset was silently dropped. Core. When the classification rules contradict each other the platform proposes no class and asks a reviewer which is right. The index that stops the same question being re-asked on every poll keyed those proposals on the proposed class — which a conflict does not have — so every conflict about one asset collapsed onto one row. An asset the catalogue argued over twice showed the first disagreement and swallowed the second for as long as the first stayed unanswered. The key now includes which classes disagreed. The Approvals queue's own read also gained the index it never had, so paging it no longer scans the whole history table.

  • The query language's spec fixtures accepted predicates the product refused. Core. The static §4.3 catalogue the conformance fixtures resolve against had drifted from the registry-derived one the running validator uses, in eleven places: attr.os_version typed as a number against a column holding "22.04.3 LTS", protocol five OT values short so protocol:S7 was invalid in the fixtures and stored in the database, strength with no value set so strength:strng validated and matched nothing, identifier:(kind:mac) offering a value the column never holds, network.type at two values instead of four, and keys (attr.managed, fact.cve.max_cvss, fact.eol.software.date) that no registry declares. All resolved in the registry's favour, with a parity test that refuses a twelfth.

  • An alert's severity only ever went up. Core. A raise at the same or a lower severity was deduped into a silent touch, so an alert that opened critical stayed critical until the condition vanished entirely: a package downgraded from CVSS 9.8 to 5.1 by a partial fix kept a critical badge, and an asset whose worst end-of-life deadline was dealt with kept the grade of the deadline that was gone. The Alerts page is triaged top-down by severity, so this put stale worst-cases at the top of the list. The findings-driven detectors now lower an open alert to the rung its findings actually reach. The row is never deleted and never re-opened — the severity changes on the alert that is already there, the move is appended to its evidence timeline with its direction and reason, and nothing is notified: a pager that fires to report good news is a pager that gets muted. Escalation is unchanged. The evidence timeline draws a lowered severity with a downward arrow labelled "Severity lowered" — it drew every severity change with an upward arrow, which on the one row a person reads to learn which way it went said the opposite of what happened.

  • Billing webhook audit events were filed under system. Enterprise. The Stripe webhook publisher named no event_category on the NATS path, so audit-service's ingest fallback filed every billing.* event under the platform's own catch-all — while the same event sent over the HTTP fallback path was filed under tenant, so the category depended on whether NATS happened to be up. Both paths now build one envelope, categorised tenant. Existing rows are not rewritten.

  • The Findings page's search box searched a prefix of the list, not the list. It narrowed the rows the browser already held, and the page fetches at most five pages of 200 — so on a tenant with more than a thousand findings a term matching only the 1,200th one answered "no findings match", which on screen is indistinguishable from a clean estate. The software surfaces link here with ?q=<product> for any product in the catalogue, so this was reachable in one click. GET /findings now accepts q, matched server-side across the finding's summary, its subject label, its kind as it is displayed (end of life finds software_end_of_life) and the host the row names underneath it (web-01 finds every finding on web-01); % and _ are literal characters rather than wildcards. The term narrows the rows, the total and the per-producer facet counts under one predicate, so none of them can disagree.

  • The topology view's "cross-segment links" headline reported the size of the picture as the size of the estate. It counted the edges DRAWN, which is the same number as the real total only until an estate is big enough to truncate — a tenant with 900 cross-segment links and a cap of 200 read "200" in 24-point type. It now reads the server's own total_edges and says how many were drawn when the two differ.

  • Two relationships between the same pair of assets were drawn as one line on the map. An application that both runs_on and connects_to its host had its second edge hidden underneath the first, with the label overprinted and no way to hover it — so that edge's provenance, confidence and observation count were unreachable, and the map silently under-reported how two things are related. Parallel edges are now spread apart and each is separately hoverable.

  • The map's Export menu could not be closed by a keyboard user. It closed on mouseleave and nothing else: Escape did nothing, tabbing away left it open over the graph, a click on the canvas left it open, and on a touch device (where there is no mouseleave) it never closed at all. It now closes on Escape, on an outside click and after choosing an item, returns focus to the button, and carries aria-haspopup / aria-expanded.

  • An end-of-life or vulnerability finding on a package showed the machine's name, not the package's. Every such finding on one host rendered with an identical label, so nine end-of-life packages read as nine identical rows. The Findings list and drawer now name the package and carry the host beside it as context; search still matches either.

  • Orphan-relationship findings were invisible from the assets they concern. A relationship edge pointing at a missing asset was listed on Risk & Compliance → Findings and nowhere else — not on either asset's Findings tab, not in the has_findings filter, not behind a finding:(…) query — which is every surface a person would actually fix it from. An edge is now reachable from either of its ends.

  • An expired fact was read as "the problem went away". asset_facts.expires_at says when a collected answer stops being current. The end-of-life and configuration producers ignored it, and once it was honoured naively an expired fact would have looked like an absent one — making the producer stop asserting its finding, which resolves it. Both now treat an expired fact as not evaluated: no new finding, and the existing finding is left exactly where it is rather than being marked "no longer detected" by a pass that measured nothing. A fact that is genuinely gone still resolves, as it always did.

  • A finding's evidence can no longer grow without bound. A certificate-issuer finding on a host presenting hundreds of certificates wrote every one of them into the finding's evidence. Long lists are now truncated with an explicit marker saying how many were omitted — never silently — and an oversized document drops its largest entries, naming them, while keeping everything the finding needs to stay matchable and navigable.

  • The AI query seam sent an asset's full record to the model provider on a get_asset follow-up. The page of rows crossing the provider boundary has always been projected onto an explicit field allowlist, so the tenant's owner_email, description, tags, attributes, metadata and tenant_id never left the platform. A follow-up "tell me more about that one", however, forwarded inventory-service's full asset envelope verbatim — every one of those columns, plus the asset's identifiers, endpoints, crypto configurations and certificates. A tenant asking their inventory a question in prose was sending their colleagues' addresses and their own free text to a third-party provider. Follow-up detail now crosses on the SAME allowlist the rows do, and an unrecognised envelope crosses as nothing rather than as itself. Faceting by owner_email (or owner) is refused before the tool runs, for the same reason: the facet's values are the people.

  • Every request the audit middleware recorded was categorised system. The global request-logging middleware, which runs in every service, derived event_category from the request path by reading the second path segment as the service name — and for /api/v1/inventory-service/assets that segment is v1. Nothing matched, so for as long as the /api/v1 prefix has existed every row it wrote landed as system whatever it was about, with an event type naming the service (system.inventory-service.create) instead of the resource. Handlers that pass their category explicitly were unaffected. The service segment is now found whether or not the /api/vN prefix is present, the category comes from an explicit per-service table covering every registry service (a test fails if a service is added without a decision), and the event type names the resource (asset.assets.create, tenant.tenants.delete).

Existing rows are not rewritten — they stay system, so anything that reads event_category shows a step at the deploy: on the platform admin Security dashboard the "Events by category" panel and the compliance report's events-by-category summary show system shrinking while asset, discovery, compliance, user and authentication appear; the compliance-gap analysis's covered categories widen from that point; and the compliance tags audit-service assigns from the category (soc2, iso27001, pci_dss, …) attach to these rows from now on, having never attached before. An audit alert rule or SIEM filter written against event_category = system, or an event type of the form system.<service>.<action>, matches only the old rows and needs rewriting. The activity page's category chips are built from the rows on screen and widen on their own.

  • Billing audit events from Stripe webhooks were discarded on INSERT. The NATS ingest path in audit-service defaulted a missing event_category to api, which the audit.activity_logs CHECK rejects, and the billing webhook processor is the one publisher that sends no category — so every billing.* audit event it recorded was refused by the database with nothing visible to the publisher. The default is now system, which the CHECK accepts. The billing publisher still names no category of its own; tenant would be the accurate one and is a follow-up.

  • A framework could report PASS for controls nothing had assessed — the materialized compliance score's "is anything in scope?" probe was one tenant-wide question ("does this tenant have any crypto configuration or certificate?"), written when every measurement read one of those two tables. The registry-driven measurements added three more shapes (asset, fact, finding) and a single TLS endpoint satisfied the old probe for all of them. Two customer-visible consequences, both on the Posture page, the framework card and the dashboard's Inventory health hero:

  • Lifecycle scored 100 on an estate the end-of-life catalogue had never resolved a date for. The live evaluator correctly reported NOT ASSESSED while the stored rollup — which is what those three surfaces read — reported four passing controls out of four.

  • Inventory Hygiene's IH-005 and IH-006 read PASS wherever the hygiene producer had never run, so the score went UP for not looking.

The probe is now per measurement shape, each mirroring its shape's own FROM/WHERE, with the fact and finding shapes probing the specific fact key or producer the control measures rather than "any fact at all". Nothing that genuinely has data to read changes status.

  • Cryptography findings had no evidence panel. The crypto producer writes the whole derivation of its score — the two numbers it takes the worse of, and the catalogue components that produced them — and the Findings inspector fell through to the generic "raised by the Crypto producer" card, answering "why this score?" for the five other producers and not for the one whose entire output is a score. Opening a cryptography finding now shows the score, its components worst-first with each one's catalogue rating, and — when the catalogue's number and the number recorded at observation disagree — both, with which one won. The customer documentation already described this.

  • scripts/lint-go.sh shared golangci-lint's cache across every git worktree of this repo on a host. The cache keys entries by file path, so a worktree that had since been deleted could still serve a stale result to a sibling worktree resolving the same path — leaving a //nolint directive unread and make standards-check reporting a false lint failure with no repro under a hand-run golangci-lint run. GOLANGCI_LINT_CACHE is now derived per worktree from git rev-parse --absolute-git-dir (scripts/lib/golangci-cache.sh), unless a caller already set it.

  • API server WriteTimeout was shorter than every generative seam's own per-call timeout in inventory-service, compliance-engine and cbom-service (15s vs. 20s–90s), so a slow /ask, /remediation/draft, /draft-controls or /cbom/compare request could lose its response to a dropped connection with no body. All three API servers (never the plaintext /health server) now read the new seams.GenerativeWriteTimeout constant, sized above the slowest configured seam call.

  • tenant_frameworks.description (custom policies) was nullable but scanned into a plain string in two readers. TenantFrameworkService.ListTenantFrameworks and .GetTenantFramework failed the whole read (converting NULL to string is unsupported) for a custom policy whose description is SQL NULL — the same defect #1680 fixed for platform_frameworks.description/.organization, on the tenant_frameworks sibling that was left standing. Both queries now COALESCE(description, ''). A DB-integration test seeds a NULL-description tenant framework and reads it through both fixed readers plus a regression guard on the evaluation path's existing COALESCE.

  • shared/discovery's SSH banner was unbounded, unredacted free text from the remote device, unlike the structurally identical LLDP/CDP banner in shared/hostobs (redact then truncate). Both capture sites (the full-handshake path and the plain-read fallback) now route through boundSSHBanner, which strips an embedded PEM block before capping the result at 256 bytes — redacting before truncating, so a PEM straddling the cut is still caught. ProbeResult.SSHBanner and Metadata["banner"]/Metadata["ssh_banner"] all inherit the bound from the same source value.

  • The in-cluster Platform Sensor kept its own SSH prober, and it still stored the raw banner. services/cluster-sensor-service's ssh_prober.go was an independent copy of the handshake shared/discovery runs for the standalone sensor — the parallel-copy drift the shared-source rule exists to prevent — so the bound above never reached it: both its handshake path and its banner-only fallback wrote the unredacted, unbounded banner into the finding. It now delegates to the shared prober, the way tls_prober.go already does, and inherits boundSSHBanner. Reconciling the two also fixed the shared fallback, which read from the connection ssh.NewClientConn had already closed on error and so could never return a banner (a device that announced itself was recorded as a probe failure); it now re-dials, as the in-cluster copy did, and is skipped once a host key has been captured rather than discarding it for a version string. The banner-only result no longer manufactures ssh_key_types = [""].

  • crypto:(…) and cert:(…) could not see a configuration with no endpoint. crypto_implementations.endpoint_id is NULL by design for a configuration that is not tied to one socket — an at-rest cloud resource, disk or database encryption — and asset_id is the roll-up target. The query language's asset/crypto and asset/cert shapes inner-joined the endpoint, so every such configuration, and every certificate reachable only through one, was missing from crypto:(…) and cert:(…) over an asset: the Inventory facet rail, saved views, the ask seam and any compliance measurement over the asset shape all returned a narrower set than the language defines, with no error and nothing empty to notice. The two reverse shapes (crypto_configuration/asset, certificate/asset) made the same assumption, so such a configuration could not name its own asset either.

The endpoint stays authoritative where there is one — a configuration whose endpoint moves to another asset follows the endpoint — and the fallback applies only where the walk has no endpoint to follow. This is the same rule, spelled the same way, as shared/findings.AssetSubjects, which finding:(…) and the has_findings facet resolve through; a new unit test holds the two definitions to the same text, and an integration test drives both over the same rows. crypto:(…) from an endpoint deliberately does not fall back: a configuration measured at no socket belongs to no socket.

  • shared/redact now recognises SNMP community strings. A v1/v2c community string IS the credential — an SNMP GET authenticates with nothing else — and the interrogators carry one as Credentials.Custom["community"], where the name-based redactor did not recognise it. Matched as a name suffix (community, snmp_community, read_community, trap_community) plus both spellings of community_string; deliberately not a bare community fragment, which would redact Azure's communityGalleryImageId. Both polarities are in the provider-boundary field table.

  • The tenant AI kill switch is now enforceable on POST /ask and on the remediation-draft route, and tested. Both handlers read tenant_admin_settings and answer 403, and both blocks could be deleted outright with every suite staying green — the contract harnesses construct the handlers with no database, so the read, the refusal and the context stamp that carries the question-recording opt-in were never executed. A tenant that turned the assistant off in Settings was protected only by the web UI declining to render the control; an API token, the vistaplatform_ask MCP tool or a script would still have had the question translated, or the finding's evidence projected, and sent to the model provider. No behaviour changed: what changed is that deleting either block now goes red.

  • Settings → AI assistant no longer reports the matcher as "Not yet built". Its learned scorer has been the seam's default in every deployment, Core included, since the matcher workstream, with merge-candidate ranking and the model id on Settings → Identification rules as its consumers. The seam catalogue row is corrected, and a new guard makes the classical half structural rather than hand-maintained.

  • go test -tags ee ./... is green in auth-service again. A contract test asserting a Core-build property lived in an untagged file, so the suite of the build the commercial images come from failed on it. Split per polarity, with the Enterprise half — which did not exist — now covering what a build with the providers linked and no AI_PROVIDER configured reports.

  • The public tree's go test ./... is green under shared/ai/seams. The invoker-ownership guard ships to the Core export and hard-failed there, on a rule about ee/ directories that the export removes by construction. It now identifies the export positively and skips, while still failing on a checkout that has lost its Enterprise trees some other way.

  • Four collector guards that could not fail (asset-inventory gate 2). A mutation sweep over every projection phase 2 widened found three tests that stayed green with the thing they guard deleted, and one row whose provenance nothing checked.

The F5 sys/version projection was unguarded. Its only test drove the interrogator through Registry.Get, so the name-based redaction backstop scrubbed the fixture's adminPassphrase and sslPrivateKey and the test passed with the projection removed. The backstop matches NAMES, so a vendor field that is sensitive without being named like one — a licence key under Title, a support-contract id — reached DeviceInfo on every F5 interrogation with nothing failing. There is now a test that reads the projection's output directly, before any redaction.

The host agent's TestProjections_DoNotForwardPoison asserted the absence of a string that was never in its inputs. All four projections could have been replaced with a whole-struct copy without it noticing.

shared/hostobs redacts free text before it truncates it, and the ordering was unpinned. Cutting a PEM block in half first leaves a fragment with no END line, which the scrubber's regex cannot match, so the fragment ships. The two orderings agree on every input except that one, and the existing tests' banners all fit inside the 256-byte bound — so swapping the two lines left the suite green while any LLDP or CDP system description over the bound with a key straddling the cut would have carried key material into a device's banner.

Cloud containment edges were written with provenance nothing asserted. source_kind is the field a reviewer uses to decide whether to believe an edge — "the provider's API stated this" is a different claim from "something proposed it" — and source_ref is what lets a pass sweep the edges it drew.

  • The Ask endpoint's own tenant-kill-switch read had no test. Ask consults ai.TenantAIControls directly (fail closed to 503 if the read errors, 403 if the tenant switched the assistant off) whenever it holds a database handle, but the existing harness always builds AskHandlers with a nil pool, so neither branch was ever driven — only the boundary's SECOND enforcement of the same switch (stamped onto the context directly) was tested. Added DB-integration tests that seed a real tenant_admin_settings row and drive both branches through a real *sql.DB. Mutation-tested: made the unreadable-settings branch fail open (return without a 503) and confirmed the new test catches it.

  • Removed shared/ai/seams.StripPlanCitations, a stripping helper with a doc comment and a test but no caller. Unlike its row-grammar sibling StripCitations (which the CBOM comparison narrator genuinely needs, for a plain-prose summary alongside the raw chip-rendered text), the plan grammar's one caller — the Enterprise remediator's renderPlanNotes — deliberately keeps the [ev:…]/[guide] markers in an accepted plan's notes, because the markers are the checkable part of an auditable step. No code path wanted a stripped plan step, so the helper is gone rather than left unreachable "just in case."

  • A generative call from a Core handler could not be attributed, so every question would have been refused. The grounded query seam held its own private invoker context key, which meant the only code able to attribute a call was the Enterprise package itself — while its caller is, by construction, a Core HTTP handler that may not import an Enterprise tree. Every request would have reached the audit boundary unattributed and been rejected, reporting a perfectly healthy model provider as unavailable, with the cause visible only in a log line. The same bug the enricher had; caught this time by landing the caller. (asset-inventory workstream 4.4b)

  • Findings on an endpoint-less crypto configuration were invisible to their asset. findings.AssetSubjects — the one definition shared by the query language's finding:(…), the has_findings facet and the per-asset findings read — walked crypto_implementations.endpoint_id with an inner join. That column is nullable by design (an at-rest cloud resource has no socket; asset_id is the documented roll-up target), so every such configuration's findings, and every certificate reachable only through one, were unreachable from the asset with no error anywhere. The path now falls back to asset_id where there is no endpoint.

  • A control alert stayed open after every finding behind it was resolved. The control_noncompliant detector read its findings with its own near-miss of the open-finding predicate — detection_state = 'ACTIVE' AND workflow_status <> 'SUPPRESSED' — which agrees with the registry's one definition on three of the four workflow values and disagrees on RESOLVED. A tenant who worked every finding on a control and marked them resolved kept the alert: Remediation → Alerts still said the control was noncompliant, with nothing open behind it, and the only way to clear it was to suppress findings they had genuinely fixed. It now compiles shared/findings.OpenSQL, the same generated definition the query language and the has_findings facet use (ADR-0005 D3).

  • control_noncompliant re-raised every noncompliant control on every scan pass, even when nothing about it had changed. Unlike the findings-driven alert jobs in the same package, this detector called Raise unconditionally for every control its query matched — and a noncompliant control matches that query on every 30-minute pass until its findings clear. The alert engine dedupes a same-severity re-raise into a silent touch, so this never duplicated an alert, but it still moved updated_at/last_event_at (and issued a needless write) every interval for every noncompliant control a tenant had. It now mirrors FindingsAlertScanJob's raise-on-change guard: Raise is called only when the alert is new or the control's baseline severity has risen above the open alert's.

  • Silencing one score-drop alert type auto-closed the alerts it already had. Splitting hygiene_score_drop out of compliance_score_drop made one detector serve two types, so a disabled type no longer returned before the auto-resolve sweep — and the sweep closed that type's open alerts under the observation "framework no longer active or scored", which was not what had happened: the framework was still activated and still scored. Turning an alert type off in Settings → Notifications & Alerts → Alert Rules now leaves its open alerts alone, as it did when the job served one type.

  • A framework with no organization recorded could blank the Frameworks page. platform_frameworks.description and .organization are nullable but are read into plain Go strings, and a NULL fails the scan — which aborts the whole read, so one such row returned an error instead of one degraded entry for every licensed, available and default-framework list. Both columns are now COALESCEd at every read, reporting "no organization" as an empty string.

  • The same NULL-organization defect broke every other reader of platform_frameworks, not just framework_license_service.go. PlatformFrameworkService.ListFrameworks / GetFramework / GetPlatformDefaultFramework, TenantFrameworkService.ListPublishedFrameworks / ListPublishedFrameworksWithLicense / ViewFramework, FrameworkContextService's batch-evaluate framework lookup, and EvaluationService.GetFrameworkStatus / GetComplianceScore all scanned the same nullable description/organization columns into plain Go strings. Worse than the Frameworks-page defect above: EvaluateFramework — the core evaluation function every score, control-status and finding view goes through — also scanned an uncoalesced description for both platform and tenant (custom-policy) frameworks, so a NULL there surfaced as "framework not found" for a real, published, licensed framework, and GetFrameworkStatus's per-row error tolerance meant the framework simply vanished from a tenant's Frameworks status with no error at all — a real "not assessed" rendered as "does not exist". All of the above are now COALESCEd the same way.

  • Risk & Compliance → Findings: the search box now filters the findings. It narrowed only the inventory-service crypto-risk stream, so on every findings-shaped lens — the producer lens included — typing in it did nothing while looking exactly like an applied filter. That is the failure the page already refuses for its producer chips, pointed the other way. It now matches what the row SHOWS — summary, target label, subject label, joined hostname — and deliberately not kind or producer: searching a field nobody can see makes a hit unexplainable.

  • A discovered certificate could be invisible from the asset serving it. A crypto configuration's leaf certificate is recorded twice — as crypto_implementations.certificate_id (a denormalisation a few reads use directly) and as a row in the crypto_implementation_certificates junction. Everything that walks DOWN from an asset to its certificates reads only the junction: the certificate path in shared/findings.AssetSubjects (behind the asset page's Findings tab, finding:(…) and the has_findings facet), the query language's cert:(…) sub-predicate from an asset, an endpoint or a configuration, certificate/asset traversal, the location roll-up's certificate and expiry counts, and the certificate-expiry bands on the crypto risk list.

The two were written by different statements in different transactions — the column by ingest, the junction row by a later best-effort call that only logged on failure — so they could diverge, and did: the original valid_certificate_role CHECK rejected the literal leaf, so on every sensor-discovered certificate the column landed, the junction insert was refused, and ingest reported success. The certificate existed and carried its risk while being unreachable from, and unsearchable by, the asset it was served on.

Ingest now writes both halves in one transaction, deriving the junction row from the row's own certificate_id so the two cannot name different certificates, on both the insert and the re-observation path. Configurations written before that — and the demo/QA fixtures that set only the column — are converged by an idempotent backfill in the schema's POST-MIGRATIONS block, so an existing database repairs itself on the next apply.

  • A failed history append aborted a finding producer's whole run. producer.Writer treats the history trail as best-effort, but ignoring an error inside a transaction does not undo it: Postgres aborts the transaction on any failed statement, so every later Upsert in the pass came back current transaction is aborted, naming a statement unrelated to the cause. The append now runs in a SAVEPOINT, so "best-effort" is a property of the code rather than a comment above it.

  • An SBOM upload could return 500 for data it had already stored. The post-commit hook that triggers the finding producers ran in the request path unguarded; a failure in it unwound through the handler after the commit, and the user's retry swept and rewrote everything the first upload landed.

  • Inventory Hygiene and Lifecycle were unreachable on a 0-cap tier (asset-inventory workstream 3.6, the recurring CMP-6 class). Both frameworks were added to scripts/database/seed.sql and to scripts/audit-edition-boundary.mjs's FREE_FRAMEWORKS list, but not to shared/services.FreeFrameworkCodes — the list that actually exempts a framework from the compliance_frameworks_max cap. On the community, free and starter tiers (cap 0) a tenant could not activate either one. Both codes are now in FreeFrameworkCodes (and in the same list's second copy, services/compliance-engine/ee/content.FreeFrameworkCodes, now pinned against the shared one by its own test so the two cannot drift again). The parity test that is supposed to catch exactly this kind of drift, TestFreeFrameworkCodesMatchTheEditionBoundaryAudit, was also failing on main for an unrelated reason: an apostrophe in a comment the same PR added next to the two new codes ("the inventory's own two") broke its quote-matching regex, which read the apostrophe as a string delimiter and manufactured a bogus multi-line "code" out of the prose in between. The test now scans the array literal character-by-character with real string/comment state instead of a bare regex pair, so a comment's punctuation can no longer corrupt what it extracts.

  • tls_compression_enabled could not report enabled (asset-inventory workstream 3.6). The measurement answered false for every configuration that has ever existed: it uppercased the probe record and then searched the result for a lowercase literal, and the stored JSON renders a space the search did not expect — two independent reasons the check could not fire, in four lines. It now reads the value out of the probe record, and reports not assessed when the probe recorded nothing about compression. No shipped control targets this measurement type, so no compliance score moves.

  • A replayed PCAP and a live sensor now grade the same host the same way. pcap-processor wrote a flat confidence of 0.85 on every row it produced, while the sensor graded host observations 0.60–0.95 by how directly the device stated its own identity. So an LLDP advertisement worth 0.95 live arrived as 0.85 from a replay of the same capture, and a DNS answer worth 0.60 arrived as 0.85 as well — an auto-approval rule reading confidence >= 0.8 fired on a DNS answer it was never meant to. One ladder now, in shared/hostobs, called by both.

  • A large mDNS announcement is no longer reported as a malformed frame. The sensor caps captured payloads at 2 KB, and a printer advertising a dozen service types goes past that routinely; the reader refused the whole message and counted it as a parse fault, discarding the records that HAD parsed. It now keeps what parsed before the cut. A message that contradicts itself — a forward or self-referential compression pointer, a reserved label type — is still refused whole, because that is a different fact about the segment.

  • A clock step no longer stalls a sensor's host observations. The coalescer compared a wall-clock tick against a capture timestamp, so the length of its window depended on the drift between two clocks: an NTP step backwards froze expiry until the clock caught up, with subjects piling up and nothing anywhere saying why. Both ends read the same clock now, which also means the monotonic reading is present on both and a step in either direction cannot affect it.

  • The NetBIOS name reader bounds compression-pointer chains, as the DNS reader in the same package always has. Not exploitable at the sensor's payload cap; two parsers of one construct that disagree about what they refuse is how one of them turns out to be the wrong one.

  • Two nav items were drawing a question mark instead of their icon. The tenant UI's icon set is an explicit name → glyph map (a full-icon-library import once put ~935 kB in the bundle), and a name missing from it renders a neutral placeholder rather than failing — which is right in production and is exactly why nothing noticed that Inventory → Software and Inventory → Map both named icons that were never added. A test now walks the lens registry and asserts every icon it names can actually be drawn. A wider sweep found 24 more such names elsewhere in the UI; those are tracked separately.

  • An inferred relationship no longer became fact because an unrelated asset was approved. Approving an asset activates the pending relationships attached to it, on the principle that approving an asset approves what was observed about it. That reasoning covers measured and imported relationships; it does not cover ones the platform inferred, which are conclusions rather than observations and are supposed to appear in Approvals for a person to decide. They were being activated along with the rest, so a guess drawn while either end was still pending silently became a confirmed relationship that then counted in impact analysis — with nobody ever asked.

  • "What breaks if this dies" answered nothing for controllers and networks. Impact analysis followed every relationship the same way round — backwards, from the thing that depends on something to the thing it depends on. That is right for most of the vocabulary and exactly wrong for the two types that point the other way: a controller manages its access points and a virtual network contains its subnets, so asking what breaks when one of those dies returned an empty answer, while asking it of an access point returned the controller. It was backwards for precisely the kind of asset the question is usually about. Each relationship type now declares which way it is followed, and "what this depends on" is the exact mirror of "what depends on this".

  • A business service you declared was left out of the blast radius. Recording that an asset impacts a business service had no effect on impact analysis — the relationship was skipped, because the platform reserved that type for a conclusion of its own. Your own assertion now counts. The answer includes the service and stops there, rather than continuing into everything else the service touches.

  • Asking for zero hops quietly gave you two. depth=0 on the neighbourhood and impact reads fell through to the default instead of being refused, so a caller got a graph it had not asked for with nothing to indicate it. Out of range is now refused at both ends of the range, as it always was at the top.

  • A relationship could be confirmed against an asset nobody had approved. Accepting a proposed relationship only checked that the relationship itself was still undecided, not that both of the assets it joins had been admitted to the inventory. The Approvals queue never offers such a relationship, but the asset page's Relationships tab did: from an unapproved asset's own page the only other end it can see is the approved one, so Accept looked available and worked — producing a confirmed relationship to an asset nobody had let in, which then counted in impact analysis. Accepting now requires both ends to be approved and says which asset to approve first; rejecting is unaffected, since "this claim is wrong" is answerable whatever the assets are doing.

  • ServiceHints was outside the interrogation redaction backstop. deviceinterrogation.Sanitize masked PEM blocks in the device identity and in peer references but not in an asset's service_name / service_version, which the HTTP and TLS probers fill from a server banner and host inventory fills from the process holding a socket — free text whose origin is outside our control. Found by the host-inventory boundary test; the same redact.TextPEM rule now applies there.

  • Gate 1 review of the asset-inventory phase-1 stack. Nine review passes over the integrated branch — reads, identity wiring, frontend, device-interrogation, schema and RLS, removed-table residue, other services' readers, contracts and docs, and test adequacy — before any of it ships in core-v1.0.0. What they found, by theme:

  • A contested identity erased the proposal it told you to go read. Three intake paths returned the "these might be the same thing" outcome as an error from inside RunInTx, so the transaction rolled back and took the merge proposal with it — while the message said to review it in Approvals. The contested outcome now commits the proposal and is mapped to a 409 outside the transaction. A batch of discoveries no longer aborts on one contested finding either; the rest of the batch lands and the held one is counted.

  • A second sighting of an application could never match the first, so every poll INSERTed another merge proposal for the same question. OpenMergeProposal is idempotent now, and an application observed with its host resolves dependently against that host.
  • Every S3 bucket was inventoried as an application and every RDS instance as a server. Two tables answered "what class is this cloud resource" and neither knew the strings the collectors actually write. One table now, in shared/assetclass, with a guard that scans for those literals — an unrecognised resource type is a plain cloud_resource, because coarse and true beats a guessed leaf.
  • A merge moved one of the eight columns that point at an asset or an endpoint. The other seven are ON DELETE SET NULL, so nothing errored: the survivor simply showed no SSH host keys, no external connections, no interrogation jobs and no disk-encryption state for hardware it had just absorbed. The lists are data now, and a test reads the foreign keys out of schema.sql and fails in both directions.
  • PUT /infrastructure-assets/{id} accepted an identifiers array and dropped it, while rewriting hostname and address by raw SQL — so a renamed host kept its old identifiers, gained none for the new name, and was duplicated by the next sighting. Identifier edits go through the engine; a person may retire only what a person declared, and the response reports what was KEPT. The form shows collector-minted identifiers locked rather than hiding them.
  • A schema file that was fine on a fresh database could wedge an upgrade. A trigger's WHEN clause named a column added three thousand lines later in POST-MIGRATIONS; on any database still carrying the previous shape the file aborted with the DROP TRIGGER already committed. Invisible to a double-apply, whose two passes both build today's shape — so the new test builds the OLD shape deliberately and applies the file over it.
  • Several honest-looking empties were failed requests. A facet level that 500s no longer renders as "No sites recorded."; a failed merge-proposal read no longer renders as "Nothing awaiting review"; a failed saved-views read no longer invites you to create the views you already have. Each now says it could not load, and offers a retry.
  • The import wizard guessed an IP identifier out of any column with "ip" in the middle of a word — "Description", "Equipment", "Email Address". An identifier is not a mis-filed value: it is what the engine matches on. Identifier columns are matched on whole words only.
  • Counts and links that did not mean what they said: the merge queue reported its page size (50) as its total; the dashboard's "High-risk assets" and "Unscored assets" tiles linked to the unfiltered list; an asset nobody had assessed wore the same chip as one assessed and found clean; ⌘K advertised two retired lenses and none of the new ones.
  • The removed-table audit is strict and reads the SQL. It was advisory and Go-only, which left a hole the shape of the defect it exists to catch: a seed file filtering on the dropped asset_type column, inside a DO block with no exception handler, would have rolled back an entire demo load.

The parity ledger's phase-1 column is filled from the automated suites, with the tests named per journey and a note saying what that evidence does not cover: the fresh install on our own development cluster and the soak are still owed. One journey — elevating an external connection to an owned asset — stays unverified, because it has no test at any layer.

  • make lint and make standards-check now actually lint the Go code. Both ran golangci-lint from the repository root, which a go.work workspace with no root module cannot load: golangci-lint printed a typechecking error next to 0 issues. — a red make lint on 2.x, a false green on older releases — and the standards gate skipped the step whenever the tool was absent. Neither had ever linted a package. scripts/lint-go.sh now runs golangci-lint from inside every module go.work declares, with --max-same-issues=0 --max-issues-per-linter=0 so findings are never silently truncated, and treats a typecheck error as a failure whatever the exit status. Making the guard real surfaced 20 findings the diff-scoped CI gate had never seen — the device agent's audit logger dropped write and fsync errors (a lost audit event was invisible), five unchecked Body.Close calls, ten WriteString(fmt.Sprintf(...)), and a deprecated go-redis SetEx in the token-revocation path — all fixed.

  • ai.Redact was not idempotent, and the second pass corrupted the text. The redaction marker [redacted] ends in ], which the value grammar lists as a terminator, so redacting an already-redacted string read the value one byte short, wrote a fresh marker, and left the old bracket behind — [redacted]], then ]], growing by a byte on every pass. Two things were wrong with that: the audit digest of a prompt that had been through the boundary twice differed from the same prompt seen once, which is precisely what ADR-0008 D4.7's prompt hash exists to make answerable; and a caller that scrubbed early and indexed into the result — ai.Redact is exported so it can — found every offset shifted. redactText now recognises its own output and copies it through. (#1639)

  • CBOM comparison results are now returned in a deterministic order. The diff engine built its change list by iterating Go maps, so the change set was stable across recomputations and the order was not — contradicting the "compare the same pair tomorrow and get the same result" property the feature is documented on. Invisible while the client re-sorted anyway; load-bearing now that a narrative cites rows by position.

  • A comparison whose changes are all non-semantic no longer renders a summary with an empty breakdown. "Mixed posture change between …: ." — the neutral count was suppressed unless some other category was also present. It now reads "No posture change between …: 2 neutral."

  • A rack spare no longer produces a merge proposal on every scheduled import. A NetBox device with a serial, no address and a free-text name maps to no role, so it arrives as an unknown host — a class that did not recognise a serial. The first import created it and every import after it asked you whether the same untouched chassis was the asset it had just made. An unknown host now identifies by its serial number, so such a device is imported once and matched thereafter. assets_skipped no longer counts it, and it no longer appears in the drift view as "in NetBox, not in your inventory" — because it is in your inventory. To give it a real class, map its role under Settings → Integrations → your NetBox connection → Device-role mapping.

  • A device an asset class still cannot match on is not imported. One with no serial, no address and no DNS-shaped name, or one whose device-role mapping points at a class that does not identify by anything NetBox holds for it (a virtual machine or a cloud resource never identifies by serial). Those are skipped and counted, and show up in the drift view instead, where they belong.

  • testdb.NewTenant was the one un-retried write in the DB-integration harness. The schema-apply helpers (ApplySchema*) already wrap their work in RetryTransient (added for a nightly flake), but NewTenant's INSERT was a bare db.Exec: when another integration leg applied schema.sql concurrently (ACCESS EXCLUSIVE on tenants), Postgres's 40P01 deadlock detected failed the calling test outright instead of retrying — observed on admin-service/ee/msp under a concurrent make test-integration-db run, passing in isolation. Both the INSERT and the cleanup DELETE now go through RetryTransient.

  • A healthy 256-bit EC certificate counted as a crypto-risk "certificate issue." GetSummary's certificate-issue counter used a bare public_key_size < 2048, the RSA/finite-field floor, against every key family — while the crypto finding producer raised no weak_certificate finding for the same certificate. It now uses anyWeakKeySizeSQL (services/inventory-service/internal/services/crypto_risks_service.go), the same family-aware classifier the crypto_implementations key-size counter already used.

  • One asset was reported as assessed and clean while its finding was filed under another. crypto_implementations carries both an asset_id (the roll-up target written at ingest) and a nullable endpoint_id, and an endpoint can be re-homed to a different host without the configuration's asset_id being rewritten. The reader that answers "what is wrong with this asset" resolves the endpoint's asset first; the crypto producer read the bare asset_id. So for a moved endpoint, one producer pass recorded coverage of the OLD host — which then read as "assessed by crypto, nothing scored" — while the weak-configuration or quantum-vulnerability finding it raised was reachable only from the new one. Both sides now share a single definition of which asset a configuration belongs to, for configurations, certificates and keys alike.

  • Inventory → Software's "N vulnerabilities" and "end of life" links opened the wrong list. They carried the install (or the product) as a filter and landed on the Findings page's crypto-risk lenses, which read a different data set and ignore that filter — so the page showed an unfiltered crypto list under a banner claiming it was narrowed to one software install. The same defect was fixed for the Alerts page earlier in this cycle; these two were missed.

  • A finding on a cryptographic key or on a relationship between assets had no word for itself. The asset page's Findings tab printed the raw subject type ("crypto · key") and a remediation plan item printed only "finding", months after both became real subjects. Both now name every subject type the finding registry defines, and a test reads that registry so a new subject type cannot ship without a word for it.

  • Every Enterprise-only operation in the admin, auth and audit API specs is now marked as such. 97 operations documented routes that only an Enterprise build registers — the whole MSP tenant-lifecycle and self-service billing surface, SSO configuration, SIEM export — so the Core edition shipped documentation for capabilities it has no handler for. Four services now check their spec against their routes on every build, in both directions.

  • The published Core copy of the auth API spec was invalid YAML. A description that ended on a line beginning with # was mangled when internal issue references are stripped for publication, leaving a stray . that no parser would accept. Nothing had ever parsed the published specs; something does now.

  • The health endpoint of a service whose whole API is admin-plane now answers through the edge. GET /api/v1/tenant-health-service/health returned 404 on both the tenant host and the admin host on any Kubernetes install with an admin host configured: tenant-health-service is declared in the service registry's admin plane as a whole service, so the admin-plane routes (priority 900) also matched its /health path and outranked the per-service health carve-out (priority 150) — the request was denied on the tenant host and reached the backend with an un-rewritten path on the admin host. The carve-out is now generated above the admin plane, on both hosts, for every backend. Health carries no tenant data, so this is the same posture every other service already had. Pre-existing; also present in 0.12.x.

  • Actively enriched and PCAP-uploaded discoveries now auto-approve. The sensor's TLS enricher stamps its results active_enrichment and pcap-processor stamps pcap_upload; the approval evaluator's source mapping recognised neither, so every source:sensor segment rule silently skipped exactly the discoveries that carry the certificate chain and the measured protocol version, and every discovery from an uploaded capture — they piled up in Approvals on segments set to auto-approve. Both now map to sensor, each with a mutation-checked test alongside the earlier passive_host_observation one. Every other consumer of these values already handled both spellings; the approval evaluator was the one that did not.

  • The Dashboard's "Critical findings" tile counts every producer. It read /findings/statistics.severity_counts, which is scoped to the compliance producer — failed controls on activated frameworks — while labelling itself "across all assets" and linking at the By Framework lens. An organization whose only Critical findings were end-of-life or vulnerability ones read "0 critical findings" on the Dashboard and saw them on Risk & Compliance → Findings: the same Dashboard-vs-Findings divergence closed once before (H-2), recurring one producer later. /findings/statistics now also returns all_producer_severity_counts, built from findingListWhere — literally the WHERE behind GET /findings — so the tile and the page it links to (now By Producer) read one row universe by construction rather than by two hand-written expressions agreeing. The compliance-scoped rollup stays for the surfaces that are about framework compliance, and the framework licence gate still applies to compliance rows inside the wider one, so an unactivated framework's findings remain off the Dashboard. Two things the number is not, documented on the tile and in docsv4/core/features/findings.md rather than left to be discovered: it is the Critical rung only, so the page lists more rows than the tile counts, and it includes suppressed and resolved findings that the Findings page's default Open filter hides. Pinned by a cross-layer guard that fails if either rollup's producer scope changes without the tile's caption changing with it, and by a registry-driven count that requires one Critical per producer in standards/findings-registry.yaml.

  • An RSA certificate is no longer scored as static RSA key transport. The crypto finding producer resolved a certificate's public_key_algorithm — the bare string RSA — against the catalogue row of that exact code, which is RSA key transport (static): the TLS key-exchange assessment (weak, deprecated, risk 70). Every RSA certificate therefore raised a High weak_certificate finding whatever its modulus, an RSA-4096 root CA included, and because every host chains to its CA, every host in an estate rolled up to High — the RC-verification dataset reported 20/20 assets High with 18 of them carrying nothing above Low in their own configurations. A finite-field public key now resolves through its sized catalogue row (RSA-2048, RSA-4096, …), the same rule the key inventory already used. A size the catalogue does not carry gets no catalogue verdict for its key rather than borrowing the key-transport row (the SP 800-131A floor still reports a below-floor modulus); a family the catalogue does not size at all keeps its bare row, so a DSA certificate is still scored — and still reported as quantum-vulnerable — by the DSA row at every modulus. Five mutation-checked integration tests pin it. Existing installs re-score on the producer's next pass; the change is visible as assets moving from High to the band their own configurations earn.

  • The Dashboard's "Critical findings" tile and the page it opens now describe the same set. #1742 widened the tile's number to every producer but left two axes disagreeing with its destination, both documented at the time as caveats. Both are closed.

The tile counted every ACTIVE finding whatever its workflow status, so a Critical you had suppressed with a reason ("accepted, compensating control") stayed on an attention tile forever — and the Findings page opens on its Open filter, which hides exactly those rows, so the one finding driving the count was the one finding the destination would not show. The rollup now excludes Resolved and Suppressed, spliced from the findings registry's own definition of open (shared/findings.WorkflowOpenSQL) rather than a second hand-written predicate, so the count and the page's Open chip cannot drift apart. A finding you suppressed is off the tile, not out of the product: the Findings page has no separate Suppressed filter, so switching its Open chip to Critical + High brings it back with its status on the row.

The tile counts the Critical rung while the page listed every severity and had no severity URL parameter, so clicking "40 critical findings" opened a list of several hundred rows with nothing saying which forty were meant — the failure dashboard-metrics.ts already names for the High-risk assets tile ("a tile that counts a subset must link to that subset"). The Findings page now reads ?severity=critical|high|medium|low, applies it on the server (the page caps at five pages of 200, so a client-side narrowing would under-report a large tenant), and renders it as a clearable banner, because a filter arriving from a link is one the reader never chose. The page's own Critical + High chip and the URL filter are one axis, so picking the chip clears the parameter instead of intersecting with it.

Guards on both halves, mutation-tested in both polarities: TestDashboardCriticalTile_RouteCarriesItsNarrowings fails if the route stops naming the rung the tile reads, and TestSeverityRollupScopes_AreWhatTheirNamesSay fails if the rollup starts counting suppressed rows again.

  • /findings/statistics.severity_counts is recorded as contract-only. The Dashboard was its only consumer and moved to all_producer_severity_counts; the field stays in the published contract (removing it would be breaking) but is now documented as unverified by any screen.
  • ECDSA certificates and keys are classified for post-quantum migration. A certificate's public-key algorithm is the bare family name crypto/x509 reports — "RSA", "ECDSA", "Ed25519" — and the crypto producer resolves it against the algorithm catalogue to decide whether the key is Shor-breakable. The catalogue had no bare ECDSA row (only the ECDSA-with-hash pairings and the SSH host-key names, neither of which assesses an EC public key as such), so an ECDSA certificate resolved to nothing, was left unclassified and raised no crypto/pqc_vulnerable finding: on one tenant 16 RSA certificates raised 16 findings beside 6 ECDSA certificates raising none, while NIST IR 8547 puts both on the same deprecation clock. The same six keys sat in the Keys lens with no algorithm, because the key producer asks the catalogue for the same code. The catalogue now carries ECDSA as a bare signature row (strong, current, risk 15, the counterpart of DSA), the seed backfills the algorithm onto EC keys already recorded without one, and the signature component of every TLS_ECDHE_ECDSA_* suite — previously "ambiguous" and left unlinked — links to it. ECDSA certificates also pick up the row's catalogue verdict as a Low weak_certificate finding, exactly as RSA-4096 ones already did.
  • One Ed25519 catalogue row, not two. Ed25519 and ED25519 both existed, with different risk scores; the code column is unique only case-sensitively while every lookup is case-insensitive, so which row rated an Ed25519 certificate depended on scan order. Ed25519 (the RFC 8410 / crypto/x509 spelling) is kept and takes over the retired row's OID and guidance; the seed re-points every reference before deleting the duplicate, so an upgrade of a database that linked it does not trip a foreign key. The catalogue consistency test now fails on any pair of codes that fold to one.
  • A key the catalogue cannot size or spell is still classified for post-quantum migration. Whether a key is Shor-breakable is a property of its FAMILY — true of RSA at every modulus and of ECDSA on every curve — while the catalogue row a key resolves to answers a different question, how risky its SIZE is. Deriving both from the one row meant a key dropped out of the migration queue whenever no row matched it: an RSA certificate at a modulus the catalogue does not carry (RSA-1536, RSA-8192) raised no crypto/pqc_vulnerable finding, and neither did a key in the Keys lens whose size could not be read at all. The producer now resolves the family taxonomy the catalogue already carries (algorithm_family + primitive) alongside the row, and falls back to it for the public key alone when no row of its own answered. It remains the same NIST IR 8547 denylist of Shor-breakable primitives, applied to a family instead of a row — a family the catalogue does not know stays UNCLASSIFIED rather than being assumed safe, and symmetric and post-quantum families (AES, SHA-2, ML-DSA, ML-KEM) are untouched. A family verdict is deliberately not a measurement: it never scores a certificate, because knowing a key is RSA is not knowing what its modulus is worth.

  • Self-signup returned HTTP 500 for most real company names. The tenant slug replaced spaces and nothing else, so any name carrying a character outside [a-z0-9 -] produced a value the database's CHECK (slug ~ '^[a-z0-9-]+$') rejected — and signup is the only way into a Core install. Acme, Inc., O'Brien Ltd and Müller GmbH all failed. The derivation now folds any run of other characters into a single dash, trims the ends, and appends a block of the tenant's UUID so the two constraints the old code never considered — UNIQUE and varchar(100) — are guaranteed rather than hoped for: two tenants may legitimately share a display name, and a name that slugifies to nothing (日本, !!!) still yields a valid slug instead of a 500. Found by rc-verify, whose own per-release tenant is named after the version it is verifying.

  • A blank entitlement value no longer means "unlimited". Every entitlement value — a catalogue default, a tier's composition, or a per-tenant exception — is now validated against its item's kind before it is written: a boolean gate is {"enabled": true|false}, a capacity is {"quantity": N} with N a whole number ≥ 0 or an explicit null for unlimited, a choice is a non-empty string. An omitted or empty value used to be stored as {}, which the resolver treated as a present value and the quantity reader parsed as unlimited — so a composer cell left blank, or a request that left the value out, granted unlimited capacity from whichever layer outranked the tenant's real plan. The same item named twice in one composition is now a 400 naming the key instead of a swallowed database error, the admin forms refuse the inputs the API refuses (abc used to become null and therefore unlimited), and a pre-existing malformed row now fails closed rather than open. (#1775)

  • Platform collectors no longer consume the tenant's sensor cap. Every tenant is provisioned two platform-managed collectors at creation. The sensor cap counted them while the Usage & Limits page never did, so the gate charged two slots the meter did not show — and on a plan allowing one sensor the tenant could never register a real one. Registration now counts only sensors the tenant registered, as the page always has. (#1776)

  • A connector import no longer proceeds when the plan check itself fails. NetBox import and CMDB pull treated an error from the asset-cap check as permission and carried on past the plan limit; both now stop and report the error, as manual creation and spreadsheet import already did. Restoring a soft-deleted asset and elevating a third-party connection into a managed asset — both of which raise the enforced count exactly as a create does — now go through the same cap. (#1776)

  • Two views stopped describing a plan by columns nothing writes. The feature-availability endpoint and the plan impact analysis both read the legacy per-plan limit columns, which the plan editor never updates, so for any operator-authored plan the analysis reported "no change, no tenant affected" whatever the composition did. Both now read what the plan actually grants — the same resolved entitlements that gate the tenants — and the analysis counts usage the way the gates do: pending invitations hold a seat, platform collectors are not the tenant's sensors. (#1777)

  • The MSP tenant-settings endpoint now works at all. Its optimistic-locking WHERE clause left Postgres unable to infer the type of one parameter, so every real call failed at query-parse time with a 500 — whether or not a version was supplied. Nothing called the endpoint, which is why a fault this total went unnoticed; it is fixed with an explicit cast, and the new tenant Settings tab above is its first working caller.

  • Retired the MSP tenant-settings frameworkId field. It wrote a "default compliance framework" key into the shared tenant settings document that no code anywhere read back — the authoritative default framework has lived on the tenant's framework licences in compliance-engine since the evaluation rework. Existing stored values are left in place, unread; the GET no longer returns the field and the PUT no longer accepts it.

  • The MSP tenant-settings save no longer wipes a tenant's other settings. admin-service's MSP PUT .../tenants/:id/settings replaced the entire tenant_admin_settings.config document with just the fields the MSP form itself owns (frameworkId, email_verification_required, onboarding_required) — so a platform admin saving any one of those silently deleted the tenant's discovery_auto_scan policy, drift baseline window and identity auto-accept threshold, each owned and written by a different service. The audit trigger recorded the wipe but nothing prevented it. The save is now a top-level jsonb merge (config || EXCLUDED.config) that only ever touches the keys the request actually sets — an absent field leaves the stored value untouched rather than nulling it out. See services/admin-service/ee/msp/tenant_settings.go.

  • cluster-sensor-service could not read back a discovery job the platform created on its own initiative (created_by IS NULL — every HMAC service-auth caller, including the automatic active-scan sweep), nor one that named a sensor, nor list jobs at all, so such jobs would have sat in queued forever with the stuck-job sweep republishing them to the same failure every 30 seconds. All three reads are fixed. (#1839)

  • Risk, cryptographic strength, compliance, health and confidence now retain their own units and shared definitions. Crypto findings inspect every relevant component instead of treating a high catalogue score as proof of weakness; unscored CVEs and unresolved evidence remain visible. Compliance/hygiene colours retain their existing thresholds. External strengths use a nullable canonical vocabulary with preserved historical values, control writes use lowercase severities, and new catalogue rows require explicit scores. See the rating upgrade guide for coordinated schema/client changes, reassessment and evidence limitations.

  • A denied asset's last-seen no longer freezes the moment it is denied. Re-observation ingest hard-filtered a denied asset BEFORE the observation ever reached the identification engine, so its last_seen_at (and every operational surface ordered by it) never moved again however many times a sensor kept reporting it — even though the same re-observation correctly keeps archived assets' last-seen current. A denied asset still stays denied and its crypto is still never (re-)materialized; it now also still advances its last-seen, via a minimal touch that deliberately does not run the full identity resolve — a denied asset gains no new identifiers, no new endpoints and no reclassified ownership, only the honest fact that it is still being seen.

  • sensor_discoveries.approval_status no longer says "pending" forever on rows no human will ever act on. Two classes of processed row sat pending indefinitely: host observations (identity evidence, never a crypto finding awaiting approval) and findings that matched an asset already archived or denied (nothing is materialized for either, and no approval decision follows). Both now land on a terminal value — observed and suppressed respectively — set at write time going forward and backfilled for existing rows on upgrade. cluster-sensor-service's per-job materialization summary no longer counts either as "awaiting approval" (previously approval_status <> 'auto_approved', now = 'pending'), and the Discover wizard states suppressed findings ("N on denied or archived assets — not shown in Approvals") instead of silently folding them into "awaiting approval" or a shortfall nobody could explain.

  • Reverse-DNS hostname resolution for a sensor discovery now only runs against a public destination address. For a private/LAN address the cluster's own resolver is either unreachable for that address or answers a different network's idea of it — and is weaker evidence than the sensor's own passive mDNS/NBNS/LLDP observations besides, for exactly the population (LAN hosts) this affected most. The fabricated-cluster-answer rejection (#1804) is unchanged and still applies to the public case; sensor-side passive DNS capture is a separate code path and is unaffected.

  • A partially observed crypto configuration is now completed by the full observation instead of sitting beside it. A passive sensor that decodes only the protocol of a handshake wrote a configuration with every component empty; the active probe that followed measured the full handshake and, because the two fingerprints differed, added a second configuration for the same endpoint that never superseded the first. The asset page listed both, the inventory's configuration count included both, and the CBOM shipped an empty component beside the real one. Ingest now recognises a live configuration whose measured components all agree with a fuller observation as the same configuration seen less completely, fills it in place (keeping its first-seen date, certificate and links) and re-scores it exactly as a fresh one; a less complete re-observation of a configuration already held is treated as a re-observation, never a new row. A genuinely conflicting pair — TLS 1.2 seen passively, TLS 1.3 seen actively — stays two configurations. Each configuration now records every discovery method that contributed to it (discovery_methods, shown as provenance chips on the asset page's Cryptography tab), and the schema reconciles pairs written before this fix on upgrade: the partial row is retired (soft-deleted), its certificate and algorithm links and tickets move to the complete row, and the complete row inherits the earlier first-seen date.

  • A floating address no longer proposes merging the service at it with the node announcing it. MetalLB in L2 mode, kube-vip, keepalived, Windows NLB, VRRP, HSRP and CARP all announce a virtual address by gratuitous ARP from whichever node currently holds it. The sensor decoded that correctly — the node's MAC at the floating address — and the identification engine, finding the MAC on the node's asset and the address on the service's, called it a cross-kind conflict and opened a merge proposal (scored 0.84, because a MAC is a strong identifier), again after every "keep separate". Three changes, one rule each: (1) an observation that is only a measured MAC-to-address binding — no name, host key, serial, agent or cloud id — whose MAC and address resolve to two different assets is now resolved to the address's asset with the MAC deliberately left unattached, recorded as a hosted_on relationship (service → node) carrying the evidence, and opens no proposal; an observation carrying the node's MAC plus a name or stronger identifier of the other asset is still the genuine conflict it always was. (2) A reviewer's "keep separate" is now read back: a conflict between assets a reviewer already kept apart, on the same kinds of evidence, is resolved without re-asking (to the earlier proposal's own observation asset when it had one, otherwise to the asset the weaker evidence names), the history entry names the honoured proposal, and the ingest log says "kept separate on "; new evidence of a kind the reviewer never saw reopens the question. A question already pending is also no longer re-noted in history on every observation that re-asks it. (3) VRRP/CARP (00:00:5e:00:01:xx, 00:00:5e:00:02:xx), HSRP (00:00:0c:07:ac:xx, 00:00:0c:9f:fx:xx) and GLBP (00:07:b4:xx:xx:xx) virtual router MACs — which move between routers with the address — are no longer used as identifiers, like locally-administered MACs; the protocol is kept as an attribute.
  • MCP OAuth discovery now points somewhere. The MCP endpoint's 401 answered WWW-Authenticate: Bearer resource_metadata="" — an empty pointer, from which a client learns nothing and abandons the flow — and /.well-known/oauth-protected-resource was not routed at all, so it fell through to the web-UI catch-all and returned the app's index.html with HTTP
  • A client following the chain received HTML where it expects JSON, with a success status hiding it. mcp-service now serves RFC 9728 protected-resource metadata at both the well-known root and the RFC 9728 §3.1 path-suffixed URI, the chart routes those paths to it ahead of the UI catch-all, and the 401 carries the document's absolute URL. Note this is necessary but not sufficient for hosted clients: Vista's authorization server still does not implement RFC 7591 dynamic client registration, so Claude.ai, ChatGPT and Claude Code cannot complete automatic sign-in and must use a Personal Access Token.
  • The sensor's heartbeat now reports config_running — the effective values of every control-plane-managed setting (active_probing, network_discovery, host_observation, host_observation_window_seconds, host_observation_dns, dedup_ttl_minutes, reporting_interval_seconds, log_level) seeded from its own configuration file. Without it, sensor-manager's platform bootstrap had nothing to adopt on a sensor's first report, so a sensor with locally customised settings would be silently reset to built-in defaults on its next beat.
  • An agent's first heartbeat no longer switches off what its configuration file turned on. Control-plane-managed settings (#1785) resolved to their built-in defaults for any device nobody had configured, so an agent started with HOST_INVENTORY_ENABLED=true was handed false about a minute later and stopped collecting for good; poll and heartbeat cadences reset the same way. A device now reports the settings it is running, and the first report — that one only — is adopted as its own override where nothing else is set and the value differs from the built-in default. The revision it receives therefore describes what it is already doing, an operator sees the real starting position in the console instead of a default nobody chose, and a device running the defaults still inherits any fleet default set later. Recorded in the audit trail under its own bootstrap scope with no acting user, because nobody made the change.
  • External connections no longer count certificate-hygiene observations (a missing Signed Certificate Timestamp, an untrusted or self-issued/pinned CA, an incomplete chain, a missing Subject DN) as cryptographic weakness. A TLS 1.3 / AES-256-GCM connection to a trusted endpoint could previously be flagged "weak crypto" on the dashboard's External Exposure panel for no reason other than a missing SCT. Those observations are now recorded separately (cert_hygiene_flags) instead of folding into weak_reasons/ crypto_strength. Also extends Signed Certificate Timestamp detection to all three RFC 6962 delivery routes (embedded, TLS extension, OCSP) via a new cert_sct_source field on both external_connections and certificates, where previously only the embedded route was checked.
  • Actively probed SSH servers now report their key exchange, cipher, MAC and host-key algorithms. The active probe completed a key exchange with a library that exposes no algorithm names, so an SSH crypto configuration reached the inventory with only a banner and a host key type: key_exchange_algorithm, symmetric_encryption and hash_algorithm stayed NULL, nothing resolved against the algorithm catalogue, and every SSH asset scored 0 — which the product means as "not assessed" — however it was configured. The shared prober now reads the server's SSH_MSG_KEXINIT in cleartext before any key exchange, records the offered name-lists verbatim and derives the negotiated algorithm per RFC 4253 §7.1. Weak offers (diffie-hellman-group1-sha1, CBC and 3DES ciphers, hmac-md5, SHA-1 ssh-rsa host keys) are linked as inferred, so worst-component-wins scoring reports what a server will agree to rather than only what it agreed with us. Reading KEXINIT needs no algorithm in common, so a legacy-only appliance — previously reduced to a banner — is now inventoried in full. The standalone sensor and the in-cluster Platform Sensor share the probe and both carry the result.

  • SSH banners no longer carry a binary packet. The banner-only fallback read a fixed-size blob rather than the identification line, and an SSH server sends its SSH_MSG_KEXINIT immediately behind that line — usually in the same TCP segment — so algorithm name-lists, a cookie and random padding were stored as the device's banner and shown in the UI. The read now stops at the line terminator RFC 4253 §4.2 defines.

  • Algorithm catalogue: five SSH names a current sshd offers. Added sntrup761x25519-sha512 (the standardised spelling OpenSSH offers from 9.9), mlkem768nistp256-sha256 (post-quantum hybrid, flagged is_pqc), curve448-sha512, hmac-sha1-96-etm@openssh.com and hmac-md5-etm@openssh.com. Without a catalogue row these resolved to nothing and the component was recorded as unassessed.

  • An active-scan target that was an IP literal duplicated the endpoint. When an intake path did not know a listener's name it could write the scan target into fqdn even when the target was an address — it has dots, so it passed a naive "contains a dot" check — producing a second asset_endpoints row for a listener already recorded with fqdn NULL, each with its own crypto configuration and findings. An IP is never a name: it is now dropped from fqdn at the source (the active-scan finding builder) and at the funnel (identity.EndpointObservation.Sanitized), and an address-bearing endpoint is now matched on (address, port, transport) rather than the full tuple including fqdn, so a differently-named re-observation of the same listener updates the existing row instead of inserting a second one. Existing duplicate rows on upgraded installs are merged by a one-time reconcile, keeping the oldest row and re-pointing its crypto configurations, external connections and SSH keys. The reconcile never keeps an IP literal as the survivor's name: the losing rows the defect wrote usually hold the address spelled as text, so backfilling one would have re-created the very value this release stops writing — permanently, because a name is only ever filled onto a row that has none. A real name among the duplicates wins, a survivor carrying the literal itself is corrected, and a lone row that has only the literal is cleared.
  • discovery-processor-service's reverse-DNS enrichment ran from inside a cluster pod, whose resolver synthesises PTR answers for in-cluster IPs (<a>-<b>-<c>-<d>.<namespace>.svc.cluster.local and similar) — a customer host's real address could come back as a Kubernetes-internal name nothing on the customer's network ever answers to. reverseDNSLookup now rejects cluster-internal answers and returns "" instead, so the asset keeps its address as its display name until a real name arrives.
  • Several device-interrogation collectors (UniFi managed devices and VPN networks, Fortinet IPSec tunnels and SSL VPN, PAN-OS SSL-decrypt profiles and security rules, F5 virtual servers, SNMP sysName) copied a vendor display/config label straight into the asset's hostname field. A label like "U6+ Living Room" or "Back Porch #1" is not a DNS name, so the identity layer rejected it as an invalid hostname identifier on every run — losing the identifier and flooding the log with the same reject. Collectors now keep the raw label in asset metadata for display and only promote it to hostname when it is a valid DNS name.
  • Third-party TLS connections with no reverse-DNS record (most cloud/CDN IPs) now show the hostname the client's TLS ClientHello actually requested (SNI) instead of a bare IP:port in Inventory → 3rd Party — sensor-manager and discovery-processor now fall back to the captured SNI before reverse DNS, and external_connections.dest_hostname fills from a later observation without ever being blanked by one that carries no hostname.
  • Ingest now defers a finding's certificates and crypto configuration on the status the matched asset has, not the one the batch asked for. A discovery row that matched no auto-approval rule — the same host seen on an address in no registered segment, a TLS endpoint reported by device interrogation — arrives pending_approval, and when it landed on an asset already monitoring its crypto was parked in assets.metadata->'deferred_findings', which only an approval of a pending asset ever replays. It is materialized immediately instead, and the import response reports the effective status per finding so discovery-processor stamps those sensor_discoveries rows auto_approved rather than leaving them pending where nothing surfaces them.
  • A discovery no longer un-archives an asset. A device the tenant archived and that later reappeared on a segment carrying an auto-approval rule was promoted back to monitoring — with an approved history entry naming no person — and its certificates and crypto configuration written into the live inventory. Archiving is now respected the way denied always was: the status is left alone and the finding is neither materialized nor deferred.
  • Sensor heartbeat discoveries_made and errors_count were not trustworthy. discoveries_made read the length of the in-memory batch buffer, which drains every report interval — a sensor that had accepted hundreds of discoveries over its lifetime reported discoveries_made: 0 on every heartbeat after its first successful submission, making an active sensor look idle on the sensor detail drawer. errors_count was hardcoded to 0. Both are now cumulative, process-lifetime counters: discoveries_made increments when a discovery is accepted into a submit batch (not on successful upload, so outages don't hide capture activity), and errors_count increments at the sensor's existing capture/submission/heartbeat/registration failure log sites.
  • Reflected mDNS no longer inventories the gateway as every host on every other VLAN. The sensor attributed every mDNS response to the frame's sender MAC, and a router running an mDNS reflector (UniFi, Avahi, Bonjour gateways) re-originates other hosts' announcements from its own MAC. A response now counts as the sender's only when an A/AAAA record names the frame's source address; otherwise it is hearsay about the named host, carries no MAC, and is graded like a DNS answer. The consumer files .local names as segment-scoped hostnames rather than globally unique FQDNs, so a reflected copy of a name can no longer absorb the host itself from another segment.
  • A platform-sensor scan that asked for TLS and SSH no longer records "SSH on 443" with TLS data. A finding created for one requested protocol only receives that protocol's probe, and a protocol refuted by its own probe on a port it is not known on records no finding instead of an empty crypto configuration.
  • A host inventory the platform could not materialise no longer reads as a success. The Job Logs line leads with the consumer's error instead of the listener count it never wrote, and the agent card's "Last host inventory" ignores failed runs.
  • A name typed on the Devices page is now the asset's display label. Registering a device that the sensor had already observed kept the sensor's measured name as the label; the declared name now wins, while an address typed into the same field still only fills an empty label.

Removed

  • measurement_types.extraction_query (asset-inventory workstream 3.6, ADR-0005 D5). The column stored the SQL a measurement type "runs". Nothing ever executed it, and what a measurement type reads is now declared in standards/measurement-types.yaml. A seeded query is an injection hazard and an upgrade hazard at once, and a column holding SQL that nothing runs is a standing invitation for something to start running it. Dropped from the schema, the API and the generated client.

  • compliance_findings is dropped. There is one findings table, and every producer writes to it. Asset-inventory workstream 3.1 (ADR-0005 D3): the compliance producer's rows now carry producer = 'compliance' and kind = 'control_noncompliant' in the unified table alongside the crypto, end-of-life, vulnerability, configuration, hygiene and drift producers that follow it. No view, no backfill, no dual-write — ADR-0007's no-migration decision applies, the old table is dropped in POST-MIGRATIONS with DROP TABLE IF EXISTS ... CASCADE, and scripts/audit-removed-tables.mjs fails make audit on any Go or SQL that still names it.

What changed in the shape, and why each one had to:

  • Subject, not asset. (asset_id, asset_type) and its three-value CHECK become (subject_id, subject_type) from the registry's vocabulary, and the three values map onto two: network_asset → asset, crypto_implementation → asset, certificate → certificate. The old asset_type named the measurement KIND, not the type of the id beside it — asset_id was always the asset's for the first two, because a cryptographic configuration measurement is scoped and selected per asset. On live data about three in four of those ids were a certificate's, which is why the old name described the minority case, why the posture grid dropped three findings in four into an "unattributed" row, and why a certificate finding rendered a raw UUID where a hostname belonged.

    The configurations a measurement was taken over are not lost: they are listed on the finding as evidence.crypto_implementation_ids, which is what the inspector links through and what a ticket copies into crypto_implementation_id when there is exactly one. crypto_configuration remains a valid finding subject, reserved for the configuration producer that measures one at a time. - Severity is the registry's lowercase ladder (info/low/medium/ high/critical), not Low/Med/High/Critical. Med had no spelling anything else in the product used. Control authors still type High, so the writer normalizes and the database holds one vocabulary. - Identity gained control_id, with NULLS NOT DISTINCT: a compliance finding is one per (control, subject) pair, so two controls failing on one server are two things to triage. Without it the second control's write reaches the first's row through ON CONFLICT and overwrites it — a lost violation with no error. NULLS NOT DISTINCT keeps the dedup on for the six producers that leave control_id NULL, which Postgres would otherwise treat as always-distinct. - The workflow columns came with it — assignment, remediation notes, resurfaced-at, staleness. None is compliance-shaped; they are the finding LIFECYCLE, which is the half of the old table that generalised. - compliance_finding_history, remediation_plan_items and tickets keep their finding_id, re-pointed at findings(id). A ticket whose finding is gone survives with the link cleared, because the ticket is the user's work.

  • The port-as-asset tables, and the asset_type enum, are dropped — with no migration and no compatibility view. network_assets (the view, the partitioned parent and its eight partitions), devices, and the four-value asset_type enum are removed from the schema body and dropped in POST-MIGRATIONS with DROP TABLE / DROP TYPE IF EXISTS ... CASCADE. Links are re-parented rather than bridged: crypto_implementations.endpoint_id, external_connections.source_endpoint_id and ssh_keys.endpoint_id now point at the endpoint, while device_jobs and database_encryption_states point at the asset.

The owner established (2026-09-11) that there are no installs of the current line to carry forward — the public repository has no released version on this model, and our own development cluster is rebuilt from the published chart — so a compatibility view would have been a layer maintained for nobody, and every reader was rewritten in place instead. make audit runs scripts/audit-removed-tables.mjs --strict unconditionally so a query naming one of these cannot reach main: a dropped relation does not fail to build, it fails at runtime, per query, and this codebase has form for swallowing exactly that error behind a 200.

The scopes and auto-approval-rule JSON predicate columns go the same way, replaced by a query text column holding a query-language string.

  • The legacy per-field asset filters are gone from the MCP tool surface, with no deprecation window: query_assets takes a query string, and sending one of the old filters is a schema violation refused before any backend call. The REST API keeps them for one release, translated into the language server-side.

  • GET and PUT /api/v1/inventory-service/discovery/capabilities (and their /api/v2 twins), together with the capability_policy key they wrote into tenant settings. They served the retired web-ui discovery wizard; no current UI, MCP tool or QA tool called them, and no discovery path enforced the key. The two real switches are unchanged: Settings → Discovery → Active Scanning for automatic scans, and the per-job active_scanning option a discovery job carries. The MSP tenant-settings endpoints in admin-service no longer echo or accept capability_policy.

Security

  • npm: uuid pinned to ≥ 11.1.1 through a root overrides entry (GHSA-w5hq-g745-h8pq, missing buffer bounds check in v3/v5/v6). It reaches the tree only as exceljs's dependency and exceljs calls v4 without a buffer, so shipped behaviour is unchanged; the lockfile no longer carries the vulnerable version.

  • The AI provider transport followed a redirect off its own origin, re-sending the prompt. A model endpoint that is compromised, misconfigured, or reached through a poisoned resolver could answer 307/308 with a Location anywhere; Go re-sends the POST body, so the whole prompt — system prompt, question, and the grounding rows the redaction boundary had just decided were safe to send to THAT provider — was delivered to a host the endpoint chose, with the call still succeeding. Under AI_ALLOW_PRIVATE_ENDPOINTS (a self-hosted model, the flag's whole purpose) the client has no dial guard at all, so the target could be the cloud metadata endpoint. The transport now refuses any redirect that leaves the configured endpoint's scheme+host, the same decision the NetBox connector already made for itself; a same-origin redirect is still followed, because a reverse proxy normalising a path is legitimate.

  • Every upload cap in the platform was preceded by an unbounded read. The audit middleware, which runs ahead of every handler in all sixteen services, read each request body into a buffer and never used the bytes. The SBOM upload's 32 MiB ceiling and the host-inventory intake's were therefore applied to a body that was already resident, so an authenticated caller ignoring them could make any service hold whatever the edge forwards, per concurrent request, before a handler line ran. The read is gone; both caps now bind.

  • POST /ask and POST …/draft-controls decoded unbounded bodies before applying their published caps. Both checked their limit — 4096 characters for a question, 64 KiB for a pasted standard — on a string the JSON decoder had already built, so the cap chose which error the caller got and said nothing about the allocation. Both now bound the body first and answer 413 naming the published cap, with headroom for JSON escaping so a legal request never meets the transport bound. The drafting endpoint's published 64 KiB cap was also a property of one Author implementation rather than of the endpoint — it lived inside the Enterprise drafter, so any other seam (the null one, a future local model) received whatever got past the transport bound, eight times the number the error message names. It is now checked at the seam, before dispatch.

  • passcode and a bare pin are credentials the redactor did not recognise. Neither matched a fragment nor ended in "key". Added, with snmp_version, pinning and pinned_certificate pinned as must-survive so the fix cannot become the over-strict version of the same bug. (This finding also covered SNMP community strings, which gate 4 closed independently and better, with a suffix rule rather than the bare fragment X.5 proposed — communityGalleryImageId is an Azure image id and is posture.)

  • The resource-tracking middleware drained every request body too, so the fix above did not bind. Six services — auth, admin, cbom, compliance-engine, inventory-service and sensor-manager — register resourcetracking.Middleware BEFORE the audit middleware, and it read each body into a buffer for the same stated reason and threw the bytes away the same way. Removing the audit read alone therefore changed nothing for the SBOM upload's 32 MiB cap, the catalogue bundle's, /ask's or draft-controls'. It now counts the bytes the handler reads through a transparent wrapper instead of buffering them, so the usage meter reports the same number and every downstream MaxBytesReader finally binds.

  • Two settings on one page asked for two different permissions. PUT /settings/identification (the auto-merge threshold) was gated on assets.update while its sibling card on the same Settings page, the drift baseline, was gated on settings.update — so a role granted one and not the other could edit half a page, with nothing on screen saying why. The threshold now requires both: settings.update because it is a settings page, and assets.update because the threshold authorises the platform to merge two of your assets without asking, which is the same act as accepting a merge proposal by hand. Swapping one for the other would have let settings.update alone authorise asset rewriting.

  • Owner email addresses no longer cross the AI provider boundary. owner_email was on the allowlist of asset-row fields sent to a model for the narration turn. The redactor does not touch it — an address is not secret-shaped — so the allowlist WAS the decision, and it was one a field list implied rather than stated. It is projected out, and the ai-assistant page now says in so many words what an asset row carries when it is sent. The capability is unaffected: exists(owner_email) is a predicate and runs in Postgres, so "which production servers have no owner?" is still answered — what the model no longer sees is the address it could only have repeated back.

  • Accepting a drafted remediation plan now records the server's provenance, and bounds the plan. POST /findings/:id/remediation/accept stamped the row inferred with a source_ref built from the model id in the REQUEST BODY, so the one piece of provenance the server could have stood behind was a caller assertion — any client could claim any model, of any length. The row now names the model this deployment is configured with, or remediator:model when it pins none. steps and summary were also unbounded, and are rendered into the plan item's notes; they are now held to the same limits that bound what the drafter may propose (12 steps, 600 characters each), refused rather than truncated. A plan this platform drafted always fits.

  • A human's decision on a merge, class or relationship proposal is now audited by name. The accept and reject routes carried no explicit audit entry, so the record said "POST /approvals/classes//accept, 200" and not which class was applied to which asset. Note the asymmetry that made it worth closing: the MACHINE path (an auto-accepted merge) writes a rich record — score, model, candidates, reason — precisely because nobody is there to ask, while the path with a person behind it wrote less.

  • Three audit events in this build were being discarded by the database. audit.activity_logs.event_category carries a CHECK constraint, and data_modification (the auto-accepted merge) and configuration (both tenant settings handlers, and the algorithm-catalogue writes) are not in it — so every one of those entries was rejected on INSERT with nothing visible to the caller: the helper discards the error, the write is batched, and the flush is a background goroutine talking to another service. Two of the shared audit package's own published category constants were values the database rejects, which is where configuration came from. The constants are corrected, the call sites use them, LogActivity now refuses an unstorable category loudly instead of letting it vanish, and two tests hold the line: one derives the valid set from the CHECK in schema.sql, the other scans inventory-service's own call sites.

  • A statement ceiling on every read that runs a tenant's own query. The asset list, the facet rail and the stale list splice a caller's query-language predicate into their WHERE clause, and no deployment set a statement_timeout; context cancellation was the only backstop, which sends a cancel request on a second connection and leaves the statement running until the backend notices. Those reads now open their transaction with SET LOCAL statement_timeout, five seconds, reverting with the transaction so it cannot leak onto a pooled connection.

Honest about what prompted it: the review expected a catastrophic regex ((a+)+) to be the way in, and that was measured and does not reproduce — PostgreSQL's ARE is a hybrid DFA and returned in under a millisecond on every pattern tried. The ceiling is kept as a general bound on a predicate a tenant controls, not as a fix for a demonstrated blow-up, and the tests say so.

  • The findings evidence rail has a redaction backstop. Writer.Upsert wrote Finding.Evidence verbatim into findings.evidence, a jsonb the UI renders and the remediation drafter reads, for every producer. Every other rail in the platform has a backstop and this one — the single chokepoint for six producers — had none. Nothing leaks through it today; the point is the next producer. A key the redactor has explicitly NAMED as a credential is now masked, and PEM private-key blocks are masked inside string values wherever they appear. Deliberately NOT the ordinary redactor: its catch-all treats anything ending in "key" as material, and the drift producer's observation_key is the finding's own identity. Masking that would have broken a producer silently, which is the same bug pointed the other way.

These twelve come from the general asset inventory's cross-cutting security review (BUILD_PLAN X.5), whose full findings — including what was reported for the owner rather than fixed — are in docsv4/internal/developer/design/asset-inventory/SECURITY_REVIEW_X5.md.

Documentation

  • Added a Concepts page (docsv4/core/concepts.md) defining the vocabulary the platform is adopting as it grows from a cryptographic inventory into a general asset inventory: asset, endpoint, class, identifier, relationship, fact, finding, risk, alert, provenance, approval, query, scope, and snapshot. Linked from the Platform Overview and the customer docs index. First deliverable of the general asset inventory initiative's Phase 0 (workstream 0.7); no product behavior changed.

  • Audit logging guide gains an "Upgrading" note (docsv4/core/guides/audit-logging.md) explaining that rows recorded before the #1699 event-category fix keep event_category = system and an event type of the form system.<service>.<action> — an alert rule or SIEM filter written against either shape matches only the old rows going forward.

  • Product-descriptor docs corrected to the current nav label. CBOM generation moved from "Risk & Compliance → CBOM" to "Risk & Compliance → Bills of Materials" when the artifact pipeline gained kinds (ADR-0005 D6); the route stays /risk-compliance/cbom. Fixed in docsv4/internal/product-descriptors/scopes-and-reproducible-boundaries.md, docsv4/internal/product-descriptors/cbom-as-audit-evidence.md, and docsv4/internal/developer/standards/features/post-design-permission-families.md.

  • The upstream data sources' attribution is now stated, and travels with the data. NOTICE gains a "Third-party data" section: this product uses data from the NVD API but is not endorsed or certified by the NVD; OSV's aggregated database is CC-BY-4.0, attributed to OSV (https://osv.dev) and to the upstream database named on each record; endoflife.date is mirrored under its published API terms. Every offline catalogue bundle built by make build-catalog-bundle now carries the same wording as an ATTRIBUTION.md member inside the tarball, so the obligations reach whoever ends up holding the file rather than staying behind in the repository. The operator guidance in "Attribution and terms" (catalogue docs) says so, and no longer describes the review of these terms as pending.

  • The minimum supported Windows version is documented: Windows Server 2019 or later (or Windows 10 1809 and later), for the host the device agent runs on and for any Windows host it collects from in remote mode. That is where the OpenSSH Server feature is available, which is what PowerShell-over-SSH remote collection needs. WinRM remains unsupported.

  • The README, the edition matrix and the Usage & Limits page now say what the code does. The README claimed six free frameworks (there are eight), nine inventory lenses (eleven, two of the named ones do not exist) and grouped plan authoring with billing as MSP-only, while the shipping Core build mounts plan authoring and billing is an Enterprise capability; the generated edition matrix said only MSP could author plans. All three now agree, the README gained rows for the NetBox connector and the AI assistants, and the tenant Usage & Limits page shows Storage as not metered instead of rendering the unmeasured value as "0.0 GB used" against the plan's quota. (#1778)

Internal

  • make test-integration-db now applies seed.sql, matching the nightly test-backend CI job, which has always applied schema + seed before running any test binary. The local runner previously stopped at schema.sql, so a test relying only on testdb.Connect(t) (never calling testdb.ApplySchemaAndSeed/ApplySeed itself) saw an unseeded database locally and a seeded one nightly — passing locally and failing nightly on a real collision with a seeded row, not a flake (#1672).

  • TestContract_EverySpecPathHasARoute (inventory-service) is now edition-aware. A spec operation whose handler lives under an ee/ tree is marked x-edition: enterprise in api/openapi/inventory-service.openapi.yaml (see api/README.md for the convention) and is expected to have no route in a Core export (ee/ absent) while still being required to have one in a full/Enterprise checkout — both directions are pinned by tests in spec_route_table_contract_test.go. Previously this test hard-failed in a Core export because the NetBox and CMDB-sync connector routes (registered under ee/connectors/netbox and ee/cmdbsync) genuinely have no matching route once ee/ is stripped.

Nothing in this section changes anything a customer sees or runs — it is the test harness only.

  • The parallel DB-integration test run is deterministic again. Nightly test-backend failed on two consecutive nights and roughly half of local make test-integration-db runs failed, always in a different test, always one that passed alone. The cause was not any of those tests: shared/testdb's ~200 ApplySchemaAndSeed call sites each re-applied the whole 30,000-line schema.sql and seed.sql from inside a run whose package binaries are parallel, so an apply held ACCESS EXCLUSIVE across every table while other packages were mid-transaction and Postgres broke the cycle by killing one side (40P01 deadlock detected). The seed half had the mirror problem — its tenant-scoped inserts select from tenants, and a throwaway tenant being CASCADE-deleted mid-statement failed their foreign-key check, exhausting the applier's retries and failing a whole package at setup.

Both files are now applied once per database: their sha256 is recorded in testdb_meta.applied_sql by the local runner and by nightly, and the harness reads that marker while holding the same exclusive advisory lock the apply takes, so two binaries cannot both decide to apply. A test whose subject is the re-apply calls the new testdb.ForceApplySchema / ForceApplySeed. The role and grant re-assertions behind ConnectAsAppRole (a GRANT … ON ALL TABLES per call, which rewrites a pg_class row per table) are cached the same way. Ten consecutive full parallel runs are clean, with zero genuine transient-race retries where the same tree previously logged fourteen.

Locking the tenant lifecycle against the applier — the other obvious fix for the seed half — was tried and reverted: it hangs the suite, because a shared advisory request queues behind a waiting exclusive one, so a test holding a row lock and then wanting a tenant waits on an applier waiting on that row lock. Ten packages sat at the timeout under nightly's own whole-module command. The seed re-applies simply stop happening instead, which leaves the two deliberate re-appliers to the foreign-key retry that exists for them. NewTenant carries a comment saying not to try it again.

  • Test fixtures that bind a well-known port get their own loopback address. cluster-sensor-service's UDP-dispatch suite bound 127.0.0.1:44818 and :47808, the same sockets sensor/internal/discovery's sweep suite needs — packages make test-parallel runs at the same time. The sensor suite had already moved to 127.0.0.2 and said why in a comment; the comment was not a check, so the other suite stayed put. It is now on 127.0.0.3, and a guard fails any test file that binds a fixed well-known port on 127.0.0.1.

  • Two edition-gate tests no longer write to seeded reference rows. Applying the seed once removed a mask: the enforcement-layer test that proves a hand-edited tier cannot unlock a paid capability was writing that grant onto the seeded enterprise tier and never putting it back, while a test in another package — running in parallel against the same database — asserts that no active tier grants an edition-gated capability. Both used to be rescued by seed.sql's corrective UPDATE running a couple of hundred times per run. Each test now uses a tier of its own and cleans it up, and the enforcement test asserts its premise (the tier row really does grant the capability) so it cannot pass by granting nothing.

  • Every Core release is now published as a release candidate first. Owner decision: core-vX.Y.Z-rc.N goes through the real pipeline — export, public tag, release-core.yml, signed images and chart at ghcr — is installed on the dev cluster from the published chart, and is smoke-tested; only then is core-vX.Y.Z cut, from the same commit. The pipeline support is the smallest set of changes that makes an RC correct rather than merely accepted: the GitHub Release is created --prerelease --latest=false, the images job does not move any :latest tag for a prerelease, release notes come from the base version's CHANGELOG section (an RC has none of its own, by rule) and the public export carries that section relabelled under the RC heading without duplicating it. publish-public-tree.yml warns — deliberately not fails — when a final tag has no matching -rc.* tag at the same commit. New: scripts/rc-smoke.sh, a curl/jq smoke run over a live install (every backend's health through the gateway with the service list derived from the registry, both UIs, both login planes, the tenant reads, the Core-vs-regulated framework partition, a real CBOM generation, and the version every pod reports). Procedure: RELEASE_PROCESS.md § Release candidates.

  • make audit now guards CHANGELOG.md against the release-prep merge bug that hit three times in one day. A branch that merged origin/main after [Unreleased] was consolidated into a new ## [X.Y.Z] section replayed a stale diff and re-inserted bullets git had already moved, so the shipped release section carried the same bullet twice and duplicate ### Added / ### Fixed headings. scripts/audit-changelog.mjs now checks: no bullet's first line repeats anywhere in the file; no ### Heading repeats within [Unreleased] or the release section right after it (older, already-frozen sections that legitimately repeat headings are left alone); exactly one leading ## [Unreleased]; every other ## [...] heading carries a - YYYY-MM-DD date; and [Unreleased]'s own headings are drawn from the file's existing heading vocabulary. Mutation-tested both polarities in scripts/audit-changelog.test.mjs.

  • This release's own notes are published from a lead, not from the whole entry. The entry you are reading ran to roughly 200 KB — a reference document, and well past the 125,000-character limit GitHub answers 422 to, which would have failed the release at its very last step with every image already built and signed. So the detail moved here, CHANGELOG.md's ## [1.0.0] section carries a hand-written lead (Highlights, Breaking / Upgrading, Editions, Verify) plus a link to this page, and the lead ends with a <!-- release-notes-end --> marker that release-core.yml lifts the GitHub Release body from. A section with no marker is still published whole, so nothing changes for the releases already recorded below this one, and the byte cap stays behind the lift as the belt. The changelog audit's duplicate-bullet check follows the bullets across both files, and the export strips issue references from this document the same way it already strips them from the carried changelog — they address a repository a public reader cannot open.

  • scripts/rc-smoke.sh now drives the platform-admin plane against the admin host rather than the tenant host, where deny-admin-plane answers 404 by design — so the platform-admin rows failed on a correctly configured two-host install and would have passed on a misconfigured single-host one. The session row is now the Core-served GET /admin/auth/me, and the Enterprise-only tenants list is asserted against the build's own edition read-out (GET /admin/platform/edition) instead of being expected to answer on Core. scripts/test-rc-smoke.mjs pins that no admin-plane path is ever requested against the tenant host, and runs the unreachable-host rehearsal.

  • The schema upgrade-path tripwire no longer fires on release candidates. It matched core-v1.*, which core-v1.0.0-rc.1 satisfies, so the unit suite went red on main telling us to re-arm a test for which no prior release exists — a candidate is installed, looked at and thrown away, never upgraded from. CI never saw it because its checkout has no tags. It now matches a final core-v1.Y.Z only, and the tag filter is unit-tested in both polarities so the guard still fires the day 1.0.0 ships.

  • rc-verify waits for the ingress, not just the pods. kubectl rollout status proves every pod is Ready; it does not prove Traefik is routing to them, and for a few seconds after a rollout the public URL can refuse the connection outright. Everything after the install talks to that URL, so the gap bit three separate steps in three disguises: the smoke tenant got HTTP 000 — registering and failed core-v1.0.0-rc.7, Playwright's globalSetup timed out on the login form, and the first framework-score reads come back mid-convergence. One bounded wait now covers all of them. It WARNs rather than fails if the edge never answers — the steps below report the real problem with better messages, and a readiness probe that can end a run adds a failure mode instead of removing one. step grew a printed WARN level for it, because a level that is recorded and never shown is how a check becomes invisible.

  • rc-verify stops waiting for a chart that is never coming. A chart that has not been published YET and one whose build already failed look identical from the registry, so the wait loop reported "not published yet — retrying" either way. On core-v1.0.0-rc.7 that cost 27 minutes: Release Core had failed three minutes in, when one of eighteen image jobs hit a transient reset pulling its base image, and the verifier went on hoping until its deadline. It now asks the run that is supposed to publish the chart whether it is still trying, and fails immediately — naming the conclusion and linking the run — when that run has finished without succeeding. Deliberately one-directional: a missing run, an in-flight run, a rate limit, no network or no jq all stay silent and leave the existing deadline in charge, because a diagnostic that can itself fail a release is worse than the wait it replaces.

  • rc-verify's score deadline raised to 10 minutes, from measurement. A tenant seeing the golden dataset for the FIRST time needs ~3 minutes for the compliance reconcile worker to settle — early reads legitimately show cert-expiry-not-expired at 100 and best-practices at 33 before the producers finish and the scores converge to what expected.json pins. Now that every automatic run uses a fresh tenant, every run pays that cost, while the same cluster finishes rolling eighteen services. Five minutes left two minutes of slack; that is not margin for a gate. HTTPTotal follows, because a whole-run cap below the sum of the per-check deadlines turns a slow check into a confusing timeout instead of a named failure.

  • The Findings page now has a browser journey. It had none, while Posture and Bills of Materials did — backwards, since Findings is where a compliance failure becomes something a person can act on, and it is the page that has silently omitted a whole producer before (it read compliance-only for a while after the eol and vulnerability producers shipped). The journey asserts the property the API contract names as the hazard: per-producer facet counts come from producer_counts, computed under the same filters minus the producer one, "so a facet's number and the list it leads to describe the same set". Labels come from the GENERATED FINDING_PRODUCERS registry the page itself renders from, so a new producer is covered the day it is added and a page that disagrees with the registry fails here rather than dropping that producer's findings quietly.

  • rc-verify now asserts what the frameworks SCORE, not merely that they scored. The previous check was satisfied by any non-null number, which cannot see a change to the scoring MODEL. A score is severity-weighted over CONTROLS and a control fails if it has one active finding anywhere in the tenant (the XCCDF model, #1369) — it is not a percentage of compliant subjects, which is why 19 of 20 valid certificates still leave cert-expiry-not-expired at 0. That is correct and surprising enough that expected.json already warned against re-investigating it; the numbers are now pinned so the model cannot be changed by accident (flip it to per-subject and the score reads 95 and the gate fails). The dataset deliberately earns one partial score (inventory-hygiene = 71) and the suite asserts that at least one framework is strictly between 0 and 100, so a scorer collapsed to the two extremes cannot pass. Polls, because scores converge downward as producers finish. Mutation-tested with score_drift.

  • rc-verify's UI leg could fail before running a single journey. retries in the Playwright config covers tests, not globalSetup, so one slow first paint took the whole leg down — which is what happened on core-v1.0.0-rc.4: the browser starts the moment the data path returns, ~90s after a helm upgrade has restarted all 18 services, and the login form had not rendered inside the timeout. An identical run minutes later passed 9/9. globalSetup now waits for the platform to answer (auth-service health and the login page — separate pods, independent failures) before launching a browser, and retries a login that never got off the ground. It deliberately does NOT retry a login the form rejected: "Rate limit exceeded" and a wrong password are answers, not flakes, and hammering either makes the report worse. Its failure screenshots are now collected into the run artifact — the one artefact that would have named rc.4's cause was never captured — written to a directory that holds no session state, because .auth/tenant.json is a live login and must never reach a downloadable artifact.

  • rc-verify counted the dataset's outbound SaaS observations as expected assets. The golden dataset names six public destinations — GitHub, Stripe, Datadog, Okta, S3, Slack — which the platform routes to external_connections, the third-party lens, and never turns into managed assets; the generator says so in as many words. The data path counted every destination it generated, so on core-v1.0.0-rc.3 it sat at "20 of 26 assets exist" for five minutes and failed the release gate on correct behaviour, on a fresh tenant as well as a reused one. The expectation now splits the two populations the way the platform does, from the address, and the external half is asserted against /external-connections rather than going uncounted — it had been covered by nothing, which is how the asset count came to be wrong about it.

  • rc-verify also counted host observations as endpoints. A host observation is the host naming itself over ARP/DHCP/mDNS — it is what CREATES an asset, not a service listening on one, and the platform writes no asset_endpoints row for it. The data path counted one per host anyway, deriving 62 endpoints against a platform that stores 42. This was invisible on core-v1.0.0-rc.3 only because the asset miscount above failed the run three checks earlier; fixing that one uncovered this one. The derivation now decides it the way the platform does (discovery_type == "host_observation"), the fake platform in the tests models the same thing, and the golden profile's derived numbers (20 assets, 6 external connections, 42 endpoints, 22 TLS) are pinned against what a live tenant on the dev cluster actually holds.

  • The README is now audited against source. It was the only edition surface with no drift guard, which is how "six frameworks" and "nine lenses" survived for weeks after both had grown. make audit now checks its framework and lens counts against the code that defines them and asserts every edition-gated capability has a paid-only row; the check fails if it reads nothing, so an emptied pattern cannot pass vacuously. (#1778)