Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/verify-cloudflare-facts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ jobs:
--base dev \
--head "$BRANCH" \
--title "chore(verifier): cloudflare-facts value change (${DATE_TAG})" \
--body "Automated PR from verify-cloudflare-facts.yml — at least one verified fact changed or went absent at the upstream source. Review the diff; the privacy pages render this data verbatim. The BASELINE prose in docs/BASELINE_COPY.md may need a paired update."
--body "Automated PR from verify-cloudflare-facts.yml — at least one verified fact's value changed at the upstream source (or, for DPF, the listed organisation is no longer present). Review the diff; the privacy pages render this data verbatim. The BASELINE prose in docs/BASELINE_COPY.md may need a paired update."

- name: Open verifier-alert Issue on failure
if: ${{ steps.run.outputs.has_failure == 'true' && steps.run.outputs.dry_run == 'false' }}
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ possible; Pass 2 should follow it.
- Surface uncertainty as explicit open questions in plans, not resolved
silently.

## Process rules from Pass 1
## Process rules from Pass 1+2

Specific technical lessons worth codifying:

Expand Down Expand Up @@ -107,6 +107,7 @@ Specific technical lessons worth codifying:
carve-out only when a static page genuinely cannot serve the use case.
The `wrangler.jsonc` comment block carries the same posture statement
alongside its env-inheritance gotcha.
- **Workflow-permission verification for non-standard API endpoints (Pass 2 lesson).** When a GitHub Actions workflow change asserts that a `permissions:` scope (`actions: write`, `contents: write`, etc.) unlocks a given REST API endpoint, the assertion needs runtime verification — not just doc-reading. GitHub Actions' `permissions:` YAML enumerates a fixed list of scopes; not every API endpoint maps cleanly to one of those scopes. Variables in particular are not in the permissions list at all, and the default `GITHUB_TOKEN` returns HTTP 403 on `PATCH /repos/{owner}/{repo}/actions/variables/{name}` regardless of the declared scope. For any workflow change involving an API endpoint outside the well-known set (issues, pull-requests, contents), one of the following is required before merge: (a) runtime dispatch of the workflow against a real auth setup, (b) explicit citation of GitHub docs showing the endpoint accepts the declared token scope, or (c) a runbook step in the plan calling out "this requires a PAT, not `GITHUB_TOKEN`, because <reason>." The Pass 2 G D.10/11 chain shipped a 403-on-first-run bug because none of the three was performed.

## Skills

Expand Down
79 changes: 79 additions & 0 deletions docs/CSS_CONVENTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# CSS conventions — Blackbrowed Labs site

Codified during Phase J of Pass 3 (2026-05-12) as the first
multi-extraction CSS work crossed the threshold where conventions
needed to be explicit. Treat this doc as the authority for any
future CSS-sharing decision; deviate only with an explicit
rationale recorded in the deviating plan.

## 1. Module location

CSS shared across more than one page template or component lives at
`src/styles/<topic>.css`, where `<topic>` names the subtree
(`products.css`, `forms.css`, etc.). Each module is imported
exactly once via `@import` at the top of `src/styles/global.css`.
The whole site loads `global.css` (via `src/layouts/BaseLayout.astro`),
so the shared modules cascade everywhere.

Per-component or per-page CSS that is genuinely local — rules that
do not appear elsewhere in the codebase — stays in the
component's scoped `<style>` block. Astro 6's per-component
`<style>` scoping is intentional; do not retire it for rules that
have a single consumer.

## 2. Class naming

Class names use BEM-style namespacing: `block__element--modifier`,
where `block` corresponds to the high-level component or page
section (e.g. `products-index`, `product-detail`, `contact-form`).
Shared CSS modules in `src/styles/` declare each block's rules side
by side, without collapsing distinct blocks under a single generic
class.

The design bundle's `.btn` / `.field` global vocabulary
(`design/handoff-bundle/dev/components.html`) is reference-only.
Components do NOT consume those names as runtime classes; instead
each block re-implements the visual treatment under its own BEM
scope (`.contact-form__submit`, `.contact-form__field`, etc.). This
keeps markup stable across CSS reorganizations and avoids
fragile cross-component class coupling.

## 3. Scope of extraction

Extraction is full-block: a duplicate-across-files `<style>` block
moves to `src/styles/<topic>.css` in its entirety, including
layout-affecting rules (max-width, margin, padding, grid / flex
containers). Page-private rules — rules that genuinely differ
between consumers — stay in the page's residual scoped block. If a
page has zero page-private rules after extraction, the scoped
`<style>` block is removed entirely.

The trigger for extraction is the appearance of a third byte-
identical scoped block. Two consumers (a single DE/EN sibling pair)
is review discipline territory — co-locate the styles and review the
two scoped blocks together. Three or more is shared-module territory.

## 4. Specificity discipline

Shared modules contain only single-class or single-element
selectors — no `:where()`, no `!important`, no descendant chains
deeper than two levels. Where a page genuinely needs to override a
shared rule, the override lives in the page's residual scoped
`<style>` block (which Astro automatically scopes higher than
global module CSS via its scoped-class suffix). Global modules
should never need to "win" over scoped pages.

## 5. Module-import order in `global.css`

```css
@import "tailwindcss";
@import "./tokens.css";
@import "./<topic-1>.css";
@import "./<topic-2>.css";
/* ... base element styles below */
```

Order: tokens always first (every other module depends on the
custom properties); shared modules in alphabetical order after.
Tailwind's `@import "tailwindcss"` stays first because Tailwind 4's
`@theme` directives must register before `@theme` consumers.
2 changes: 1 addition & 1 deletion docs/TECH_STACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ The repo's `.gitignore` is at the root and covers three categories of files:
- Block force pushes: enabled
- Other toggles intentionally off — `dev` is an integration branch and direct commits are expected

- **Status checks deliberately not required:** "Require status checks to pass" is off on `protect-main` because GitHub requires at least one specific check to be named, and we don't yet have a PR-triggered CI workflow (the deploy workflows are push-triggered, not PR-triggered). Pass 2 backlog item #5: add a PR-triggered CI workflow (`npm ci && npm run build` at minimum) and add it as a required status check on `protect-main`.
- **Required status checks:** `protect-main` requires the `build-pr` check from `.github/workflows/ci-pr.yml` to pass before merge (added 2026-04-28, ruleset id `15468856`). The workflow runs `npm ci` → `npm run check` → `npm run build` on every PR targeting `main`, plus a warn-only verifier-freshness probe (60-day soft threshold, 90-day hard gate enforced separately in the deploy and nightly workflows). `strict_required_status_checks_policy: true` requires the check to have run against the PR's latest head SHA, not a stale one. The job name `build-pr` is what's registered with GitHub Actions and what the ruleset references — renaming the job would invalidate the binding and require a re-flip.

- **Default branch:** `main`. Changed from `dev` to `main` at the same time as ruleset configuration. Workflow is now: feature work on `dev` → auto-deploy to staging (`dev.blackbrowedlabs.com`) → PR `dev` → `main` → auto-deploy to production (`blackbrowedlabs.com`).

Expand Down
3 changes: 3 additions & 0 deletions scripts/build-headers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
* the PUBLIC_ENVIRONMENT environment variable. Runs before `astro build`
* via the "build" npm script.
*
* Run from the project root (as `npm run build` does); paths resolve
* against `process.cwd()`.
*
* - production: long-lived caching for static assets; no robots tag.
* - staging (or anything else): same caching + X-Robots-Tag: noindex,
* nofollow on every path. Triad member 2 of 3 (meta tag + robots.txt
Expand Down
56 changes: 56 additions & 0 deletions scripts/checks/__fixtures__/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Verifier fixture test matrix

Each fixture under this directory exercises a specific branch in a
check module. Run any single scenario via:

```bash
MOCK_SCENARIO=<fixture-stem> node scripts/run-verifier.mjs --dry-run
```

The orchestrator routes the named fact to its matching fixture and
uses the default `*-active` fixture for the sibling fact (so every run
exercises both checks, but only one is the scenario under test).

## DPF (`scripts/checks/dpf.mjs`)

| Fixture | Branch triggered | Expected `dpf.status` | Notes |
|---|---|---|---|
| `dpf-active.json` | Happy path | `ok` | Cloudflare present in active EU-cert rows |
| `dpf-absent.json` | Mode #4 (org missing) | `absent` | Structural markers OK; Cloudflare not in active rows |
| `dpf-parser-broken.json` | Branch A — missing structural markers | `parser-broken` | Tiny `{"error": "deprecated"}` shape |
| `dpf-parser-broken-empty.json` | Branch C — `orgs.length === 0` | `parser-broken` | Structural markers OK; zero rows pass the EU-cert filter |
| (no fixture) | Branch B — `parseActiveOrgs` throws | `parser-broken` | **JSON-fixture-unreachable by construction.** The throw branch is only reachable via a JS-runtime mutation that JSON cannot encode. If a future refactor adds an explicit throw inside `parseActiveOrgs` on a malformed-but-encodable input, add a `dpf-parser-broken-malformed.json` fixture then. |

> **Load-bearing coercion (do not silently remove).** The
> `String(p.OrganizationPublicDisplayName ?? '')` coercion at
> `scripts/checks/dpf.mjs:170` is what makes Branch B unreachable from
> any JSON-encodable input — every JSON shape (string, number, boolean,
> null, undefined, missing key) is coerced to a string before
> `.trim().toLowerCase()` runs, so the `.map` step cannot throw on
> JSON data. Any future implementer who edits that line MUST update
> this matrix: the Branch B "JSON-fixture-unreachable" classification
> depends on the coercion staying in place.

## CWA retention (`scripts/checks/cwa-retention.mjs`)

| Fixture | Branch triggered | Expected `cwa_retention.status` | Notes |
|---|---|---|---|
| `cwa-active.html` | Happy path | `ok` | Aggregated retention parses to the cached value |
| `cwa-changed-figure.html` | Mode #6 — figure shift | `changed` | Aggregated retention parses to a different number |
| `cwa-parser-broken.html` | Mode #5 — pattern miss | `parser-broken` | Page content present but no retention figure findable |

## Live-network mode

Run without `MOCK_SCENARIO` to fetch the live DPF API and the live CWA
docs page. Done in CI only (the weekly cron). Not generally exercised
in local development.

## Notes on adding new fixtures

- DPF fixture extension: `.json`. CWA fixture extension: `.html`. The
check module declares its `fixtureExtension` export; the orchestrator
reads it.
- Fixture stem must start with the check module's `scenarioPrefix`
(`dpf-` or `cwa-`) for the orchestrator to route correctly.
- Sibling fact uses the check module's `defaultFixture` (`dpf-active`
or `cwa-active`) when the scenario-under-test names the other fact.
11 changes: 11 additions & 0 deletions scripts/checks/__fixtures__/dpf-parser-broken-empty.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"RecordCount": 1,
"SumCount": 1,
"Items": [
{
"OrganizationPublicDisplayName": "Example Org With Only Swiss Cert",
"EUCert": null,
"SwissCert": { "PublicStatus": "Active" }
}
]
}
4 changes: 2 additions & 2 deletions scripts/checks/cwa-retention.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* src/data/cloudflare-facts.json (specifically: aggregated retention
* months, and the absence of a published raw-events retention period).
*
* Source: https://developers.cloudflare.com/web-analytics/data-retention/
* Source: https://developers.cloudflare.com/web-analytics/faq/
*
* Source-of-truth: plans/active/pass-2/g-d-2/spec.md §6 (interface) and
* §3.5/§3.6 (mode #5 / mode #6 detection).
Expand Down Expand Up @@ -76,7 +76,7 @@ function stripTags(html) {
const NUMBER_WORDS = {
one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8,
nine: 9, ten: 10, eleven: 11, twelve: 12, eighteen: 18, twenty: 20,
twentyfour: 24, 'twenty-four': 24,
'twenty four': 24, 'twenty-four': 24,
};

function toMonths(numStr, unit) {
Expand Down
37 changes: 27 additions & 10 deletions scripts/checks/dpf.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
*
* Source-of-truth: plans/active/pass-2/g-d-10/g-d-10-2/dpf-investigation.md
* (endpoint, method, body, JSON field paths) and plans/active/pass-2/g-d-2/
* spec.md §6.3 / §3.2 / §3.4 (mode #2 / mode #4 detection contract).
* spec.md §3.2 / §3.4 / §9.1 / §9.3 (mode #2 / mode #4 detection contract,
* error-handling style, parser-shape constraints).
* Runs as a registered check inside scripts/run-verifier.mjs.
*
* Failure-mode coverage:
Expand Down Expand Up @@ -40,23 +41,39 @@ export const expectedValue = 'Cloudflare, Inc.';
export const fixtureExtension = 'json';

// Verifier-only endpoint. Anonymous, no cookies, no Origin/Referer
// gating today (verified in g-d-10-2/dpf-investigation.md). Substring
// `Search: "Cloudflare"` filter is a verifier-side optimisation — the
// active-status check happens server-side via `Status: "Active"` and is
// independent of the search term. If Cloudflare ever rebrands or the
// upstream changes their search semantics to exact-match, the verifier
// flips to `absent` (mode #4) loudly; deliberate trade per the
// investigation doc.
// gating today (verified in g-d-10-2/dpf-investigation.md). Phase G.3
// widened the filter from `Search: "Cloudflare"` + `RowsPerPage: 10`
// to `Search: ""` + `RowsPerPage: 5000` (one request that returns the
// full active list, currently ~3,600 rows / ~1.8 MB / ~2.7 s) plus an
// in-process substring filter on 'cloudflare' inside parseActiveOrgs.
// This survives a Cloudflare rebrand or any upstream change in
// `Search` semantics — provided the new name still contains the
// substring 'cloudflare'. Trade: ~5 KB payload + ~0.8 s becomes
// ~1.8 MB + ~2.7 s per weekly cron run, in exchange for rebrand
// robustness. Pagination is supported but not used — the API returns
// the whole active list in one response. See
// plans/active/pass-3/g/dpf-pagination-investigation.md for the
// probe-by-probe evidence and the EUCert.PublicStatus distribution
// (Active=2,960; Active-Re-cert=638 — the verifier still treats only
// strict "Active" as live).
const API_ENDPOINT = 'https://dpfapi.azurewebsites.net/api/participants';
// Investigation: plans/active/pass-3/g/dpf-pagination-investigation.md
// confirms RowsPerPage values >= SumCount return the complete active
// list in a single ~2.7 s request (SumCount ~3,600 at probe time).
// 5000 gives ~40% headroom for active-list growth before the verifier
// would need to revisit. Search:'' returns the unfiltered list; the
// substring match for 'cloudflare' happens in parseActiveOrgs()
// below — rebrand-robust within the constraint that the new name
// still contains the substring 'cloudflare'.
const REQUEST_BODY = {
DataCovered: [],
Frameworks: [],
Industries: [],
PageNumber: 0,
RecourseMechanisms: [],
StatutoryBody: [],
RowsPerPage: 10,
Search: 'Cloudflare',
RowsPerPage: 5000,
Search: '',
StartLetter: '',
Status: 'Active',
States: [],
Expand Down
3 changes: 3 additions & 0 deletions scripts/extract-tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
*
* Runs after `astro build` in the npm "build" script so the file lands
* only in the build artefact (dist/) and is not committed.
*
* Run from the project root (as `npm run build` does); paths resolve
* against `process.cwd()`.
*/

import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
Expand Down
3 changes: 3 additions & 0 deletions scripts/run-verifier.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
* `has_value_diff`, `has_failure`) so the workflow can route to the
* matching notification channel (silent variable refresh, PR, or Issue).
*
* Run from the project root (as the verify-cloudflare-facts.yml
* workflow does); paths resolve against `process.cwd()`.
*
* Source-of-truth: plans/active/pass-2/g-d-2/spec.md (original design) +
* plans/active/pass-2/g-d-10/plan.md G D.11.1 (alert-model rewrite).
*
Expand Down
Loading
Loading