diff --git a/.github/workflows/validate-public-mirror.yml b/.github/workflows/validate-public-mirror.yml new file mode 100644 index 0000000..9def981 --- /dev/null +++ b/.github/workflows/validate-public-mirror.yml @@ -0,0 +1,38 @@ +name: Validate Public Mirror + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + validate-public-mirror: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Run cross-platform public mirror validator + shell: pwsh + run: | + python scripts/validate-public-mirror.py *> public-mirror-validation.log + $exitCode = $LASTEXITCODE + Get-Content public-mirror-validation.log + if ($exitCode -ne 0) { + Write-Error "Public mirror validation failed with exit code $exitCode" + exit $exitCode + } + - name: Upload public mirror validation log + if: always() + uses: actions/upload-artifact@v4 + with: + name: public-mirror-validation-log + path: public-mirror-validation.log diff --git a/README.md b/README.md index b4f7290..20bbc4c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Clear Lake Watch is a late-prototype / early-MVP public dashboard for organizing It is not official public-health guidance, an official advisory, a validated forecast, or a deployed sensor network. +This public mirror is a static reviewed snapshot generated on May 5, 2026, and it is not live lake conditions. + **Live public mirror:** [coreytshaffer.github.io/clear-lake-watch](https://coreytshaffer.github.io/clear-lake-watch/) ![Clear Lake Watch dashboard preview](assets/clear-lake-watch-preview.png) @@ -19,6 +21,8 @@ It is not official public-health guidance, an official advisory, a validated for - Private/local records, reviewer notes, raw field records, and trusted-review paths are excluded from the public mirror. - Dashboard is not official guidance, a health advisory, a validated forecast, or a deployed sensor network. +See [`docs/epistemic_safety_case.md`](docs/epistemic_safety_case.md) for the project’s reviewer-facing explanation of freshness, provenance, warning-preservation, and fail-closed publication boundaries. + ## Reviewer Start Here For a 5-minute review: diff --git a/app.js b/app.js index 767b34d..dcce048 100644 --- a/app.js +++ b/app.js @@ -23,6 +23,7 @@ const mapReviewStatusElement = document.querySelector("#map-review-status"); const siteReviewGridElement = document.querySelector("#site-review-grid"); const productGridElement = document.querySelector("#product-grid"); const sourceStatusGridElement = document.querySelector("#source-status-grid"); +const sourceFreshnessLegendElement = document.querySelector("#source-freshness-legend"); const sourceOutputGridElement = document.querySelector("#source-output-grid"); const manifestNotesElement = document.querySelector("#manifest-notes"); const weatherContextGridElement = document.querySelector("#weather-context-grid"); @@ -1436,11 +1437,17 @@ const renderDataProducts = (products = []) => { }; const renderSourceStatus = (manifestData) => { - if (!sourceStatusGridElement && !sourceOutputGridElement && !manifestNotesElement) { + if ( + !sourceStatusGridElement && + !sourceFreshnessLegendElement && + !sourceOutputGridElement && + !manifestNotesElement + ) { return; } sourceStatusGridElement?.replaceChildren(); + sourceFreshnessLegendElement?.replaceChildren(); sourceOutputGridElement?.replaceChildren(); manifestNotesElement?.replaceChildren(); @@ -1453,6 +1460,29 @@ const renderSourceStatus = (manifestData) => { return; } + const freshnessLegend = Array.isArray(manifestData.freshnessLegend) + ? manifestData.freshnessLegend + : []; + + freshnessLegend.forEach((entry) => { + const item = document.createElement("article"); + item.className = "source-freshness-item"; + + const term = document.createElement("h4"); + term.textContent = entry.term ?? "Freshness field"; + + const field = document.createElement("p"); + field.className = "source-status-meta"; + field.textContent = entry.field ? `Manifest field: ${entry.field}` : "Manifest field: not specified"; + + const definition = document.createElement("p"); + definition.className = "source-status-note"; + definition.textContent = entry.definition ?? "Freshness meaning is not defined in the manifest."; + + item.append(term, field, definition); + sourceFreshnessLegendElement?.append(item); + }); + manifestData.sources.forEach((source) => { const card = document.createElement("article"); const status = source.status ?? "unknown"; @@ -1487,7 +1517,7 @@ const renderSourceStatus = (manifestData) => { freshness.textContent = source.resourceAgeDays === null || source.resourceAgeDays === undefined ? source.note - : `Resource file age: ${source.resourceAgeDays} days. ${source.note}`; + : `Resource freshness: source file was ${source.resourceAgeDays} days old at refresh. Observation freshness is listed separately above. ${source.note}`; card.append(title, statusBadge, meta, date, freshness); sourceStatusGridElement?.append(card); @@ -1590,7 +1620,7 @@ const renderSnapshotStatusStrip = (manifestData) => { value: fhabsReports?.latestObservationDate ? formatDate(fhabsReports.latestObservationDate) : "Unavailable", - note: "Report dates reflect what is present in the public FHABS source file.", + note: "Observation freshness from FHABS report records; this is separate from source-file freshness.", kind: "reported", }, { @@ -1598,7 +1628,7 @@ const renderSnapshotStatusStrip = (manifestData) => { value: fhabsResults?.latestObservationDate ? formatDate(fhabsResults.latestObservationDate) : "Unavailable", - note: "Lab-linked result records may lag the public report stream.", + note: "Observation freshness from lab-linked records; source-resource age is tracked separately.", kind: "reported", }, { @@ -1643,7 +1673,7 @@ const renderFreshnessBadge = (generatedAt, { unavailable = false } = {}) => { note.className = "freshness-note"; note.textContent = unavailable ? "The public data bundle could not be loaded, so snapshot values are not being shown." - : "Source observation dates may be older than the dashboard refresh time."; + : "Observation freshness can be older than dashboard refresh time; resource freshness only says whether the source file was recently accessible."; freshnessRowElement.append(badge, note); }; diff --git a/data/manifest.json b/data/manifest.json index a941303..ab5ae5c 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -1,90 +1,116 @@ { - "generatedAt": "2026-05-05T01:44:50.0255703-07:00", - "schemaVersion": 1, - "dashboard": "Clear Lake Watch", - "status": "ok", - "sourceFreshnessMaxAgeDays": 14, - "sources": [ - { - "id": "usgs-lake-level", - "label": "USGS Lakeport lake level", - "source": "USGS", - "status": "ok", - "rowCount": 29, - "latestObservationDate": "2026-05-03", - "station": "11450000", - "parameter": "00065", - "note": "USGS daily values used for the Lakeport lake-level card and hydrology series." - }, - { - "id": "usgs-cole-creek-discharge", - "label": "USGS Cole Creek discharge", - "source": "USGS", - "status": "ok", - "rowCount": 29, - "latestObservationDate": "2026-05-03", - "station": "11449820", - "parameter": "00060", - "note": "USGS daily values used for tributary-flow context and hydrology series." - }, - { - "id": "fhabs-bloom-reports", - "label": "FHABS bloom reports", - "source": "California Water Boards FHABS", - "status": "ok", - "rowCount": 3429, - "clearLakeRowCount": 230, - "latestObservationDate": "2025-09-07", - "resourceUrl": "https://data.ca.gov/dataset/ab672540-aecd-42f1-9b05-9aad326f97ec/resource/c6a36b91-ad38-4611-8750-87ee99e497dd/download/bloom-report_2026-05-04.csv", - "resourceDate": "2026-05-04", - "resourceAgeDays": 1, - "note": "Public report records filtered to Clear Lake and normalized into reports, map markers, arm summaries, and report-pattern analytics." - }, - { - "id": "fhabs-results", - "label": "FHABS lab results", - "source": "California Water Boards FHABS", - "status": "ok", - "rowCount": 2080, - "clearLakeRowCount": 169, - "latestObservationDate": "2024-01-11", - "resourceUrl": "https://data.ca.gov/dataset/ab672540-aecd-42f1-9b05-9aad326f97ec/resource/9d4e1df4-0cd6-4165-9e63-effcafd9dccc/download/hab-results_2026-05-04.csv", - "resourceDate": "2026-05-04", - "resourceAgeDays": 1, - "note": "Public lab-linked result records filtered to Clear Lake and normalized into observation-shaped records." - } - ], - "outputs": [ - { - "file": "data/live.json", - "recordCount": 13, - "description": "Dashboard live snapshot cards, map markers, analytics bundle, and data product index." - }, - { - "file": "data/reports.json", - "recordCount": 230, - "description": "Normalized FHABS Clear Lake report records." - }, - { - "file": "data/observations.json", - "recordCount": 62, - "description": "Normalized USGS and FHABS observation records." - }, - { - "file": "data/sites-normalized.json", - "recordCount": 11, - "description": "Normalized stable site registry export." - }, - { - "file": "data/analytics.json", - "recordCount": 20, - "description": "Precomputed reporting-pattern and observation-coverage analytics." - } - ], - "notes": [ - "Observation dates may be older than the dashboard generation time.", - "Lakeport lake-level values are shown as feet Rumsey, with approximate elevation calculated using Zero Rumsey = 1318.256 ft above mean sea level.", - "FHABS report counts represent reporting activity, not direct bloom intensity.", - "This manifest describes the generated public-data snapshot and is not official public-health guidance." - ] -} \ No newline at end of file + "generatedAt": "2026-05-05T01:44:50.0255703-07:00", + "schemaVersion": 1, + "dashboard": "Clear Lake Watch", + "status": "ok", + "sourceFreshnessMaxAgeDays": 14, + "sources": [ + { + "id": "usgs-lake-level", + "label": "USGS Lakeport lake level", + "source": "USGS", + "status": "ok", + "rowCount": 29, + "latestObservationDate": "2026-05-03", + "station": "11450000", + "parameter": "00065", + "note": "USGS daily values used for the Lakeport lake-level card and hydrology series." + }, + { + "id": "usgs-cole-creek-discharge", + "label": "USGS Cole Creek discharge", + "source": "USGS", + "status": "ok", + "rowCount": 29, + "latestObservationDate": "2026-05-03", + "station": "11449820", + "parameter": "00060", + "note": "USGS daily values used for tributary-flow context and hydrology series." + }, + { + "id": "fhabs-bloom-reports", + "label": "FHABS bloom reports", + "source": "California Water Boards FHABS", + "status": "ok", + "rowCount": 3429, + "clearLakeRowCount": 230, + "latestObservationDate": "2025-09-07", + "resourceUrl": "https://data.ca.gov/dataset/ab672540-aecd-42f1-9b05-9aad326f97ec/resource/c6a36b91-ad38-4611-8750-87ee99e497dd/download/bloom-report_2026-05-04.csv", + "resourceDate": "2026-05-04", + "resourceAgeDays": 1, + "note": "Public report records filtered to Clear Lake and normalized into reports, map markers, arm summaries, and report-pattern analytics." + }, + { + "id": "fhabs-results", + "label": "FHABS lab results", + "source": "California Water Boards FHABS", + "status": "ok", + "rowCount": 2080, + "clearLakeRowCount": 169, + "latestObservationDate": "2024-01-11", + "resourceUrl": "https://data.ca.gov/dataset/ab672540-aecd-42f1-9b05-9aad326f97ec/resource/9d4e1df4-0cd6-4165-9e63-effcafd9dccc/download/hab-results_2026-05-04.csv", + "resourceDate": "2026-05-04", + "resourceAgeDays": 1, + "note": "Public lab-linked result records filtered to Clear Lake and normalized into observation-shaped records." + } + ], + "outputs": [ + { + "file": "data/live.json", + "recordCount": 13, + "description": "Dashboard live snapshot cards, map markers, analytics bundle, and data product index." + }, + { + "file": "data/reports.json", + "recordCount": 230, + "description": "Normalized FHABS Clear Lake report records." + }, + { + "file": "data/observations.json", + "recordCount": 62, + "description": "Normalized USGS and FHABS observation records." + }, + { + "file": "data/sites-normalized.json", + "recordCount": 11, + "description": "Normalized stable site registry export." + }, + { + "file": "data/analytics.json", + "recordCount": 20, + "description": "Precomputed reporting-pattern and observation-coverage analytics." + } + ], + "notes": [ + "Observation dates may be older than the dashboard generation time.", + "Resource freshness and observation freshness are separate checks.", + "Lakeport lake-level values are shown as feet Rumsey, with approximate elevation calculated using Zero Rumsey = 1318.256 ft above mean sea level.", + "FHABS report counts represent reporting activity, not direct bloom intensity.", + "This manifest describes the generated public-data snapshot and is not official public-health guidance." + ], + "fieldDefinitions": { + "generatedAt": "Dashboard snapshot generation time. This describes when the public JSON files were built, not when lake conditions were observed.", + "sourceFreshnessMaxAgeDays": "Review threshold used by validation to warn when source observations are older than the dashboard freshness window.", + "latestObservationDate": "Observation freshness field. This is the newest environmental observation date available for a source within the public snapshot.", + "resourceDate": "Resource freshness field for downloadable source files. This is the date embedded in the source file URL or release, not an environmental observation date.", + "resourceAgeDays": "Resource freshness field for downloadable source files. This is how old the source file was when the dashboard snapshot was generated.", + "status": "Machine-readable source load status for the snapshot refresh pass." + }, + "freshnessLegend": [ + { + "term": "Dashboard snapshot freshness", + "field": "generatedAt", + "definition": "When Clear Lake Watch generated these public JSON files." + }, + { + "term": "Observation freshness", + "field": "latestObservationDate", + "definition": "How recent the newest environmental record is for a source." + }, + { + "term": "Resource freshness", + "field": "resourceDate / resourceAgeDays", + "definition": "How recent the downloaded source file is, separate from the age of the observations inside it." + } + ] +} diff --git a/docs/architecture_sketch.md b/docs/architecture_sketch.md new file mode 100644 index 0000000..1987846 --- /dev/null +++ b/docs/architecture_sketch.md @@ -0,0 +1,21 @@ +# Architecture Sketch + +This sketch is intentionally lightweight. It shows the trust boundary for future sensor data without implying a deployed pipeline. + +```mermaid +flowchart LR + A["Field Sensor"] --> B["Local Gateway"] + B --> C["MQTT Topic"] + C --> D["Intake Validator"] + D -->|invalid or broken| E["Quarantine / Dead Letter"] + D -->|passes deterministic checks| F["Review Queue"] + F -->|approved for internal use| G["Internal Store"] + G -->|reviewed public-safe export only| H["Clear Lake Watch Public Layer"] +``` + +## Boundary Notes + +- The local gateway and MQTT layer are internal transport surfaces. +- The intake validator is where fail-closed behavior starts. +- Quarantine and dead-letter paths preserve auditability without turning bad records into public claims. +- Clear Lake Watch should read only reviewed public-safe exports, not raw sensor traffic. diff --git a/docs/deployment.md b/docs/deployment.md index 5322e64..48c9787 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -18,13 +18,9 @@ If a local server is already running on port `4173`, run the full endpoint check powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-dashboard.ps1 ``` -If you are intentionally reviewing an older static snapshot for portfolio or archival work, make that choice explicit: +If you are intentionally reviewing an older static snapshot for portfolio or archival work, make that choice explicit in the release note and README rather than relying on a CLI override. There is no `-AllowStaleSnapshot`-style flag today: the validator warns on stale snapshots but does not block or force-override publication, so the reviewer must decide. -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-dashboard.ps1 -SkipHttp -AllowStaleSnapshot -``` - -Do not use `-AllowStaleSnapshot` for a fresh public publish unless the stale date is explained in the release notes. +Do not publish a stale snapshot as if it were fresh. If you are intentionally preserving an older static snapshot, name the snapshot date and the reason in the release notes. Use `docs/publication-review-checklist.md` before staging, committing, pushing, or promoting the dashboard. That checklist separates local review, private repository work, public mirror updates, and flagship portfolio promotion. @@ -32,7 +28,7 @@ Current working posture as of May 5, 2026: - The public snapshot was refreshed locally on May 5, 2026. - Keep publication as a separate decision from local refresh. -- Do not use `-AllowStaleSnapshot` for a fresh public publish unless the stale date is explained in the release notes. +- Do not publish a stale snapshot as fresh; if a static snapshot is preserved intentionally, name its date and reason in the release notes. (No CLI flag currently gates this — it is a manual reviewer decision.) - Capture a current screenshot before broad portfolio promotion. ## GitHub Pages diff --git a/docs/epistemic_safety_case.md b/docs/epistemic_safety_case.md new file mode 100644 index 0000000..446cec3 --- /dev/null +++ b/docs/epistemic_safety_case.md @@ -0,0 +1,42 @@ +# Epistemic Safety Case + +## Purpose +Clear Lake Watch is designed as an environmental-data trust pipeline. This prototype explicitly demonstrates **epistemic safety**: when source freshness, provenance, confidence, or publication context is weak, the validator surfaces stale-data warnings and requires manual review before any republish rather than producing a clean-looking but misleading public output. Automated stale-data blocking (a hard publication gate) is a design goal, not yet an implemented control. + +## Existing Safety-Relevant Mechanics +This repository natively embeds trust and provenance safeguards throughout its architecture: +* `scripts/validate-public-mirror.py`: The build verification script ensures that public deployment bounds are strictly enforced. +* `assert_manifest_freshness`: Ensures that any published data provides the necessary metadata context. +* `sourceFreshnessMaxAgeDays` & `freshnessLegend`: Establishes the rules for how stale data can be displayed, ensuring users are never misinformed about the data's recency. +* `docs/publication-review-checklist.md` & `docs/scheduled-public-refresh-design.md`: Document the rigorous publication gates preventing automated overrides of uncertain environmental data. +* `docs/site-registry-trust-review-pass-001.md`: Tracks the review state of geographical source markers. +* `needs-local-review`: A status preventing untrusted markers from being promoted to authoritative without explicit evidence. +* Stale-data handling today: the validator emits warnings when a snapshot or source observation is older than the freshness threshold, and publication remains a manual, reviewed step. (A `-AllowStaleSnapshot`-style override gate is **proposed / not implemented**; no CLI flag currently blocks or force-overrides stale publication.) + +## Environmental Data Boundary Conditions & Pipeline Responses + +The following table maps critical epistemic failures to their intended safe system behaviors. The status column distinguishes what the validator enforces today from what is a documented design goal: + +| Boundary Condition | Intended Safe Behavior | Status Today | +| :--- | :--- | :--- | +| **Stale source** | Preserve stale-source warning | Implemented: validator warns and keeps stale-source language visible | +| **Missing provenance** | Require local review (e.g. `needs-local-review`) | Implemented: markers stay `needs-local-review` | +| **Conflicting advisory metadata** | Fail closed | Proposed / not implemented | +| **Simulated data routed toward public output** | Block public promotion | Partially implemented: reviewed-export field guards + private/local exclusion checks | +| **Publication confidence too weak** | Surface warnings and require manual review before republish | Implemented as a manual review step; automated stale-data blocking (a `-AllowStaleSnapshot`-style gate) is **proposed / not implemented** | + +The validator surfaces stale-data warnings and requires manual review before any republish; automated stale-data blocking is not yet implemented. + +## Why Sabotage by Omission Matters +In environmental and public-interest systems, overt malicious action is rare. The most significant threat is **sabotage by omission**—failing to expose uncertainty, hiding the true age of data, removing caveats, or promoting unreviewed simulated results to look like field evidence. By embedding freshness validation and review gating directly into the pipeline, this prototype guards against silently deceiving the public. + +## Non-Claims +To remain strictly within its prototype boundaries, this project makes the following explicit non-claims: +* Clear Lake Watch is **not** official public health guidance. +* It is **not** regulatory monitoring. +* It is **not** a replacement for California FHABS, Lake County Water Resources, or other agency advisories. +* It does **not** claim comprehensive water-quality coverage. +* It is purely a prototype demonstrating reviewable environmental-data trust boundaries. + +## Future Work +Future iterations may explore expanding the `freshnessLegend` to handle varying levels of automated confidence, explicitly testing simulation-bounds for future modeling tasks, and further integrating with the `agent-control-evals` framework to formally score pipeline decisions. diff --git a/docs/field_sensor_readiness_packet.md b/docs/field_sensor_readiness_packet.md new file mode 100644 index 0000000..30167c5 --- /dev/null +++ b/docs/field_sensor_readiness_packet.md @@ -0,0 +1,199 @@ +# Field Sensor Readiness Packet + +**Status:** planning and governance artifact +**Audience:** operators, reviewers, collaborators, and future field-monitoring contributors +**Prepared:** 2026-06-25 + +Clear Lake Watch is a late prototype / early MVP. It is not official public-health guidance, an official advisory, recreation guidance, emergency guidance, a validated forecast, a deployed sensor network, or a live ingestion platform. + +This packet defines what future field sensor data would need before it could support Clear Lake Watch or any related public environmental reporting. It is documentation-first on purpose. The goal is to make future sensor work governable before it becomes functional. + +## Purpose + +- Define the minimum trust boundaries for future sensor observations. +- Separate raw telemetry from validated and publishable environmental records. +- Preserve provenance, freshness, uncertainty, and reviewability. +- Keep the public dashboard fail-closed unless reviewed exports are ready. + +## Relationship To Clear Lake Watch + +Clear Lake Watch is the reviewed publication mirror, not the operational sensor backbone. + +The intended operating path remains: + +```text +sensor or field device -> local gateway -> intake validation -> review or quarantine -> reviewed export -> static public mirror +``` + +This packet does not authorize direct sensor-to-dashboard publishing. A sensor reading is not automatically environmental truth. It must pass validation, provenance, freshness, and review checks before public display. + +## Data Lifecycle + +### 1. Raw Observation + +A raw observation is the first structured record received from a device or gateway. + +Properties: + +- may contain transport errors or stale timestamps +- may have bad units or impossible values +- may come from an unknown or misconfigured sensor +- is not publishable + +### 2. Auto-Checked Observation + +An auto-checked observation passed deterministic checks such as: + +- required fields present +- timestamp parseable +- sensor and station IDs known +- coordinates within expected bounds +- parameter and unit pair allowed +- value inside instrument or domain sanity ranges + +Auto-checked is still not public approval. + +### 3. Human-Review Observation + +Some records require a reviewer before any downstream use. + +Examples: + +- borderline but not impossible value +- stale reading that may still matter for internal diagnostics +- duplicate event with inconsistent metadata +- changed sensor calibration status +- station relocation or coordinate drift + +### 4. Reviewed Internal Observation + +A reviewed internal observation may support local analysis, QA checks, or operator dashboards, but it still does not automatically belong on the public mirror. + +### 5. Approved Public Observation + +A public observation must: + +- have clear provenance +- have valid or explicitly reviewed quality status +- have acceptable freshness for its intended use +- avoid private fields +- avoid overstating scientific certainty +- fit a reviewed public-safe export contract + +## Raw Vs Validated Vs Publishable + +| State | Meaning | Public use | +| --- | --- | --- | +| Raw | Received but not trusted yet | Never publish | +| Validated | Deterministic checks passed | Internal only unless reviewed | +| Publishable | Reviewed, bounded, provenance-clear, and contract-compliant | May enter a reviewed public export | + +## Freshness Boundaries + +Future sensor work must keep the same distinction already used in Clear Lake Watch: + +- resource freshness: whether the export or message bundle is recent enough to trust as an intake artifact +- observation freshness: when the environmental reading was actually observed + +A recent file or recent MQTT delivery does not make an old reading current. A fresh observation in a delayed export is also not the same thing as live public monitoring. Both timestamps must remain visible to operators. + +## QA/QC Gates + +Minimum deterministic gates before internal use: + +- observation ID is unique +- sensor ID is known +- station ID is known +- `observed_at` and `received_at` are parseable +- `received_at` is not earlier than `observed_at` +- parameter is from an approved list +- unit matches the parameter contract +- coordinates are present and plausible for the intended station +- value is within parameter sanity limits +- review status starts in a non-public state + +Recommended parameter sanity examples: + +- water temperature should not be below freezing for an unfrozen lake sample without explanation +- dissolved oxygen should not be negative +- pH should stay within physically plausible freshwater bounds +- conductivity should use an expected unit family such as `uS/cm` + +## Quarantine Rules + +A record should be quarantined when any of these occur: + +- impossible value +- stale reading beyond the allowed observation window +- missing required timestamp +- invalid unit +- unknown sensor or station +- coordinates outside the approved region +- duplicate observation ID +- broken JSON or missing required field + +Quarantined records stay out of reviewed exports and should move to a quarantine queue or dead-letter path with a machine-readable reason plus an operator-facing note. + +## Dead-Letter Handling + +Dead-letter handling is for records that cannot be trusted enough even for normal review flow. + +Use dead-letter handling when: + +- parsing fails +- schema is broken +- required identity fields are missing +- the message cannot be associated with a known source + +Dead-letter storage should preserve: + +- raw payload or a safe copy +- receipt timestamp +- parser or validation error +- gateway or intake identifier + +Dead-letter handling is an operations and audit surface, not a public data surface. + +## Human Review Requirements + +Human review should be required when: + +- values are suspect but not clearly invalid +- a sensor recently changed calibration or firmware +- publication wording could imply public-health authority +- a record would become the newest public observation for a station or parameter +- a station location or label changed +- a bulk replay or outage-recovery import occurred + +Review questions: + +1. Does the record look physically plausible? +2. Is the provenance clear enough to trust the source path? +3. Is the freshness acceptable for the claimed use? +4. Does the export remove private or sensitive fields? +5. Would public display risk overstating certainty or recency? + +## Minimal Viable Deployment Path + +Keep the first deployment path small: + +1. one station naming convention +2. one or two low-risk parameters such as water temperature or air temperature +3. deterministic intake validator +4. quarantine and dead-letter outputs +5. reviewed JSON export for internal use +6. only later, a reviewed public-safe export if the trust contract holds + +This packet does not authorize direct dashboard integration. The first credible milestone is a reviewed internal export path that proves fail-closed behavior. + +## Known Limitations + +- This packet does not implement MQTT, ingestion, storage, or review tools. +- It does not prove any deployed sensor exists. +- It does not define calibration SOPs in full detail. +- It does not authorize public-health, recreation, or toxin-risk claims. +- It does not replace mentor, lab, or agency review for sensitive variables. + +## Practical Boundary + +If future sensor work becomes convenient before it becomes reviewable, the project will mislead people. Clear Lake Watch should accept that some data stays private, stale, or quarantined rather than pretending raw telemetry is trustworthy enough for public interpretation. diff --git a/docs/flagship-maturity-plan.md b/docs/flagship-maturity-plan.md index 8c9673c..0607e4d 100644 --- a/docs/flagship-maturity-plan.md +++ b/docs/flagship-maturity-plan.md @@ -119,7 +119,7 @@ Deliverables: - README keeps "current maturity" visible near the front door. - Project brief stays concise and partner-friendly. - Portfolio copy uses "prototype" and "situational awareness" language. -- Dashboard validation passes before sharing. In static review mode, `-AllowStaleSnapshot` is acceptable; before broad publication, the snapshot should be refreshed or explicitly release-noted. +- Dashboard validation passes before sharing. For a preserved static snapshot, its age must be release-noted; before broad publication, the snapshot should be refreshed or explicitly release-noted. (There is no `-AllowStaleSnapshot` override today — the validator warns on stale data but does not gate publication, so this stays a manual reviewer decision.) - The portfolio-safe release scope focuses on presentation, validation evidence, screenshots, and conservative claims before live weather telemetry or public field submission. ### Phase 2: Trust Hardening diff --git a/docs/mqtt_topic_conventions.md b/docs/mqtt_topic_conventions.md new file mode 100644 index 0000000..bafbc87 --- /dev/null +++ b/docs/mqtt_topic_conventions.md @@ -0,0 +1,128 @@ +# MQTT Topic Conventions + +**Status:** naming proposal only +**Audience:** future gateway builders, local operators, and reviewers + +This document proposes MQTT topic conventions for a future local-first sensor backbone. It does not implement a broker, client, gateway, or dashboard connection. + +The point of these conventions is to keep identity, routing, and quarantine behavior understandable before any live ingestion exists. + +## Proposed Topic Pattern + +```text +clearlakewatch/sensors/{station_id}/{sensor_id}/observations +clearlakewatch/sensors/{station_id}/{sensor_id}/status +clearlakewatch/sensors/{station_id}/{sensor_id}/deadletter +``` + +## Naming Rules + +### Station Naming + +Use stable operator-readable station IDs. + +Examples: + +- `lakeport-demo-station` +- `oaks-arm-north-01` +- `lower-arm-shoreline-02` + +Recommended rules: + +- lowercase only +- hyphen-separated +- no spaces +- no private device serials in the station ID + +### Sensor Naming + +Use sensor IDs that describe the instrument role without exposing secrets. + +Examples: + +- `temp-probe-01` +- `do-sonde-01` +- `ph-probe-01` + +Recommended rules: + +- stable across normal restarts +- tied to inventory records +- changed if hardware identity changes materially + +## Observation Messages + +Topic: + +```text +clearlakewatch/sensors/{station_id}/{sensor_id}/observations +``` + +Use this topic for structured observation payloads that match the planned sensor data contract. + +Observation topics are still raw intake surfaces. Publishing to this topic does not imply: + +- the value is valid +- the value is current enough for public interpretation +- the value is approved for publication + +## Status Or Heartbeat Messages + +Topic: + +```text +clearlakewatch/sensors/{station_id}/{sensor_id}/status +``` + +Use this topic for: + +- heartbeat signals +- battery or uptime state +- calibration-needed warnings +- offline or degraded health messages + +Status messages help operators understand device state, but they should not become public lake-health signals. + +## Dead-Letter Messages + +Topic: + +```text +clearlakewatch/sensors/{station_id}/{sensor_id}/deadletter +``` + +Use this topic for records that cannot safely enter normal validation or review flow. + +Examples: + +- broken payload +- missing required identifiers +- bad schema +- parse failure + +Dead-letter topics are operational audit surfaces. They are not data products for the public mirror. + +## Why Raw Readings Must Not Go Directly To Public Dashboards + +Raw MQTT traffic is the wrong trust layer for public display because it can contain: + +- stale messages +- duplicate observations +- impossible values +- unknown sensor IDs +- private infrastructure details +- temporary calibration failures + +The public dashboard should consume only reviewed exports, not broker topics. MQTT is an internal transport layer, not a publication contract. + +## Minimal Routing Model + +Recommended routing path: + +```text +observations -> intake validator -> internal review queue or quarantine +status -> operator diagnostics +deadletter -> audit and repair workflow +``` + +This keeps the backbone fail-closed. If validation or review fails, the data should stop before it reaches any public layer. diff --git a/docs/public-backlog.md b/docs/public-backlog.md index 300a557..a183b76 100644 --- a/docs/public-backlog.md +++ b/docs/public-backlog.md @@ -84,7 +84,16 @@ These remain candidates for later work: ## Open Maintenance / Trust Issues -No maintenance/trust issues are currently open in this public backlog snapshot. +Freshness and publication-safety alignment follow-ups are now tracked here, opened after a claim-hygiene pass found that some safety-case wording described enforcement the validators do not yet perform: + +| Follow-up | Focus | Intent | +| --- | --- | --- | +| Implement a real stale-snapshot publication gate | Publication safety | Add an actual enforced gate (or reviewed override) so a stale snapshot cannot be published as fresh, replacing the current warn-only behavior and the previously documented but never-implemented `-AllowStaleSnapshot` control. | +| Decide stale-source policy: fail vs warn | Publication safety | Make an explicit, documented decision on whether an over-threshold source observation should fail publication or remain a visible warning, and align the validators and docs to that decision. | +| Unify Python and PowerShell validators | Reproducibility | Remove drift between `validate-public-mirror.py` (CI) and `validate-public-mirror.ps1` (local) by sharing one source of truth for required-text guards and freshness logic. | +| Compute source and weather stale status | Data trust | Derive `status` / `machineReadableStatus` from observation age at build or render time instead of trusting static `"ok"` / `"partial"` fields that can misrepresent freshness. | + +These are documented as design/implementation follow-ups; none of them are enabled yet, and none change the current manual, reviewed publication posture. ## Open Reviewer-Readiness Issues diff --git a/docs/public-mirror-boundary.md b/docs/public-mirror-boundary.md index 2b50acc..016c454 100644 --- a/docs/public-mirror-boundary.md +++ b/docs/public-mirror-boundary.md @@ -74,7 +74,7 @@ Use `docs/publication-review-checklist.md` for the full publication decision gat Before any broad public publish: 1. Refresh public data or intentionally document a static snapshot. -2. Run `scripts/validate-dashboard.ps1` without `-AllowStaleSnapshot`. +2. Run the public mirror validator and resolve any failures. (Staleness is reported as a warning, not a failure, and no `-AllowStaleSnapshot`-style flag exists — a stale snapshot is a manual reviewer decision.) 3. Confirm `.gitignore` still excludes private local records and SQLite stores. 4. Confirm the public app does not fetch `data/private/`, `*.local.json`, or detailed private review artifacts. 5. Capture a current screenshot if the publish is meant for portfolio promotion. diff --git a/docs/public-snapshot-release-note-2026-05-13.md b/docs/public-snapshot-release-note-2026-05-13.md index af89b57..6a9c6b9 100644 --- a/docs/public-snapshot-release-note-2026-05-13.md +++ b/docs/public-snapshot-release-note-2026-05-13.md @@ -17,7 +17,7 @@ This snapshot is not official public-health guidance, recreation guidance, emerg ## Static Snapshot Age Cue -Reviewed on May 28, 2026: this committed public mirror is a static snapshot generated on May 5, 2026, so the dashboard files are 23 days old at this review point. The latest USGS observation in the snapshot is May 3, 2026, which is 25 days old at this review point. +This committed public mirror is a static snapshot generated on May 5, 2026, with the latest USGS observation dated May 3, 2026. See the dashboard's snapshot-age badge for the current age of this reviewed static snapshot; the badge is computed at load time, so it does not go stale the way a hand-typed day count does. Treat the page as a dated portfolio/review artifact until a new reviewed refresh is generated, validated, and published. The stale FHABS dates above are intentional warning cues, not current bloom observations. diff --git a/docs/publication-review-checklist.md b/docs/publication-review-checklist.md index f73d1ad..2654811 100644 --- a/docs/publication-review-checklist.md +++ b/docs/publication-review-checklist.md @@ -46,7 +46,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\refresh-live-data. powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-dashboard.ps1 -SkipHttp ``` -Do not use `-AllowStaleSnapshot` for a fresh public publish. +Do not publish a stale snapshot as if it were fresh. Note: the validator warns on a stale snapshot but does not block publication, and no `-AllowStaleSnapshot`-style override flag exists — freshness is a manual reviewer decision. For an intentional static portfolio snapshot, write a release note or README note that names the snapshot date and why it is being preserved. @@ -119,7 +119,7 @@ For the current local portfolio-safe release prep branch, see `docs/portfolio-re Only publish after all of these are true: -- validation passes without `-AllowStaleSnapshot`, or the release clearly explains a static snapshot +- validation passes with no unresolved failures, and either the snapshot is fresh or the release clearly explains a preserved static snapshot (the validator only warns on staleness; it does not enforce this) - private local files are excluded - public claims match the maturity plan - a current screenshot exists for promotion use diff --git a/docs/reviewer-demo-notes.md b/docs/reviewer-demo-notes.md index cf2071d..3c20481 100644 --- a/docs/reviewer-demo-notes.md +++ b/docs/reviewer-demo-notes.md @@ -7,7 +7,7 @@ These notes provide a short review path for Clear Lake Watch and captions for th ## Suggested Review Path 1. Open the public dashboard: . -2. Start with the snapshot status strip and confirm the source freshness dates. For this May 28, 2026 review point, the committed snapshot generated on May 5, 2026 is 23 days old and should be read as static portfolio evidence, not current conditions. +2. Start with the snapshot status strip and confirm the source freshness dates. The committed snapshot was generated on May 5, 2026; check the dashboard's snapshot-age badge for its current age. Regardless of age, read it as static portfolio evidence, not current conditions. 3. Review the map QA section and confirm unresolved site assignments remain visible. 4. Open the methodology page to inspect source boundaries and limitations. 5. Open the project page to inspect MVP scope, trust guardrails, and data-product framing. diff --git a/docs/sensor_data_contract.md b/docs/sensor_data_contract.md new file mode 100644 index 0000000..8dd00ea --- /dev/null +++ b/docs/sensor_data_contract.md @@ -0,0 +1,124 @@ +# Sensor Data Contract + +**Status:** planning contract for future reviewed sensor exports +**Audience:** operators, gateway builders, reviewers, and future ingest developers + +This contract defines the minimum JSON shape for a future Clear Lake Watch sensor observation record. + +It is a planning contract only. It does not authorize live ingestion, automatic publication, or public-health claims. + +## Required Fields + +Each observation record must include: + +- `observation_id` +- `sensor_id` +- `station_id` +- `observed_at` +- `received_at` +- `parameter` +- `value` +- `unit` +- `latitude` +- `longitude` +- `source` +- `quality_flag` +- `review_status` +- `notes` + +## Field Definitions + +| Field | Meaning | +| --- | --- | +| `observation_id` | Stable unique identifier for one observation event. | +| `sensor_id` | Approved sensor identifier. | +| `station_id` | Approved station or deployment identifier. | +| `observed_at` | When the environmental reading was observed. | +| `received_at` | When the system received the record. | +| `parameter` | Observed variable such as `water_temperature_c` or `dissolved_oxygen_mg_l`. | +| `value` | Numeric value for the observation. | +| `unit` | Unit string approved for the parameter. | +| `latitude` | Public-safe latitude for the station or sample location. | +| `longitude` | Public-safe longitude for the station or sample location. | +| `source` | Source family such as `example`, `gateway`, or `reviewed_export`. | +| `quality_flag` | Deterministic or reviewed quality state. | +| `review_status` | Current review state in the publication path. | +| `notes` | Plain-language explanation, limitation, or quarantine reason. | + +## Allowed `quality_flag` Values + +- `raw` +- `valid` +- `suspect` +- `invalid` +- `quarantined` + +## Allowed `review_status` Values + +- `unreviewed` +- `auto_checked` +- `human_review_required` +- `approved_for_internal_use` +- `approved_for_publication` +- `rejected` + +## Contract Rules + +- `observation_id` must be unique. +- `received_at` must not be earlier than `observed_at`. +- `quality_flag` and `review_status` must use only allowed values. +- Records marked `quarantined` or `rejected` must not be exported to the public mirror. +- `notes` should explain anything unusual, including example-only status. +- `source` should identify the source family without exposing private broker URLs or internal credentials. + +## Example: Valid Internal Record + +```json +{ + "observation_id": "example-clw-temp-20260625t154500z-001", + "sensor_id": "temp-probe-demo-01", + "station_id": "lakeport-demo-station", + "observed_at": "2026-06-25T15:45:00Z", + "received_at": "2026-06-25T15:45:07Z", + "parameter": "water_temperature", + "value": 21.4, + "unit": "C", + "latitude": 39.0422, + "longitude": -122.9158, + "source": "example", + "quality_flag": "valid", + "review_status": "approved_for_internal_use", + "notes": "Example data for contract documentation only. Not a real field reading." +} +``` + +## Example: Quarantined Record + +```json +{ + "observation_id": "example-clw-ph-20260625t154500z-999", + "sensor_id": "unknown-sensor-demo", + "station_id": "lakeport-demo-station", + "observed_at": "2026-06-20T08:00:00Z", + "received_at": "2026-06-25T15:45:07Z", + "parameter": "ph", + "value": 21.7, + "unit": "pH", + "latitude": 39.0422, + "longitude": -122.9158, + "source": "example", + "quality_flag": "quarantined", + "review_status": "human_review_required", + "notes": "Example quarantined record. Unknown sensor ID and impossible pH value triggered quarantine." +} +``` + +## Public Boundary + +Even a valid contract record is not automatically ready for public display. Public release still requires: + +- provenance review +- freshness review +- uncertainty language +- approved publication status +- a reviewed public-safe export path diff --git a/docs/source-freshness-validation.md b/docs/source-freshness-validation.md index adc04f8..42d34db 100644 --- a/docs/source-freshness-validation.md +++ b/docs/source-freshness-validation.md @@ -6,10 +6,21 @@ Clear Lake Watch publishes a static public-data snapshot. The validation layer c This is not live monitoring, operational alerting, public-health guidance, recreation guidance, or emergency guidance. +## Freshness Terms + +- Dashboard snapshot freshness: `generatedAt` says when the public JSON files were generated. +- Observation freshness: `latestObservationDate` says how recent the newest environmental observation is for a source. +- Resource freshness: `resourceDate` and `resourceAgeDays` say how recent a downloadable source file was when the dashboard snapshot was generated. + +Resource freshness and observation freshness are separate checks. A recently downloaded FHABS file can still contain old Clear Lake observations, and an old source file can block publication rehearsal even when the dashboard code still renders correctly. + +The dashboard refresh time and source observation dates remain separate from source-resource freshness. + ## What The Validator Checks - `data/manifest.json` has a parseable `generatedAt`. - `data/live.json` and manifest `generatedAt` values are from the same refresh pass. +- Manifest field definitions explain dashboard snapshot freshness, observation freshness, and resource freshness. - Expected public sources are present: - USGS Lakeport lake level - USGS Cole Creek discharge @@ -19,7 +30,7 @@ This is not live monitoring, operational alerting, public-health guidance, recre - FHABS source entries include positive Clear Lake row counts. - Expected public outputs are listed in the manifest. - Manifest output `recordCount` values match the generated public files where the count can be computed. -- Manifest notes preserve the distinction between dashboard refresh time and source observation dates. +- Manifest notes preserve the distinction between dashboard refresh time, source observation dates, and source-resource freshness. ## Warning Versus Failure diff --git a/examples/sample_observation_quarantined.json b/examples/sample_observation_quarantined.json new file mode 100644 index 0000000..af2c953 --- /dev/null +++ b/examples/sample_observation_quarantined.json @@ -0,0 +1,16 @@ +{ + "observation_id": "example-clw-temp-20260625t154500z-900", + "sensor_id": "unknown-temp-demo-99", + "station_id": "lakeport-demo-station", + "observed_at": "2026-06-20T15:45:00Z", + "received_at": "2026-06-25T15:45:08Z", + "parameter": "water_temperature", + "value": -12.4, + "unit": "C", + "latitude": 39.0422, + "longitude": -122.9158, + "source": "example", + "quality_flag": "quarantined", + "review_status": "human_review_required", + "notes": "Example quarantined record only. Unknown sensor ID and physically implausible water temperature triggered quarantine." +} diff --git a/examples/sample_observation_valid.json b/examples/sample_observation_valid.json new file mode 100644 index 0000000..b8c1f8a --- /dev/null +++ b/examples/sample_observation_valid.json @@ -0,0 +1,16 @@ +{ + "observation_id": "example-clw-do-20260625t154500z-001", + "sensor_id": "do-sonde-demo-01", + "station_id": "lakeport-demo-station", + "observed_at": "2026-06-25T15:45:00Z", + "received_at": "2026-06-25T15:45:08Z", + "parameter": "dissolved_oxygen", + "value": 8.6, + "unit": "mg/L", + "latitude": 39.0422, + "longitude": -122.9158, + "source": "example", + "quality_flag": "valid", + "review_status": "approved_for_internal_use", + "notes": "Example documentation record only. Not real field data and not approved for public publication." +} diff --git a/index.html b/index.html index 2c3a3d5..fcfd039 100644 --- a/index.html +++ b/index.html @@ -361,10 +361,11 @@

Normalized Public Exports

Source Status

Latest Refresh Manifest

- Source rows, observation dates, and generated output counts come - from the public snapshot manifest. + Source rows, observation dates, source-resource dates, and + generated output counts come from the public snapshot manifest.

+
diff --git a/scripts/dashboard-utils.js b/scripts/dashboard-utils.js index c579adb..814276d 100644 --- a/scripts/dashboard-utils.js +++ b/scripts/dashboard-utils.js @@ -63,7 +63,28 @@ export const setStoredBoolean = (key, value) => { export const getStoredJson = (key, fallback = null) => { try { const value = localStorage.getItem(key); - return value ? JSON.parse(value) : fallback; + + if (value === null || value === undefined) { + return fallback; + } + + const parsed = JSON.parse(value); + + if (fallback === null || fallback === undefined) { + return parsed ?? fallback; + } + + if (Array.isArray(fallback)) { + return Array.isArray(parsed) ? parsed : fallback; + } + + if (typeof fallback === "object") { + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : fallback; + } + + return typeof parsed === typeof fallback ? parsed : fallback; } catch (error) { console.warn(error); return fallback; diff --git a/scripts/validate-public-mirror.ps1 b/scripts/validate-public-mirror.ps1 index 1a56715..fb24691 100644 --- a/scripts/validate-public-mirror.ps1 +++ b/scripts/validate-public-mirror.ps1 @@ -237,6 +237,22 @@ function Test-ManifestFreshness { } $maxSourceAgeDays = if ($Manifest.sourceFreshnessMaxAgeDays) { [int]$Manifest.sourceFreshnessMaxAgeDays } else { 14 } + $fieldDefinitions = $Manifest.fieldDefinitions + if ($null -eq $fieldDefinitions) { + Add-Failure "Manifest must include fieldDefinitions for trust clarity." + } else { + foreach ($fieldName in @("generatedAt", "latestObservationDate", "resourceDate", "resourceAgeDays")) { + if ([string]::IsNullOrWhiteSpace($fieldDefinitions.$fieldName)) { + Add-Failure "Manifest fieldDefinitions is missing $fieldName." + } + } + } + + $freshnessLegendText = (@($Manifest.freshnessLegend) | ForEach-Object { "$($_.term) $($_.definition)" }) -join " " + foreach ($requiredPhrase in @("Dashboard snapshot freshness", "Observation freshness", "Resource freshness")) { + Assert-TextContains -Text $freshnessLegendText -Needle $requiredPhrase -Message "Manifest freshnessLegend must include $requiredPhrase." + } + foreach ($source in @($Manifest.sources)) { if ([string]::IsNullOrWhiteSpace($source.id)) { @@ -317,6 +333,7 @@ function Test-ManifestFreshness { $notesText = (@($Manifest.notes) -join " ") Assert-TextContains -Text $notesText -Needle "Observation dates may be older than the dashboard generation time" -Message "Manifest must preserve dashboard-refresh versus source-observation distinction." + Assert-TextContains -Text $notesText -Needle "Resource freshness and observation freshness are separate checks" -Message "Manifest must preserve resource-freshness versus observation-freshness distinction." } function Test-WeatherContext { @@ -589,6 +606,8 @@ try { Assert-TextContains -Text $readme -Needle "docs/secchi-mentor-review-handoff.md" -Message "README must link the Secchi mentor-review handoff." Assert-TextContains -Text $index -Needle "late prototype / early MVP" -Message "Homepage must preserve maturity language." Assert-TextContains -Text $index -Needle "Internship portfolio prototype showing Clear Lake environmental data integration, GIS/spatial QA, source-freshness validation, static deployment, and responsible public communication." -Message "Homepage meta description must describe the internship portfolio signal." + Assert-TextContains -Text $index -Needle "source-resource dates" -Message "Homepage must distinguish source-resource dates from observation dates." + Assert-TextContains -Text $index -Needle "source-freshness-legend" -Message "Homepage must expose the freshness legend container." Assert-TextContains -Text $index -Needle "Internship Reviewer Path" -Message "Homepage must include an above-the-fold reviewer path." Assert-TextContains -Text $index -Needle "Dashboard snapshot" -Message "Homepage reviewer path must start with the dashboard snapshot." Assert-TextContains -Text $index -Needle "github.com/coreytshaffer/clear-lake-watch/blob/main/docs/clear-lake-watch-v0.1-evidence-summary.md" -Message "Homepage reviewer path must link the GitHub evidence summary." @@ -615,6 +634,8 @@ try { Assert-TextContains -Text $app -Needle "renderSnapshotStatusStrip" -Message "App must render the snapshot status strip." Assert-TextContains -Text $app -Needle "Latest Clear Lake FHABS report" -Message "App must expose FHABS report freshness in the status strip." Assert-TextContains -Text $app -Needle "Latest FHABS lab-linked sample" -Message "App must expose FHABS lab-result freshness in the status strip." + Assert-TextContains -Text $app -Needle "Observation freshness can be older than dashboard refresh time" -Message "App must distinguish dashboard refresh time from observation freshness." + Assert-TextContains -Text $app -Needle "Resource freshness: source file was" -Message "App must label resource freshness separately from observation freshness." Assert-TextContains -Text $app -Needle "before site or arm assignments should be treated as authoritative" -Message "App must preserve map-review caution language." $publicBacklog = Get-Content -LiteralPath (Resolve-ProjectPath "docs\public-backlog.md") -Raw @@ -644,7 +665,7 @@ try { Assert-TextContains -Text $publicBacklog -Needle "County GIS geometry publication boundary" -Message "Public backlog must include county GIS publication-boundary candidate." Assert-TextContains -Text $publicBacklog -Needle "Browser accessibility interaction pass" -Message "Public backlog must include browser accessibility interaction-pass status." Assert-TextContains -Text $publicBacklog -Needle "Maintenance file split" -Message "Public backlog must include maintenance file split status." - Assert-TextContains -Text $publicBacklog -Needle "No maintenance/trust issues are currently open" -Message "Public backlog must record that maintenance candidates are closed." + Assert-TextContains -Text $publicBacklog -Needle "Freshness and publication-safety alignment follow-ups" -Message "Public backlog must track the freshness and publication-safety alignment follow-ups." $releaseNote = Get-Content -LiteralPath (Resolve-ProjectPath "docs\public-snapshot-release-note-2026-05-13.md") -Raw Assert-TextContains -Text $releaseNote -Needle "Public Snapshot Release Note - 2026-05-13" -Message "Release note must include its title." @@ -655,7 +676,7 @@ try { Assert-TextContains -Text $releaseNote -Needle "September 7, 2025" -Message "Release note must include FHABS report freshness date." Assert-TextContains -Text $releaseNote -Needle "January 11, 2024" -Message "Release note must include FHABS lab-linked sample freshness date." Assert-TextContains -Text $releaseNote -Needle "Static Snapshot Age Cue" -Message "Release note must include a static snapshot age cue." - Assert-TextContains -Text $releaseNote -Needle "the dashboard files are 23 days old" -Message "Release note must make committed snapshot age visible." + Assert-TextContains -Text $releaseNote -Needle "snapshot-age badge" -Message "Release note must point to the dashboard's dynamic snapshot-age badge instead of a hardcoded day count." Assert-TextContains -Text $releaseNote -Needle "not current bloom observations" -Message "Release note must preserve stale FHABS warning framing." Assert-TextContains -Text $releaseNote -Needle "clear-lake-watch-homepage-desktop-2026-05-13.png" -Message "Release note must link the desktop screenshot." Assert-TextContains -Text $releaseNote -Needle "clear-lake-watch-homepage-mobile-2026-05-13.png" -Message "Release note must link the mobile-width screenshot." @@ -697,6 +718,7 @@ try { Assert-TextContains -Text $sourceFreshnessValidation -Needle "Source Freshness Validation" -Message "Source freshness validation doc must include its title." Assert-TextContains -Text $sourceFreshnessValidation -Needle "not live monitoring" -Message "Source freshness validation doc must preserve non-operational boundary." Assert-TextContains -Text $sourceFreshnessValidation -Needle "dashboard refresh time and source observation dates" -Message "Source freshness validation doc must preserve freshness distinction." + Assert-TextContains -Text $sourceFreshnessValidation -Needle "Resource freshness and observation freshness are separate checks" -Message "Source freshness validation doc must preserve resource-versus-observation freshness distinction." Assert-TextContains -Text $sourceFreshnessValidation -Needle "Warning Versus Failure" -Message "Source freshness validation doc must explain warning versus failure behavior." $scheduledRefreshDesign = Get-Content -LiteralPath (Resolve-ProjectPath "docs\scheduled-public-refresh-design.md") -Raw @@ -785,7 +807,7 @@ try { Assert-TextContains -Text $reviewerDemoNotes -Needle "Methodology page" -Message "Reviewer demo notes must caption the methodology screenshot." Assert-TextContains -Text $reviewerDemoNotes -Needle "Project page" -Message "Reviewer demo notes must caption the project-page screenshot." Assert-TextContains -Text $reviewerDemoNotes -Needle "dashboard-anatomy-review-guide.md" -Message "Reviewer demo notes must link the dashboard anatomy guide." - Assert-TextContains -Text $reviewerDemoNotes -Needle "the committed snapshot generated on May 5, 2026 is 23 days old" -Message "Reviewer demo notes must make committed snapshot age visible." + Assert-TextContains -Text $reviewerDemoNotes -Needle "snapshot-age badge" -Message "Reviewer demo notes must point to the dashboard's dynamic snapshot-age badge instead of a hardcoded day count." Assert-TextContains -Text $reviewerDemoNotes -Needle "not official public-health guidance" -Message "Reviewer demo notes must preserve public-health boundary." $portfolioEvidenceIndex = Get-Content -LiteralPath (Resolve-ProjectPath "docs\portfolio-evidence-index.md") -Raw diff --git a/scripts/validate-public-mirror.py b/scripts/validate-public-mirror.py new file mode 100644 index 0000000..c79d0fc --- /dev/null +++ b/scripts/validate-public-mirror.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass, field +from datetime import date, datetime +from pathlib import Path +from typing import Iterable + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +REQUIRED_FILES = [ + Path(".nojekyll"), + Path("README.md"), + Path("index.html"), + Path("project.html"), + Path("methodology.html"), + Path("styles.css"), + Path("app.js"), + Path("sw.js"), + Path("manifest.webmanifest"), + Path("scripts/dashboard-utils.js"), + Path("scripts/public-mirror-link-validation.ps1"), + Path("scripts/validate-public-mirror.ps1"), + Path("data/live.json"), + Path("data/reports.json"), + Path("data/observations.json"), + Path("data/sites.json"), + Path("data/sites-normalized.json"), + Path("data/site-review-summary.json"), + Path("data/analytics.json"), + Path("data/manifest.json"), + Path("data/lake-shoreline.json"), + Path("data/weather-context.json"), + Path("data/reviewed-field-observations.json"), + Path("docs/public-backlog.md"), + Path("docs/public-snapshot-release-note-2026-05-13.md"), + Path("docs/reviewer-demo-notes.md"), + Path("docs/publication-review-checklist.md"), + Path("docs/public-mirror-boundary.md"), + Path("docs/flagship-maturity-plan.md"), + Path("docs/scheduled-public-refresh-design.md"), +] + +TEXT_GUARDS = { + Path("README.md"): [ + "late-prototype / early-MVP", + "not official public-health guidance", + "static reviewed snapshot generated on May 5, 2026", + "not live lake conditions", + "docs/public-snapshot-release-note-2026-05-13.md", + ], + Path("index.html"): [ + "late prototype / early MVP", + "Public Data Snapshot, Not Advisory Guidance", + "What The Public Snapshot Files Are Showing", + "source-resource dates", + "source-freshness-legend", + ], + Path("project.html"): [ + "late prototype / early MVP", + "not official public-health guidance", + ], + Path("methodology.html"): [ + "not official public-health direction", + ], + Path("docs/public-snapshot-release-note-2026-05-13.md"): [ + "Snapshot generated: May 5, 2026", + "not official public-health guidance", + "Static Snapshot Age Cue", + ], + Path("docs/reviewer-demo-notes.md"): [ + "static portfolio evidence, not current conditions", + "not official public-health guidance", + ], + Path("docs/scheduled-public-refresh-design.md"): [ + "No unattended publication workflow is enabled", + "The scheduled workflow must never publish", + ], +} + +EXPECTED_SOURCE_IDS = { + "usgs-lake-level", + "usgs-cole-creek-discharge", + "fhabs-bloom-reports", + "fhabs-results", +} + +EXCLUDED_TRACKED_PATHS = [ + "docs/private", + "docs/review-screenshots", + "docs/trusted-review-request.md", + "docs/trusted-review-feedback-log.md", + "docs/communications-log.md", + "data/private", + "data/site-review.json", + "portfolio-materials.html", + "shortcuts", + "server.pid", + "server.out.log", + "server.err.log", +] + +DISALLOWED_WORKING_PATHS = [ + Path("geometry-preview.html"), + Path("data/lake-shoreline-county-candidate.json"), + Path("data/lake-shoreline-county-simplified-25ft.json"), + Path("data/lake-shoreline-county-simplified-50ft.json"), +] + + +@dataclass +class ValidationState: + failures: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def fail(self, message: str) -> None: + self.failures.append(message) + + def warn(self, message: str) -> None: + self.warnings.append(message) + + +def project_path(relative_path: Path) -> Path: + return PROJECT_ROOT / relative_path + + +def read_text(relative_path: Path, state: ValidationState) -> str: + path = project_path(relative_path) + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + state.fail(f"Missing required file: {relative_path.as_posix()}") + except OSError as exc: + state.fail(f"Unable to read {relative_path.as_posix()}: {exc}") + return "" + + +def read_json(relative_path: Path, state: ValidationState) -> object | None: + content = read_text(relative_path, state) + if not content: + return None + try: + return json.loads(content) + except json.JSONDecodeError as exc: + state.fail(f"Invalid JSON in {relative_path.as_posix()}: {exc}") + return None + + +def assert_required_files(state: ValidationState) -> None: + for relative_path in REQUIRED_FILES: + if not project_path(relative_path).is_file(): + state.fail(f"Missing required file: {relative_path.as_posix()}") + + +def assert_text_guards(state: ValidationState) -> None: + for relative_path, needles in TEXT_GUARDS.items(): + text = read_text(relative_path, state) + for needle in needles: + if needle not in text: + state.fail( + f"{relative_path.as_posix()} is missing required text: {needle}" + ) + + +def parse_date_like(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +def load_tracked_files(state: ValidationState) -> set[str]: + try: + result = subprocess.run( + ["git", "ls-files"], + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + state.warn(f"Unable to inspect tracked files with git ls-files: {exc}") + return set() + return {line.strip() for line in result.stdout.splitlines() if line.strip()} + + +def assert_tracked_boundaries(state: ValidationState, tracked_files: set[str]) -> None: + if not tracked_files: + return + + for excluded_path in EXCLUDED_TRACKED_PATHS: + normalized = excluded_path.replace("\\", "/") + for tracked_path in tracked_files: + if tracked_path == normalized or tracked_path.startswith(f"{normalized}/"): + state.fail( + "Public mirror must not track excluded review/private/local " + f"artifact: {normalized}" + ) + break + + for tracked_path in tracked_files: + if tracked_path.startswith("data/") and tracked_path.endswith(".local.json"): + state.fail(f"Public mirror must not track local/private file: {tracked_path}") + if tracked_path.endswith(".local.sqlite"): + state.fail(f"Public mirror must not track local/private file: {tracked_path}") + + +def assert_working_boundaries(state: ValidationState) -> None: + for relative_path in DISALLOWED_WORKING_PATHS: + if project_path(relative_path).exists(): + state.fail( + "Public mirror must not expose unverified county GIS review artifact: " + f"{relative_path.as_posix()}" + ) + + +def assert_manifest_freshness( + state: ValidationState, manifest: dict, live_data: dict +) -> None: + try: + manifest_generated_at = parse_date_like(str(manifest["generatedAt"])) + live_generated_at = parse_date_like(str(live_data["generatedAt"])) + except (KeyError, TypeError, ValueError) as exc: + state.fail(f"Unable to parse manifest or live snapshot timestamps: {exc}") + return + + gap_minutes = abs((manifest_generated_at - live_generated_at).total_seconds()) / 60 + if gap_minutes > 5: + state.fail( + "Manifest and live snapshot appear to come from different refresh passes. " + f"Gap: {gap_minutes:.2f} minutes." + ) + + max_source_age_days = int(manifest.get("sourceFreshnessMaxAgeDays", 14)) + field_definitions = manifest.get("fieldDefinitions") + if not isinstance(field_definitions, dict): + state.fail("Manifest must include fieldDefinitions for trust clarity.") + else: + for field_name in ( + "generatedAt", + "latestObservationDate", + "resourceDate", + "resourceAgeDays", + ): + definition = field_definitions.get(field_name) + if not isinstance(definition, str) or not definition.strip(): + state.fail(f"Manifest fieldDefinitions is missing {field_name}.") + + freshness_legend = manifest.get("freshnessLegend") + if not isinstance(freshness_legend, list) or len(freshness_legend) < 3: + state.fail("Manifest must include a freshnessLegend with at least three entries.") + else: + legend_text = " ".join( + str(entry.get("term", "")) + " " + str(entry.get("definition", "")) + for entry in freshness_legend + if isinstance(entry, dict) + ) + for required_phrase in ( + "Dashboard snapshot freshness", + "Observation freshness", + "Resource freshness", + ): + if required_phrase not in legend_text: + state.fail(f"Manifest freshnessLegend is missing {required_phrase}.") + + sources = manifest.get("sources") + if not isinstance(sources, list): + state.fail("Manifest sources must be a list.") + return + + source_ids = { + source.get("id") + for source in sources + if isinstance(source, dict) and source.get("id") + } + missing_ids = sorted(EXPECTED_SOURCE_IDS - source_ids) + for source_id in missing_ids: + state.fail(f"Manifest is missing expected source: {source_id}") + + today = date.today() + snapshot_age_days = (today - manifest_generated_at.date()).days + if snapshot_age_days > max_source_age_days: + state.warn( + "Dashboard snapshot is " + f"{snapshot_age_days} days old, which exceeds " + f"sourceFreshnessMaxAgeDays={max_source_age_days}. " + "Keep static-snapshot language visible." + ) + + if manifest.get("status") == "ok": + non_ok_sources = [ + source.get("id", "unknown") + for source in sources + if isinstance(source, dict) and source.get("status") != "ok" + ] + if non_ok_sources: + state.fail( + "Manifest status is ok, but one or more sources are not ok: " + + ", ".join(non_ok_sources) + ) + + for source in sources: + if not isinstance(source, dict): + state.fail("Manifest source entry must be an object.") + continue + source_id = str(source.get("id", "unknown")) + status = source.get("status") + row_count = source.get("rowCount") + latest_observation = source.get("latestObservationDate") + + if not status: + state.fail(f"Manifest source {source_id} is missing status.") + if not isinstance(row_count, (int, float)) or row_count <= 0: + state.fail(f"Manifest source {source_id} must have a positive rowCount.") + if not latest_observation: + state.fail(f"Manifest source {source_id} is missing latestObservationDate.") + continue + + try: + latest_observation_date = parse_date_like(str(latest_observation)).date() + except ValueError: + latest_observation_date = datetime.fromisoformat( + f"{latest_observation}T00:00:00" + ).date() + + age_days = (manifest_generated_at.date() - latest_observation_date).days + if age_days < 0: + state.fail( + f"Manifest source {source_id} has a latestObservationDate after " + "the manifest generatedAt." + ) + elif age_days > max_source_age_days: + state.warn( + f"Manifest source {source_id} latest observation is {age_days} days " + "older than the dashboard snapshot; keep stale-source language visible." + ) + + if source_id.startswith("fhabs-"): + resource_date = source.get("resourceDate") + resource_age_days = source.get("resourceAgeDays") + if not resource_date: + state.fail(f"Manifest source {source_id} is missing resourceDate.") + else: + try: + resource_datetime = parse_date_like(str(resource_date)) + except ValueError: + resource_datetime = datetime.fromisoformat( + f"{resource_date}T00:00:00" + ) + if resource_datetime.date() > manifest_generated_at.date(): + state.fail( + f"Manifest source {source_id} resourceDate is after " + "the manifest generatedAt." + ) + if not isinstance(resource_age_days, (int, float)) or resource_age_days < 0: + state.fail( + f"Manifest source {source_id} must have non-negative resourceAgeDays." + ) + + notes_text = " ".join(str(note) for note in manifest.get("notes", [])) + for required_note in ( + "Observation dates may be older than the dashboard generation time", + "Resource freshness and observation freshness are separate checks", + ): + if required_note not in notes_text: + state.fail(f"Manifest notes are missing required trust note: {required_note}") + + +def assert_live_data_shape(state: ValidationState, live_data: dict) -> None: + for key in ("liveCards", "mapMarkers", "dataProducts"): + value = live_data.get(key) + if not isinstance(value, list) or not value: + state.fail(f"Live snapshot must include a non-empty {key} list.") + + +def assert_reviewed_field_observations(state: ValidationState, reviewed_data: dict) -> None: + records = reviewed_data.get("records") + if not isinstance(records, list): + state.fail("Reviewed field observations must expose a records list.") + return + + private_needles = { + "collectorName", + "qaNotes", + "custodyNotes", + "photoOrVoucherReference", + "latitude", + "longitude", + } + for record in records: + serialized = json.dumps(record, sort_keys=True) + for private_needle in private_needles: + if private_needle in serialized: + record_id = ( + record.get("recordId", "unknown") + if isinstance(record, dict) + else "unknown" + ) + state.fail( + "Reviewed public field observation " + f"{record_id} includes private or sensitive field: {private_needle}" + ) + + +def validate() -> ValidationState: + state = ValidationState() + assert_required_files(state) + assert_text_guards(state) + + tracked_files = load_tracked_files(state) + assert_tracked_boundaries(state, tracked_files) + assert_working_boundaries(state) + + manifest = read_json(Path("data/manifest.json"), state) + live_data = read_json(Path("data/live.json"), state) + reviewed_field_observations = read_json( + Path("data/reviewed-field-observations.json"), state + ) + + if isinstance(manifest, dict) and isinstance(live_data, dict): + assert_manifest_freshness(state, manifest, live_data) + assert_live_data_shape(state, live_data) + else: + state.fail("Manifest or live snapshot JSON is missing required object structure.") + + if isinstance(reviewed_field_observations, dict): + assert_reviewed_field_observations(state, reviewed_field_observations) + else: + state.fail("Reviewed field observations JSON is missing required object structure.") + + return state + + +def emit_messages(messages: Iterable[str], label: str) -> None: + messages = list(messages) + if not messages: + return + print(f"{label}:") + for message in messages: + print(f" - {message}") + + +def main() -> int: + state = validate() + emit_messages(state.warnings, "Warnings") + emit_messages(state.failures, "Failures") + + if state.failures: + print("Validation failed for Clear Lake Watch public mirror.") + return 1 + + print("Validation passed for Clear Lake Watch public mirror.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/styles.css b/styles.css index ac9ebb4..3443973 100644 --- a/styles.css +++ b/styles.css @@ -1363,6 +1363,26 @@ h2 { gap: 12px; } +.source-freshness-legend { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.source-freshness-item { + display: grid; + align-content: start; + gap: 8px; + border: 1px solid var(--line); + border-radius: 18px; + padding: 14px; + background: rgba(221, 182, 111, 0.12); +} + +.source-freshness-item h4 { + margin: 0; + font-size: 0.98rem; +} .source-output-panel { display: grid; gap: 12px;