Release 0.30.0

Changelog

Every release, and every defect this project found in itself. Where a fault affected what the site told practitioners, the entry says so plainly instead of describing the fix as an improvement. That is deliberate: a tool that reports what your systems got wrong has no standing to conceal what it got wrong.

All notable changes to ControlSpec are recorded here. Versions follow Semantic Versioning. Dates are ISO 8601.

This file exists so that corrections are visible rather than silently absorbed. Where a defect affected what the site told practitioners, the entry says so plainly instead of describing the fix as an improvement.

[0.30.0] — 2026-08-09

Changed

  • The Terraform page is now the prerequisites page, at /prerequisites. It was named after a tool rather than a question, and it opened on sixty lines of configuration with nothing saying what they were for. A reader who did not already write infrastructure as code could not use it; a reader who did was still not told what to switch on.

The page now answers one question — what has to be enabled before this check can find anything — and answers it in plain words before any code appears. Pick a control and a platform, and it lists each dependency with three things: what the record actually is, where it is switched on, and whether it can be automated at all.

Terraform is one section of that page rather than its subject, closed by default, and framed as what it is: Terraform creates cloud resources, so the configuration that creates a system is the natural place to switch on the recording that makes it checkable — the two then cannot drift apart, and there is no window where the system exists and nothing is watching it.

/terraform still resolves, redirecting to the new URL in the same way boundaries.html has always redirected to the project information page.

  • Every dependency now carries plain language and an enablement step. iac-resources.js previously described each record only in terms of the Terraform resource that declares it, which is useless to a reader who is not going to write Terraform. Each entry now also says what the record is — "a record of every sign-in attempt to your organisation's accounts" — and where somebody turns it on — "Microsoft Entra ID → Diagnostic settings → send SigninLogs to your Log Analytics workspace". Splunk index names resolve to what they actually hold rather than to a generic sentence.
  • The control pages link to it in their own words. Each of the 39 pages names what has to be reaching the platform before its query can return anything, and links to the prerequisites page for that control. The link text changed from "provisioned" to "switched on".

Fixed

  • Two dependency notes were shorter than the explanation the tests require, and landingFor did not forward the new plain-language fields, so the "evidence you supply" row rendered with an empty enablement step.

[0.29.1] — 2026-08-09

Fixed

  • The Terraform page described two different products at once. 0.29.0 rewrote what the builder generates and the prose at the top of the page, and left everything below it untouched. The page then said, on one screen, that it provisions telemetry and that it "declares rows with the exact fields from the selected contract"; it offered a Copy the Terraform evidence block button, a section called Produce the file the pack consumes, and closed by describing a manifest that no longer exists. Its meta description, social preview and structured data still called it an "evidence handoff".

Every test passed throughout, because each one checked that a new string was present. Nothing checked that the old ones were gone. A reader — of any technical level — met two incompatible explanations and could not follow either. Eleven places corrected, and the assertions are negative now.

  • A dangling aria-labelledby. Removing the duplicated flow section deleted the heading it pointed at, so a screen reader announced a label for a section that no longer had one.

Changed

  • The page says where it sits before it says anything else. It was reachable only from a navigation pill, so a reader arrived at a Terraform page on a security-checks site with no idea why it was there. It now opens with the three stages — provision, observe, check — marks which one the reader is looking at, and links back to the control library. Two numbered lists covering the same ground were collapsed into one.
  • The opening is plain before it is technical. The lede led with IdentityInfo, Sentinel UEBA and Entra ID P2 licensing — three terms a non-technical reader meets before being told what the page is for. It now states the problem in ordinary words first and gives the concrete example second, where it lands as evidence rather than as a barrier.
  • Every control page now names its own dependency. Each of the 39 pages states what has to be reaching the platform before its query can return anything, and links here. Exported telemetry and supplied evidence are listed separately, because they fail differently: one is a setting nobody switched on, the other is a file nobody landed. Describing them together produced "IdentityInfo (UEBA), HRTerminations_CL (supplied) has to be exported", which was wrong about the second and ungrammatical about both.

This is the structural fix. The Terraform page read as unrelated to the packs because nothing in the packs referred to it.

[0.29.0] — 2026-08-09

Fixed

  • The Terraform builder provisioned nothing. Its only resource was terraform_data — Terraform's built-in no-op. The generated configuration declared evidence rows as variables the reader typed in by hand, then piped them through jq into a CSV. Terraform was being used as a spreadsheet formatter: it created no telemetry, no table and no detection, so it could not have helped AC-2 or any other control. The page said as much in passing — "this page does not apply it" — which left the obvious question, why is this Terraform, with no answer.

tests/terraform-page.test.js passed the entire time, because it asserted the no-op was present. A test pins behaviour; it cannot tell you the behaviour was not worth having.

Changed

  • The page now answers the question that comes before the query. A control has to be provisioned when the resource is created, or the check that looks for it has nothing to read. AC-2 on Microsoft Sentinel reads IdentityInfo, and that table does not exist until Sentinel user and entity behaviour analytics is enabled under an Entra ID P2 licence. Without it the query returns zero rows — which on screen is indistinguishable from no terminated identity still has access. The check reports success by reporting nothing, which is the worst failure an assurance tool can have, and it is a provisioning fault rather than a query fault.

The builder now emits three sections per control and platform: the telemetry the published query reads, the place a supplied evidence contract lands, and the detection itself — carried through from each pack's own monitoring configuration rather than reinvented.

  • Resource types are named only where the provider publishes one. Everywhere else the dependency is printed as a stated GAP with the reason: Sentinel UEBA needs a licence, the AWS credential report is generated by a scheduled job, a Splunk lookup is a file rather than a resource. Inventing a plausible-looking resource name would be the same class of error as claiming a query had been validated when it had not. Of the 42 non-supplied dependencies across all 39 combinations, 34 resolve to a resource and 8 are published gaps.
  • No resource arguments are emitted, and the block says why. None of this configuration has been applied by the maintainer. It carries the same NOT TENANT-VALIDATED statement the packs use, and omits arguments because they depend on the reader's own naming, region and retention.
  • The plan-as-change-record idea is kept, scoped to CM-6 / CM-3. There the Terraform run genuinely is the evidence the change_approvals contract describes. It is no longer offered for the other eleven controls, where the reader would have been typing the rows in by hand. The commands state plainly that Terraform knows what changed and when, but not who authorised it.

Note

While rewriting the page prose, an assertion caught a factual error in the new text: it claimed AC-2 reads IdentityInfo and SigninLogs. AC-2 reads only the first; SigninLogs belongs to AC-7 and CM-8. Corrected in the page, the data file and the test.

[0.28.0] — 2026-08-08

Added

  • Terraform evidence builder. The Terraform page now generates a control- and platform-specific HCL block from the same NIST-aligned control and evidence-contract definitions used by the packs. It records review context, declares typed contract rows, provides an example row, and emits commands for capturing the reviewed plan and exporting the contract file.
  • Pack-linked mapping. The builder shows the selected NIST SP 800-53 control, runtime query route and data sources, platform landing target, evidence fields, and any existing CISA CPG 2.0 context. Telemetry-only controls are explicitly prevented from receiving an invented row contract.
  • Generator tests. CI now verifies every control/platform combination, contract field type, target name, CSV export path, and telemetry-only boundary.

[0.27.0] — 2026-08-08

Added

  • Terraform and infrastructure-as-code evidence page. A separate, plain-language page explains how Terraform can supply intended-state and change evidence for cloud controls without being presented as a fourth monitoring destination. It documents the evidence packet, the drift limitation, and the boundary between Terraform configuration and runtime checks in Microsoft Sentinel, AWS, or Splunk.
  • Terraform navigation and discoverability. The page is included in the shared navigation, canonical sitemap, portability cross-links, generated-page chrome, and automated page-wiring tests.

[0.26.2] — 2026-08-08

Fixed

  • The header was unusable on a phone. Eight destinations plus the theme toggle need roughly 124px of wrapped rows on a 375px screen, and .nav-row was a fixed height:72px. Because align-items:center centres content that overflows, the first row of pills rendered at top:-26px — above the header, clipped by the status bar — and the brand ended up marooned in the middle of the wreckage. The wrap added in 0.26.0 to stop the page scrolling sideways is what collided with the fixed height.

The row is now min-height, so it can never clip again, and narrow screens get a real disclosure menu instead of a wrapped pill bar. It is a native <details>: no script, which matters because the CSP allows no inline script and the 52 generated pages are meant to be fully usable with JavaScript off. All eight destinations live inside the panel; the theme toggle stays outside it, because changing the theme should not require opening a menu first.

  • A one-pixel gap between breakpoints. The first version of the disclosure paired (max-width:899px) with (min-width:900px). At a viewport of 899 CSS pixels both evaluated false — the real width is fractional, so it was neither ≤899 nor ≥900 — and every page fell through to unstyled defaults with the menu panel stuck open. Rewritten mobile-first with a single min-width query, which cannot have a gap because there is only one edge.

Found by testing 320, 375, 414, 600, 768, 820, 898, 899, 900, 901, 1024, 1280 and 1600 rather than a token "mobile" and "desktop".

Changed

  • The site audit now runs at phone widths. The previous one measured only at 1280px, which is exactly why a header that was broken on every phone shipped and was reported from a photograph of one.

[0.26.1] — 2026-08-08

Four colour and structure faults, found by measuring rendered colour against rendered background on every page in both themes rather than by looking.

Fixed

  • The current page's navigation button rendered as an empty coloured blob. nav a[aria-current="page"] sets color:var(--teal) and is specificity (0,2,1); .nav-pill sets its background to the same var(--teal) at (0,1,0). The more specific rule won, so the label was painted the same colour as the button it sat on — contrast 1.00, the text invisible.

The rule is older than the shared header and was correct when it was written: the only links inside a <nav> were unstyled text on the page background, and teal was the right way to mark the current one. Putting teal-filled pills inside <nav> in 0.26.0 is what turned it into a defect. It is scoped with :not(.nav-pill) now.

  • Links with no class of their own fell back to the browser's default blue. There was no default a colour anywhere in the stylesheet. #0000EE passes on the light theme, which is why it was never noticed; on the dark theme it is 2.0:1 and effectively unreadable. It affected 41 links across 8 pages — every "Official source" in the glossary, the changelog's outbound links, the portability page's closing list, and the "open this pack" button on all 39 control pages.
  • Text added to the footer used the light-theme tokens. The footer is #0b1727 in both themes — it does not follow the light/dark variables. The commitments moved there in 0.26.0 used var(--muted) (3.42:1) and the footer links used var(--ink2) (1.58:1, barely visible against the dark panel). Both now use the footer's own palette.
  • Two pages skipped a heading level. The changelog rendered release entries as <h3> directly under the page's <h1>, and the control library's cards were <h3> with no <h2> above them. A screen reader announces a level that was never opened.

tests/chrome.test.js now pins all four. Each assertion was checked by putting the original defect back and confirming it failed.

[0.26.0] — 2026-08-08

Fixed

  • The home button on the glossary page did nothing. It was generated with href="", which resolves to the current URL, so clicking it reloaded the page you were already standing on. Introduced in 0.25.0 by the same change that added the page.
  • Six page types carried six different navigation bars. Not by decision: each page had been written listing "everywhere except here", destinations were added to some and not others, and nothing ever compared them. The home page offered eight links, the coverage map four; Activity was missing the glossary and project information entirely. A reader moving between pages saw destinations appear and disappear with no way to tell what existed.

The header and footer now come from site-chrome.js — one definition, applied to the generated pages at build time and written into the five hand-authored pages by tools/build-chrome.js between markers. tests/chrome.test.js compares all twelve pages and fails if any diverges, which is the part that was missing: the fault was invisible from inside any single file and only ever visible by comparison.

  • The .cpg-goals grid scrolled the coverage page sideways on a phone, from a fixed 320px column minimum inside a narrower container — the same fault .control-index had.

Changed

  • The banner is gone from the top of every page. It read "CONTROLSPEC · RELEASE 0.25.0 · NO CLOUD CREDENTIALS COLLECTED · VALIDATE DATA-SOURCE NAMES BEFORE USE" and was the first thing on every page, putting a version number in front of a reader who had not yet been told what the site was.

Its two commitments were worth keeping, so they moved to the footer, which is on every page anyway — still stated everywhere, no longer shouted first. The release number and the history moved to a page of their own.

  • Theme handling is in one place. Five page bundles each carried their own copy, which is why the generated pages — having no bundle — had no dark mode at all until 0.25.0. theme.js now does it for every page, and the test fails if a bundle wires #themeToggle again: two handlers toggle twice per click and look exactly like a dead button.

Added

  • A public changelog at /changelog, rendered from this file. The argument for trusting this project rests substantially on recording its own defects in plain terms; that record was previously readable only by opening a Markdown file in the repository, which is not a thing most readers will do.

The renderer shipped broken twice while being written — once leaving bold markers as literal asterisks mid-sentence, once splitting every wrapped sentence into its own paragraph. Neither threw, neither changed the page's structure, and both looked fine in a diff. tests/chrome.test.js now asserts against both, and each assertion was confirmed to fail on the real defect before being kept.

  • A portability page at /portability, stating what is vendor-neutral and what is not. Every page here names three commercial products, because those are what people search for and run, and the accumulated impression is that the work is about them.

It is not, and the distinction is the substance of the method: the objectives, the evidence contracts, the sourcetype vocabulary, the outcome vocabulary and the machine-readable profile are all vendor-neutral. Only the query text is vendor-bound, and only because a query has to execute somewhere. The strongest evidence is structural rather than asserted — the same specification is implemented three times, on three competing vendors' products, and 13 of the 27 catalogued technologies are reached through the neutral layers with no query written for any of them.

Every figure on it is counted from the published data at build time, for the same reason as the coverage map: a page arguing the method is portable would refute itself if its own numbers had drifted.

[0.25.0] — 2026-08-08

This release is about a reader the site had not been written for: someone who arrives without the vocabulary. Every page assumed a practitioner, and the assumption was invisible from the inside.

Fixed

  • "Open this pack" sent readers to the wrong platform's query. The link from a control page carried the control but not the platform, so the application opened on whichever platform the reader's environment profile happened to allow. Someone who had just read the entire Splunk page for a control clicked through and got Sentinel, with Splunk locked and no route back to the query they had come for. They were shown a different platform's query without being told.

The worst case was the thirteen Splunk pages, which are the only ones carrying observed lab output — the pages a reader is most likely to arrive on and most likely to want to open.

The link now carries platform=, and a platform named in the link is honoured for that visit. It is announced rather than done silently, the saved profile is never modified, and reloading without the parameter restores the reader's own selection. Unlocking the button alone was not enough: the query itself was gated separately, so the fit assessment now runs against a profile that includes the requested platform. Every other check in that assessment still applies — only the "you did not tick this platform" objection is answered, and the reader answered it by following the link.

  • The 52 generated pages had no dark mode and no theme toggle. They shipped with data-theme="light" fixed in the markup. A reader who chose dark on the home page and followed a link into the control library was thrown a white page with no control on it to change back, and got dark again on the way out — the site appeared to forget a setting it had in fact stored. theme.js restores the choice, loaded in the head and not deferred so the theme is settled before the first paint rather than flashing.
  • The site header overflowed a phone screen. Several destinations plus the theme toggle in one non-wrapping row was wider than the viewport: the document scrolled sideways and the last item sat off the edge, untappable. It affects every page, so the fix is on .nav-right rather than on one of them.
  • The home page's control index also overflowed at 360px, from a fixed 340px grid minimum inside a container narrower than that.
  • metrics.js still declared release 0.17.1, seven releases behind. That constant is stamped on every recorded event and on the release line of the implementation snapshot in the printed guide, so a practitioner who downloaded a pack got an artifact naming a release it did not come from.
  • Three stale figures in index.html. The hero stats read 10 / 30 / 5 in the markup and were corrected to 13 / 39 / 6 by JavaScript on load, so a crawler and a reader without JavaScript saw last month's numbers. The route legend still said "five open CSV contracts" and listed five vendor-neutral sourcetypes where there are now nine.

Added

  • A plain-language layer for every control, as shared data. What goes wrong in the world, why it keeps happening, what the check reads and what it reports — in words that assume no security background. It appears above the technical detail on all 52 pages, because it answers the question that comes first: whether any of this is worth doing.

Five of these existed already, written for the demonstration exhibit and living inside its generator. They are now in plain-language.js with eight new ones, and the exhibit reads from there — it regenerates byte-identical. One text, three consumers, nothing to keep in sync by hand.

tests/plain-language.test.js enforces both coverage and register: no control identifier, no undefined abbreviation, and a field copied verbatim from controls.js fails.

  • A glossary, at /glossary, and the terms each page uses on the page itself. "KEV" appeared as a three-letter chip on the signals page — the strongest signal there and unreadable to anyone who did not already know it. "CPG", "SIEM", "KQL", "SPL" and "RPO" appeared with no expansion anywhere near them.

Definitions live in glossary.js and are rendered wherever the term appears, including as the tooltip on the signals source chips. Each control page lists the terms it actually uses, derived from its own data — a tooltip is no use on a touch screen, and the reader who needs it is the least likely to hover hopefully over a chip.

  • A platform switcher directly under the page title. The only route to the same control on another platform was a bulleted list below the FAQ, past the query, the settings, the deployment block and eight questions. A reader comparing platforms could not tell from the top of the page that the other two existed. Each option carries its own validation status, because "which platform" and "has this actually been run" are one question.

The test pins the switcher above the query. The old cross-link assertions passed throughout, because "reachable" and "findable" are different properties and only the first was being tested.

  • An orientation band on the home page, before anything asks the reader for input. The page opened by demanding an environment profile from someone who had not been told what a control pack is or why they would want one.
  • "Why this page is here", on signals, before its first number. The page opened onto counts and a chart, which answers how much for a reader who has already accepted why. The argument for implementing a check is short and it was nowhere on the site: this happened, to someone, recently — and here is the check that looks for it in your own systems.

Changed

  • The disclosure summary chip reads "Summary · machine-written" rather than "AI SUMMARY". The old label shouted a disclaimer where the reader expected content, and the acronym did the labelling work badly — "AI" says who wrote it only to a reader who already thinks in those terms. The provenance still travels with the row, which is the property the commitment rests on: a screenshot of one line still carries where the text came from.

The assertion in tests/page-wiring.test.js pinned the literal string "AI summary", which made a copy-edit indistinguishable from someone quietly deleting the label. It now asserts that the label and its provenance are both present, which is the thing worth failing a build over.

  • The confirmed-exploitation marker reads "Attacks seen in the wild" rather than "Exploited", and says in its tooltip that the flaw is on CISA's catalogue because attacks using it have been observed — not because it is judged risky in theory.

[0.24.0] — 2026-08-07

Added

  • A browsable control library at /controls/, and a link to it from every page. The 52 generated pages existed but were close to unreachable: the only path was scrolling to the bottom of the home page, and even that reached just the thirteen hubs — the thirty-nine platform pages needed another click from a hub. Someone who wanted "the AWS one for AC-7" had no way to see it existed.

The library shows every control as a card with its objective, its CISA CPG goals, and all three platform links carrying their own validation status. It can be searched by keyword, control id or CPG goal, and filtered by platform, control family and validation status.

All 52 links are in the static HTML. The filters hide cards that are already present rather than building anything, so a crawler and a reader without JavaScript both get the complete library. The filter script is an external file because the site's Content-Security-Policy allows no inline script, which is worth keeping.

The platform filter dims the platforms not chosen instead of hiding controls — every control has all three, so hiding by platform would remove nothing and look broken.

Fixed

  • The active filter chip was unreadable in dark mode. Its background is the theme's teal, which is dark in light mode and light in dark mode; white label text gave roughly 1.7:1 against the light variant, far below the 4.5:1 minimum. The label now takes the page background colour in dark mode — measured at 5.99:1 in light and 10.01:1 in dark.

[0.23.0] — 2026-08-07

Added

  • Control Finder pages — the project's subject matter is now indexable. index.html renders every pack with JavaScript, so a search engine saw an empty shell: thirteen controls, thirty-nine queries and six evidence contracts were all invisible. Someone searching for a control and a platform could not find any of it.

tools/build-finder.js generates 52 static pages under controls/ — one hub per control and one per control and platform — carrying the requirement, the full query, the evidence contract, the organization-defined settings, the deployment configuration, the review questions, the CISA CPG mapping, and what a result does not prove. The thirteen Splunk pages also show the rows the query actually returned when it was executed.

Every sentence comes from controls.js, schemas.js, parameters.js, cpg.js, validation-status.js or the captured lab run. Nothing is written per page, so nothing can drift.

  • A static index on the home page, because the pack library is rendered by JavaScript and a crawler cannot follow it. Without that list the new pages would be reachable only from the sitemap.

Changed

  • sitemap.xml is now generated rather than maintained by hand, so a new pack cannot ship unlisted. It grew from 5 URLs to 57.
  • tests/verify-artifacts.js fails if a control is missing its four pages — adding a pack without regenerating leaves it invisible, which nothing else would catch.

Deliberately not done

  • No page per phrasing of a question. Permuting search phrasings into hundreds of near-identical URLs is what Google's scaled-content-abuse policy targets, and the penalty applies to the whole domain. The phrasings are answered as FAQ sections, with structured data, inside the page that already covers them — and tests/finder.test.js fails if any declared question is not actually rendered, since structured data describing hidden content is itself a violation.

[0.22.0] — 2026-08-07

Fixed

  • AC-7 reported accounts that had been correctly locked out. All three platform queries required the lockout event to fall between the first and last failed sign-in. A lockout is emitted after the attempt that trips the limit, so it lands after the last failure — and an account the control had successfully protected was reported as one it had not. The lockout window now carries an hour of grace past the last failure on Sentinel, Athena, and Splunk, and the pack's stated condition says so.

Every automated suite passed throughout. Structure, generation, injection safety, and preflight were all green; the defect was only visible when the published search was executed against a search engine holding data where a real lockout followed a real burst. That is the gap lab validation exists to close, and it is the first defect it found.

Added

  • Thirteen packs moved from not tenant-validated to lab-validated. Every published Splunk search was executed verbatim against a local Splunk Enterprise instance loaded with the six published evidence-contract templates as lookups and synthetic events in the documented vendor-neutral sourcetypes. Each pack returned the case it should and excluded the case it should not. Records with dates, scope, and notes are in validation-status.js, and the site now shows the transition per pack.
  • tools/lab/ — the harness that produced those records. One command builds a Splunk container from nothing, installs the lookups, generates and ingests the synthetic events, and runs every published search, writing captured output to output/lab/splunk-results.json. Nothing in it rewrites or repairs a query: a pack that fails is recorded as failing. It does not accept the Splunk licence for you — -AcceptSplunkTerms is required and refuses by default.

Changed

  • A limitation in AU-12 is now recorded rather than left to be discovered. Its coverage subsearch counts any event in index=cloud with no sourcetype filter, so an asset emitting configuration data but no audit logs is treated as covered. Noted in the validation record instead of silently changing the published query.
  • Azure and AWS packs remain not tenant-validated, and the records say so. A partial, accurate result is worth more than a complete, padded one.

[0.21.1] — 2026-08-07

Fixed

  • A CPG chip now opens the goal it names. Chips on a signals row and badges on a pack page linked to cpg.html with no fragment, so a reader arrived at the top of 34 goals and had to find the one they had just clicked. Goal cards now carry an anchor and the page scrolls to it, briefly highlighting it with the existing .attention-flash treatment, which already degrades to a static outline under prefers-reduced-motion.

The anchor sanitises the dot — 3.R becomes goal-3-R. A literal #goal-3.R is a valid HTML id, but querySelector reads .R as a class selector and silently matches nothing, so the id is built by one shared helper in cpg.js rather than three separate approximations of the rule.

  • Every technology group on the front page displayed the literal text “b8”. Two CSS rules held a raw U+0015 control character followed by b8 and be — a \25b8 (▸) and \25be (▾) escape that had been mangled at the byte level. Replaced with the literal characters, matching what the route legend a few lines below already did. A test now rejects any content: value that is a bare hex-looking fragment or contains a control character, which catches the whole class rather than these two lines.

Changed

  • The countries figure no longer reads as a fault. It said “suppressed”, which looked like a broken collector. It was working exactly as documented: all recorded events carry an edge-derived country, and the count is withheld below three so it can never narrow toward a single visitor. The threshold and the server response are unchanged; the card now shows “—” and says the figure appears once three countries are represented.

[0.21.0] — 2026-08-06

Added

  • The CM-8 pack — a device active in the estate but absent from the inventory (CPG 3.R). Reuses the existing asset_inventory contract, so no new evidence file. Coverage moves from 11 to 13 of the 23 answerable goals: 3.R directly, and 2.A because testing what the inventory is missing is what actually evidences that it is accurate — SI-2 could only reach 2.A partially, through whatever its scanner happened to see.

Review tier is automated_with_review, not automated. An absent device is at least as likely to be an identifier mismatch between telemetry and inventory as a genuinely unmanaged machine, and the engine rule returns REVIEW_REQUIRED rather than a finding to say so.

  • A plain-language summary on each disclosure row, generated during the scheduled ingest by Cloudflare Workers AI and stored. Rows previously carried a title, a date, and a pack chip, and a reader had to leave the site to learn what any of it was about.
  • The evidence behind each pack match. The taxonomy already computed the exact weakness class, technique id, and phrase that produced a match, and the ingest discarded all of it. Now stored and shown, so a claim can be checked against the linked source instead of trusted.

Fixed

  • Control matches were never refreshed once stored. ingestFeed used INSERT OR IGNORE, so an indexed row kept whatever the taxonomy said on the day it first landed. After SI-2 gained a published pack in 0.20.0, every already-indexed row still read “SI-2 · no pack”, so the dashboard contradicted the coverage map. Re-ingest now refreshes the derived columns. The fingerprint is still the identity, so this remains idempotent, and ingested_at is deliberately not refreshed.

Changed

  • What is stored, and what the model touches, are both restated rather than left to drift. A capped 600-character excerpt of the feed's own published description is now retained — for the two public-domain CISA feeds only, and never for SEC filings, which are company-authored and carry no real description anyway. The linked document is still never fetched.

The model writes one labelled sentence. It touches no count, no control match, no confidence band, and no pack recommendation. That boundary is enforced in code: generated text containing a control identifier, a CWE, a CPG reference, or the word “pack” is rejected rather than stored, along with empty, over-long, and preamble output. The summariser fails open — no binding, no free allocation left, or rejected output all leave the row without a summary, and the disclosure is still indexed with its matches.

The licence note, both worker headers, and the test-suite header now say this precisely. The page-wiring assertion that pinned the previous “never document text” wording was rewritten rather than deleted; it still fails if anyone starts fetching linked pages.

[0.20.0] — 2026-08-05

Added

  • Two packs closing the two most valuable gaps on the CPG coverage map. Coverage moves from 9 to 11 of the 23 answerable goals.

SI-2 — known-exploited vulnerability past its remediation date (CPG 2.B). This closes the loop the Signals dashboard opened. The project already reads CISA's Known Exploited Vulnerabilities catalogue to say these control areas were implicated; the pack now lets a reader ask is this in my estate, and is it overdue — against the same federal catalogue, using the remediation date CISA published for each entry. It deliberately says nothing about CVEs outside the catalogue: reporting them as clean would imply a judgement the pack did not make.

AC-7 — repeated failed sign-ins without lockout (CPG 3.E). The finding is not that failures occurred. It is that an account passed the organization-defined attempt limit with no lockout recorded while the failures were happening — a lockout in the same window is the control working, and is excluded.

  • A sixth evidence contract, kev_catalog. CISA's catalogue is public domain federal data, so the template points at the authoritative download rather than reproducing it. The inline quick-start form says plainly that a handful of pasted CVEs narrows the query to only those CVEs — the one contract where inline entry is a demonstration rather than a real answer.

Changed

  • SI-2 moves from the taxonomy's uncovered areas to a published pack, so a disclosure about an unpatched appliance now resolves to a query instead of to "no pack available". Tests assert both halves, so the two lists cannot claim it at once.
  • bin, bucket, and convert added to the Splunk preflight allowlist. They are legitimate SPL that no published pack had used yet, so the checker was flagging a correct query. Extended deliberately, not to silence a warning.

[0.19.0] — 2026-08-05

Added

  • A crosswalk from every pack to the CISA Cross-Sector Cybersecurity Performance Goals 2.0, and a public coverage map at cpg.html.

The packs are written against NIST SP 800-53, a federal systems control catalogue. A rural water utility, a county clerk's office, or a two-person clinic is not measured against it, does not recognise its identifiers, and has nobody on staff to translate them. The CPGs are the federal baseline written for exactly that population — CISA states they "strive to help small- and medium-sized organizations kickstart their cybersecurity efforts." Carrying both identifiers lets a reader arrive with the vocabulary they are held to and still reach a runnable query.

Every pack now shows its CPG goals on the implementation guide, and signal rows carry the goal identifiers their matched control areas speak to.

  • The coverage map states the gaps at the same weight as the coverage. Nine of the 23 answerable goals have a runnable pack. Eleven of the 34 goals ask for a policy, a plan, or a trained person rather than a system condition; those are marked out of scope and excluded from the denominator, because counting them as gaps would misstate what a query could ever do, and counting them as covered would be worse.

Changed

  • Mappings are made against each goal's stated outcome, not against the SP 800-53 references CISA prints beside it. Those reference blocks are deliberately coarse — goals 3.A, 3.B, 3.C, 3.D and 3.E carry the identical list — so a mapping built on them would make one pack satisfy five goals equally: precise-looking and uninformative. The page says so.
  • Every mapping carries a strength, and partial is stated rather than smoothed over. IA-5(1) is the honest failure: CPG 2.0 has no goal for credential rotation age, and current NIST guidance moved away from forced periodic rotation. It is mapped as partial with that stated in the note, is excluded from the signal-row chips entirely, and a test prevents it from being promoted. Goal identifiers and titles are transcribed from the CPG 2.0 report rather than the summary web page, which words several of them differently.

[0.18.0] — 2026-08-05

Fixed

  • A signal opened in a new tab went to the wrong pack. The dashboard's control chips carried the pack in sessionStorage, written by a click handler, while the link itself pointed at index.html#flow. Middle-click and ctrl-click fire auxclick rather than click, so opening a chip in a new tab ran no handler and landed on the default pack — silently the wrong one, which is worse than not linking at all. Copying or bookmarking a chip lost the pack for the same reason, and two chips opened in two tabs shared one storage key.

The pack now travels in the URL as index.html?pack=<id>, so new tabs, shared links, and bookmarks all resolve. The sessionStorage handoff is still read as a fallback for a page left open from the previous version.

Added

  • A pack opened from Signals now says why. Arriving at a control with no indication of what sent you there left the reader to reconstruct it. The pack page now carries a short note naming the control and stating plainly that a match is a source reference, not evidence that any organization was breached through it — the same distinction the dashboard makes, kept intact when the reader moves between pages. It offers one click back, and does not appear on a plain visit.

[0.17.3] — 2026-08-05

Added

  • The deploy toolchain is now a reviewable file rather than a snapshot. tools/Dockerfile replaces an image built with docker commit, and tools/deploy.ps1 wraps the deploy. This is the same reasoning that keeps Cloudflare bindings in version control instead of in a dashboard: a snapshot cannot be reviewed or diffed, and it captures whatever happened to be on disk. Here that meant two concrete defects — the image carried its own stale copy of the repository, which silently re-enabled the public workers.dev route moments after it was closed, and it captured a live Cloudflare API token into an image layer, where deleting the file cannot remove it.

The image contains no credentials and no copy of the repository; the working tree is mounted, so what deploys is what is on disk. The script parses the deploy output and exits non-zero if a public route appears or the cron trigger is missing, because Wrangler reports a stale config in a warning that scrolls past above the success line.

[0.17.2] — 2026-08-04

Fixed

  • The ingest Worker no longer publishes a second, unthrottled copy of the write API. That Worker is deployed only to run the disclosure ingest on a cron trigger, but wrangler deploy also published its fetch handler on a *.workers.dev hostname — and that handler serves POST /api/event and POST /api/feedback. The edge rate limits added in 0.17.1 are scoped to the site's hostname, so they did not cover it, and the origin check is a CSRF guard that a non-browser client can forge past. The result was an unrate-limited route for posting fabricated events into the same database the activity page reads, which is exactly the outcome wrangler.toml warns about. workers_dev = false and preview_urls = false are now both set — per-version preview hostnames serve the same handler, so disabling only the first would have left them reachable. Cron triggers are unaffected.

Added

  • A shared secret on the manual re-ingest route. POST /api/signals-ingest is the most expensive endpoint on the site — it fans out to every external feed and writes to D1 — and was gated only on INGEST_ENABLED plus the same forgeable origin check. It now also requires INGEST_TOKEN sent as X-Ingest-Token, compared without leaking similarity through timing. With no token configured the route refuses everything, so a missed setting fails closed rather than leaving it open.

Changed

  • Correct a stale comment in worker/wrangler.toml that still named the pre-/api collector paths, and align CI's Node version with the version used for local runs.

[0.17.1] — 2026-08-04

  • Keep Wrangler configuration, D1 schemas, and migration files out of the public Pages asset namespace with root Pages middleware.
  • Clarify that Signals chart counts are published-source matches, not proof of exploitation; confirmed KEV items remain explicitly labelled.
  • Document the /api collector paths consistently and require production edge rate limits for the public event and feedback routes.

[0.17.0] — 2026-08-03

Added

  • A sample-data preview for the signals dashboard. The page is empty until the first scheduled ingest, which made the design impossible to review before deploying. signals.html?preview=sample renders it with realistic volumes in the exact shape the live endpoint returns, so the real render path is exercised rather than a mockup.

It is opt-in through the URL and nothing else. An empty or unreachable index still shows an empty page. A dashboard that quietly filled itself with invented figures whenever the backend was down would undo every commitment this project makes about not overstating, so the preview is unreachable from the failure path and tests/page-wiring.test.js asserts that.

It is marked at three levels — a banner across the page, a note in the freshness line, and a chip on every individual row — because the realistic hazard is not a reader being fooled but a screenshot being reused later without its context.

  • Period-over-period direction on the headline tiles. A count alone does not say whether things are getting worse, so each window now carries the equivalent preceding period: 34 advisories means little, 34 against 29 last week means something. The stored read window widened to 190 days so the 90-day view has a prior quarter to compare against.
  • Entries confirmed exploited in the wild are marked. A KEV entry is the strongest evidence on the page that a control area matters right now, and it previously looked like any other advisory.

[0.16.0] — 2026-08-02

Changed

  • Renamed to ControlSpec. The previous name collided with OpenControl, an established compliance-as-code project publishing YAML schemas for NIST 800-53 and positioned against OSCAL — the same problem space this project occupies, described in nearly the same words. A reader would reasonably have assumed a fork or an affiliation, which costs this project credit for its own work. controlspec.org and the ControlSpec name have no collision in security or compliance.
  • Unified the two JavaScript global prefixes. OCA (a leftover from an even earlier name) and OCS both existed; everything is now CS*. Storage keys, the D1 database name, the Worker name, the Pages project, and generated archive filenames follow the same convention.
  • Wider content column, 1360px to 1600px, with prose capped in ch so lines stay readable at the new width. The page rail's gutter arithmetic moves with it: the full rail now needs 1976px and the compact spine 1740px.
  • The environment profile is roughly half its previous height. Each technology option was four lines — label, detail, badge, and a collapsed "How this connects" — so a group with eight products was taller than the role picker beside it. An option is now two lines with the badge inline, and the route explanations moved to one disclosure per group instead of one per option. Groups flow three across above 1280px rather than two.

[0.15.0] — 2026-08-02

Changed

  • The activity page leads with what is countable on day one. It previously showed roughly fourteen figures, nearly all of which read zero on a fresh deployment — a working project rendered as a dead one. It now opens with what actually exists (control objectives, published queries, evidence contracts, validation state, all counted from the source definitions rather than asserted) and a summary of what the disclosure index has read.
  • Usage and practitioner reports render only once they contain something. Until then a single line says so. Nothing is hidden to flatter the project; an empty section is stated rather than displayed as a grid of zeros.
  • Neutral vocabulary throughout. The engagement levels are now "Opened", "Taken for evaluation", and "Taken to implement" — plain descriptions of what a visitor did. Repeated insistence that the figures do not establish adoption has been reduced to one matter-of-fact statement, which reads as ordinary measurement discipline rather than argument.
  • No personal name is rendered on any page. Page footers now carry the licence and a pointer to the repository. Authorship remains where it belongs for an open project: LICENSE, CITATION.cff, and the commit history.
  • Footers no longer carry a standing disclaimer about employer or client material, which is not something an ordinary project announces.

[0.14.0] — 2026-08-02

Changed

  • The disclosure feature is now a curated dashboard, not a tool a visitor drives. The previous release shipped a scanner with a URL box: it put the burden of finding a source on the reader, showed a blank page to anyone who did not already know what to look for, and made the site fetch on demand. signals.html replaces it. This project now chooses its own sources, reads them on a schedule, and publishes the aggregate — which control areas public disclosures actually implicated this week, this month, and this quarter.

There is no user input anywhere in the feature. No URL field, no upload, no free-text area. Nothing a visitor can type reaches a fetch, a query, or the database, which also retires the open-proxy risk the URL box carried.

Added

  • Scheduled ingest of three feeds, all works of the US federal government and so not subject to domestic copyright: SEC Form 8-K Item 1.05 material cybersecurity incident disclosures, CISA advisories, and the CISA Known Exploited Vulnerabilities catalog. News and commentary sites are deliberately excluded because their text is copyrighted.
  • Only metadata is stored — a title, a date, a link, and this project's own derived control areas. Document text is read to classify an item and then discarded, so nothing is republished beyond a citation.
  • A naming policy that differs by source, on purpose. An SEC 8-K is a company disclosing that it was breached, so those titles are reduced to a generic descriptor and the link carries the identity. A CISA or NVD advisory is a product vulnerability record where the affected product is the content, so those titles are kept intact.
  • A dashboard built for glancing at: four stat tiles, a horizontal bar chart of control areas implicated, and a compact recent list. Time range switches between 7, 30, and 90 days. One measure means one sequential hue and no legend; the bar track carries the scale, so there are no gridlines and no second axis.
  • Direct links from every bar and every chip into the matching pack, so the path from "this is being exploited" to "here is the implementation" is one click.
  • A visible freshness line. The ingest records what each feed returned, and the page reports when a source came back empty. A silently broken adapter is indistinguishable from a quiet week, which is why it is stated rather than inferred.

Removed

  • scanner.html, scanner.js, and the on-demand /scan route, along with the URL allowlist they needed. scanner-taxonomy.js is unchanged and now does the matching during ingest instead — the deterministic core was always the valuable part.

[0.12.0] — 2026-08-02

Fixed

  • The largest figure on the activity page measured clicking around. query_generated was recorded from the render path, so it fired on every pack, platform, and evidence-mode switch. Browsing the library once produced dozens of them, dwarfing every deliberate action and making the page look busier than the project was. It is no longer recorded, and the collector rejects it.
  • "Packs exercised: 10 of 10" meant nothing. A pack counted as exercised the moment it was opened, so clicking through the library marked all ten and all three platforms within a minute. A pack now counts only when someone did something with it beyond reading, and packs are ranked by distinct participants so one enthusiastic visitor cannot look like a community.

Changed

  • The activity page is rebuilt around depth rather than volume. Opening a pack, taking its query away, and downloading something to implement with are counted separately rather than summed, by distinct browsers rather than clicks. Each level carries its own caveat — the first says outright that opening a pack is not using it.
  • Depth indicators replace the coverage fractions: packs exercised (by the stricter definition), participants who went past reading, returning participants (active on more than one day — the hardest figure here to produce by accident), and countries.
  • Practitioner reports are presented as the strongest evidence, now including issue reports by kind and the number of distinct people who reported anything. A report that a pack did not run is described as being as valuable as one that it did, because it produces a correction.
  • A validation ledger shows the 30 combinations moving from automated-tests-only toward lab-validated and field-reported. This is project progress rather than usage and moves only when a pack is actually run.
  • The static "verification" cards were removed. They asserted properties rather than measuring anything, and the same claims are made where they belong — in the provenance section of the project information page.
  • Event vocabulary: template_downloaded split into kit_downloaded and profile_downloaded, so taking a machine-readable profile is distinguishable from taking the ZIP kit.

Deployment

  • Domain set to controlspec.org across the canonical URL, Open Graph tags, sitemap, robots, citation metadata, and the collector's allowed origin.

[0.11.1] — 2026-08-02

Fixed

  • The documented deployment would have recorded nothing, silently. metrics.js posts to the same-origin path /event and _headers restricts connect-src to 'self', but DEPLOY.md described deploying the collector as a standalone Worker on a *.workers.dev subdomain — a different origin. The POST would have 404'd against Pages, the CSP would have blocked the cross-origin alternative, and both failures are swallowed by design because instrumentation must never break the page. The site would have looked healthy while the activity page showed only the visitor's own local numbers.

Added

  • functions/ — the collector as Cloudflare Pages Functions (/event, /summary, /feedback, /feedback-summary), so the routes are genuinely same-origin with the site. No CORS, no CSP change, no routing rules.
  • worker/collector-core.mjs — one implementation of the request handling, shared by the Pages Functions and the standalone Worker so the validation rules cannot drift between them.
  • Origin enforcement on writes. CORS stops a browser reading a cross-origin response; it does not stop the request arriving. Without this, anyone could POST fabricated events and inflate the published totals — which matters more than usual for a project whose stated position is that its own numbers are not adoption evidence. Requests are refused unless their Origin is on the configured allowlist.
  • A per-participant hourly event cap (300), so a loop or a script cannot flood the activity figures.
  • tests/collector.test.js — 27 assertions covering validation, the origin check, free-text capping and control-character stripping, and that unexpected fields are dropped rather than stored.
  • Root wrangler.toml declaring the D1 binding in version control rather than as an unverifiable dashboard setting.

Changed

  • The GitHub Pages workflow no longer runs on push. It published a second copy with no CSP, no HSTS, and no collector routes, competing with the canonical domain. Kept as workflow_dispatch for a manual preview or fallback.
  • DEPLOY.md rewritten as a runbook with the same-origin requirement explained, verification commands for each failure mode, and a feedback review and deletion procedure.

[0.11.0] — 2026-08-02

Fixed

  • Nine technologies were reported as having no query available when they do. Okta, VMware, Veeam, Rubrik, Commvault, a custom backup tool, and the Git/ticketing/CMDB sources were all labelled "evidence-contract path — no platform-native query is published". That understated the coverage. The published Splunk searches deliberately read vendor-neutral sourcetypesdirectory:users, privileged:assignments, credential:inventory, backup:jobs, config:snapshot, network:rules — rather than any vendor's own tables. Forwarding a product into Splunk with the documented sourcetype and fields means the existing published query covers it with no new query written. Emit one event per backup job to index=ops sourcetype=backup:jobs with resource_id and status and the published CP-9 search covers Veeam.
  • The environment profile refused those combinations. Selecting Okta as the identity source reported "identity source mismatch" even on Splunk, where the query reads sourcetype=directory:users and does not care which directory produced it. Selecting Veeam reported CP-9 as having no adapter on Splunk for the same reason. Both are now assessed as supported on Splunk, with a note naming the sourcetype and fields to confirm. Azure and AWS read vendor-specific tables (IdentityInfo, iam_credential_report, AddonAzureBackupJobs) and remain strict.

Added

  • Google Cloud, as a cloud, an identity source (Cloud IAM), a backup source (Backup and DR), and an asset source (Cloud Asset Inventory) — covered through the Splunk and evidence-contract routes. No native BigQuery or Cloud Logging query family is published, a GCP-only profile enables the Splunk path and nothing else, and the profile says so in the option itself. A native GCP query family stays a roadmap item rather than being drafted from documentation without a project to execute it in.
  • A legend explaining what the badges mean, above the technology list, for readers who have never seen the project before. It states plainly that queries only ever run in Sentinel, AWS, or Splunk, and that everything else selected is either a source those queries read or a system evidence is exported from.
  • A "How this connects" disclosure on every technology, giving its role in the flow and the exact index, sourcetype, field names, or contract file involved — so "Via Splunk" is an instruction rather than a label.

Changed

  • Replaced the three adapter tiers with four routes — direct query, via Splunk, via CSV export, roadmap — which describe how a technology reaches a query rather than ranking it. A technology can have more than one route and is reported by its strongest.
  • Each technology now declares a role: queries run here, supplies platform data a query reads, or supplies evidence you provide. This answers why a CMDB is listed at all — it will never have a query, because supplying evidence is its job, and calling that a lesser tier was misleading.
  • CloudWatch Logs Insights is now the only technology with no route at all.

[0.10.1] — 2026-08-01

Fixed

  • The page-progress rail overlapped the content on mid-width windows. It was pinned with left: max(4px, calc((100vw - 1360px)/2 - 132px)) but only hidden below 1280px, so on any window between roughly 1280px and 1624px the max() clamped it to 4px and it sat on top of the hero text and stat cards. The rail is now positioned from the gutter width itself and centred within it, and it is not rendered at all unless the gutter can actually hold it — so it cannot overlap the content at any window size.

Changed

  • Replaced the static hero flow diagram with an interactive rail. The diagram explained the five stages but took a full screen-width band, repeated what the numbered steps below it already said, and never indicated which stage the content on screen belonged to. Its five stages now live in the right-hand rail, nested under the implementation guide, with the current step highlighted as you read and each step's explanation moved into its hover and focus popover. The rail is one control that answers both "what does a pack connect" and "where am I in it".
  • The rail degrades by width rather than disappearing at one breakpoint: full labels from 1760px, a dots-only spine with the labels in popovers from 1500px, and nothing below that — where the steps are already numbered in the content. Labels in the compact band are clipped rather than removed, so they stay in the accessible name.
  • The two notes the diagram carried are now where they belong: the tenant validation reminder in the hero, and "the objective stays fixed — the platform can change at any point" in the implementation guide heading.
  • The rail recalculates on resize and on any body size change (ResizeObserver), so collapsing a section or switching packs keeps the highlight correct.
  • Nested steps dim when the implementation guide is not the section being read, and are marked aria-hidden while that guide is collapsed.

[0.10.0] — 2026-08-01

Fixed

  • The default evidence mode failed its own preflight on 24 of 30 packs. Quick start (inline) mode replaces each supplied evidence source with the platform's literal-data construct — a KQL datatable, a SQL VALUES list, or makeresults format=csv. The preflight nevertheless validated every query against the referenced-mode source contract, so it reported the query as missing the exact table inline mode had just removed on purpose. Every pack with an evidence input showed a red "Preflight needs attention" on load, while the "Evidence source" check immediately below it showed green. The declared sources check is now evaluated against the contract that applies to the selected mode. Only IA-5(1) and AU-9, which have no evidence inputs, were unaffected. Reported by review of the default page state.

Added

  • Machine-readable implementation profile. Every pack now exports a structured JSON artifact carrying the control objective and its authoritative source, organization-defined parameters, query parameters and their applied values, the evidence contract, the platform implementation and its minimum permissions, the continuous-monitoring configuration, the bounded outcome vocabulary, the human-review boundary, the validation status, and the content hash of the published template. Available from the implementation snapshot and included in every implementation kit as implementation-profile.json. The artifact is OSCAL-informed and states in its own conformance block that it has not been validated against any OSCAL schema.
  • Per-pack validation status, validation date, and content hash, shown on the page, in the downloadable profile, and in the footer of the printed guide. validation-status.js records what has actually been executed where. As of this release no pack has been run against a live tenant, and all thirty control/platform combinations therefore report "Not tenant-validated".
  • pack-hashes.js, a generated SHA-256 manifest of all 30 packs and 5 evidence templates, so a downloaded profile can name the exact artifact it came from and any reader can re-derive the value with sha256sum. CI fails if it drifts from the files on disk.
  • Three-tier technology model replacing the previous supported/unconfirmed binary. published means a platform-native query family exists; contract means the technology has no native query but can feed a published evidence contract — an Okta export drives the privileged-role controls exactly as an Entra export does; roadmap means named and scoped but not built. Okta, VMware, Veeam, Rubrik, Commvault, a custom backup tool, and Git-based change history moved from "Manual / unconfirmed" to contract and now name the contract that carries them. CloudWatch Logs Insights is stated as roadmap.
  • Flow diagram on the landing page, as inline SVG, showing the five stages a pack connects and where the practitioner's evidence enters.
  • CONTRIBUTING.md, SECURITY.md, CITATION.cff, and this changelog.

Changed

  • All three platform paths are now always visible. Platforms outside the environment profile were previously hidden outright, so the default profile showed one platform button and made 20 of the 30 published queries unreachable without first discovering the profile editor. They are now shown locked, with the reason and a route back to the profile. The hero counter reads "3 platform paths · N in your profile" rather than reporting only the selected count.
  • Corrected ARIA on all three switch controls. Nine elements carried role="tab" with no aria-controls and no role="tabpanel" anywhere on the page. The evidence-mode and category switches are now complete tab patterns with associated panels, roving tabindex, and arrow-key navigation. The platform switch became a group of toggle buttons with aria-pressed, which is what it actually is — selecting a platform updates steps 2 through 5 and the snapshot, so no single panel could own it.
  • Tap targets below 24px enlarged: the inline-grid row delete control, the "View recommended controls" and "Choose another control" links, the tenant validation reference link, and the messiness slider.

Testing

  • tests/query-check.test.js now exercises both evidence modes across all 30 control/platform combinations. It previously tested only referenced mode, which is why the inline defect shipped. It also asserts that the inline relaxation is not a blanket pass: removing a platform-native source must still fail in either mode.
  • tests/profile-export.test.js added — asserts the profile structure for all 30 combinations, that the OSCAL disclaimer is present, that the default validation status is the honest one, and that inline evidence values a practitioner typed are never serialised into the artifact.
  • tests/verify-artifacts.js now verifies pack-hashes.js against the packs and templates on disk and rejects orphan entries.

[0.9.0] — 2026-07-31

Initial public release: 10 NIST SP 800-53 Rev. 5 control objectives across Microsoft Sentinel, AWS, and Splunk; 30 reference queries; 5 evidence contracts; 50 validated query parameters; inline and referenced evidence modes; deterministic browser simulation; implementation kits; and the printable implementation guide.