diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index dfb6c10..287f3a8 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -7,16 +7,34 @@ on: jobs: verify: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: civicnotice + POSTGRES_USER: civicnotice + POSTGRES_DB: civicnotice_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U civicnotice -d civicnotice_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install CivicCore release wheel - run: python -m pip install https://github.com/CivicSuite/civiccore/releases/download/v0.3.0/civiccore-0.3.0-py3-none-any.whl + run: python -m pip install https://github.com/CivicSuite/civiccore/releases/download/v1.2.0/civiccore-1.2.0-py3-none-any.whl - name: Install package run: python -m pip install -e ".[dev]" + - name: Assert CivicCore release version + run: python -c "import civiccore; assert civiccore.__version__ == '1.2.0', civiccore.__version__" - name: Run release gate + env: + CIVICNOTICE_POSTGRES_TEST_URL: postgresql+psycopg2://civicnotice:civicnotice@localhost:5432/civicnotice_test run: bash scripts/verify-release.sh diff --git a/AGENTS.md b/AGENTS.md index 21c40ed..c8c687e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,10 +9,10 @@ ## Hard Boundaries - CivicNotice never decides legal sufficiency, publishes official notices, provides legal advice, writes back to publication systems, or updates a notice system of record. -- CivicNotice v0.1.1 must not call live LLMs or live agenda systems. +- CivicNotice v0.2.0 must not call live LLMs or live agenda systems. - deadline tracking, publication readiness tracking, channel planning, and records exports must be marked staff-review-required where applicable. - CivicNotice depends on CivicCore; CivicCore must never depend on CivicNotice. -- CivicNotice may reference CivicClerk meeting concepts only through released APIs or deterministic sample data in v0.1.1. +- CivicNotice may reference CivicClerk meeting concepts only through released APIs or deterministic sample data in v0.2.0. ## Verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df9d90..b90cf23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to CivicNotice will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.2.0] - 2026-06-19 + +### Added + +- Added durable publication-proof workpapers for staff evidence packets linked to Clerk, procurement, board, or manual source records. +- Added deterministic statutory rule checks and staff-editable notice drafting templates for common notice types. +- Added subscriber delivery planning with active/suppressed subscriber accounting, deduped email recipients, required-channel checks, and language-review flags. +- Added accessibility, plain-language, and human-approved translation readiness packets for public notices. +- Added archive/handoff packets that assemble required notice evidence, missing items, and Clerk/Procure/Boards/Records handoff targets. + +### Changed + +- Moved CivicNotice to the `civiccore v1.2.0` release wheel used by the current Windows Local city-core platform. +- Synchronized runtime health/version tests, docs, release gate version surfaces, and public UI copy for the v0.2.0 notice workflow release. +- Preserved the deterministic notice registry, deadline tracking, publication-readiness, channel-planning, records-export, optional workpaper persistence, and honest unshipped-boundary behavior from v0.1.2. + ## [0.1.2] - 2026-04-29 ### Changed diff --git a/README.md b/README.md index 1f2bfc0..ae67bfc 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ # CivicNotice -CivicNotice is the CivicSuite module for public hearing notices, legal notices, bid notices, vacancy notices, statutory publication deadlines, publication-readiness review, channel planning, and notice-record export checklists. +CivicNotice is the CivicSuite module for public hearing notices, legal notices, bid notices, vacancy notices, statutory publication deadlines, statutory rule checks, notice drafting templates, accessibility and language-readiness review, publication-readiness review, channel planning, subscriber delivery planning, archive/handoff packets, and notice-record export checklists. -Current state: **v0.1.2 notice compliance foundation release**, aligned to the `civiccore v0.9.0` release wheel. This repo ships a FastAPI package, health/root endpoints, documentation gates, deterministic sample notice registry, CivicCore-backed statutory deadline plans, optional database-backed registry/deadline workpapers, publication-readiness checklists, channel-planning helpers, notice/records export checklist, and accessible public sample UI at `/civicnotice`. It does **not** ship legal sufficiency decisions, legal advice, live LLM calls, official notice publication, publication-system write-back, or notice system-of-record integrations. +Current state: **v0.2.0 notice compliance foundation release**, aligned to the `civiccore v1.2.0` release wheel. This repo ships a FastAPI package, health/root endpoints, documentation gates, deterministic sample notice registry, CivicCore-backed statutory deadline plans, deterministic statutory rule checks, notice drafting templates, accessibility and language-readiness review packets, optional database-backed registry/deadline/publication-proof workpapers, publication-readiness checklists, channel-planning helpers, subscriber delivery planning, archive/handoff packets, notice/records export checklist, and accessible public sample UI at `/civicnotice`. It does **not** ship legal sufficiency decisions, legal advice, live LLM calls, official notice publication, publication-system write-back, or notice system-of-record integrations. ## What CivicNotice Does - Create sample notice registry stubs. - Build statutory publication deadline reminder plans using the shared CivicCore notice helper. -- Persist notice registry and deadline-plan workpapers when `CIVICNOTICE_WORKPAPER_DB_URL` is configured. +- Check notice packets against deterministic staff-review rule packs for common notice types. +- Build staff-editable notice templates with required fields and unresolved placeholders. +- Build accessibility, plain-language, and human-approved translation readiness packets. +- Persist notice registry, deadline-plan, and publication-proof workpapers when `CIVICNOTICE_WORKPAPER_DB_URL` is configured. +- Store staff-reviewed publication proof packets linked to upstream Clerk, procurement, board, or manual source records. - Assemble publication-readiness checklists for staff review. - Plan notice channels and accessibility-review needs. +- Build subscriber delivery plans without sending notices or storing subscriber PII. +- Assemble archive/handoff packets for Clerk, Procure, Boards, Records, and manual staff files. - Produce notice and records export checklists. - Demonstrate a public notice-support UI at `/civicnotice`. @@ -19,7 +25,7 @@ Current state: **v0.1.2 notice compliance foundation release**, aligned to the ` - It does not decide legal sufficiency. - It does not publish official notices. - It does not provide legal advice. -- It does not call live LLMs in v0.1.2. +- It does not call live LLMs in v0.2.0. - It does not write back to publication systems. - It does not replace a notice system of record. @@ -28,14 +34,40 @@ Current state: **v0.1.2 notice compliance foundation release**, aligned to the ` - `GET /` returns the shipped/planned boundary. - `GET /health` returns package and CivicCore versions. - `GET /civicnotice` returns the accessible public sample UI. +- `GET /docs` opens the interactive FastAPI API documentation for local deployments. +- `GET /openapi.json` returns the machine-readable API schema, including accepted request and error shapes. - `POST /api/v1/civicnotice/registry` returns a sample notice registry stub. - `GET /api/v1/civicnotice/registry/{record_id}` retrieves a persisted notice registry record. - `POST /api/v1/civicnotice/deadlines` returns statutory deadline reminders. - `GET /api/v1/civicnotice/deadlines/{plan_id}` retrieves a persisted deadline plan. +- `POST /api/v1/civicnotice/rule-check` checks a notice packet against deterministic staff-review rules. +- `POST /api/v1/civicnotice/templates` returns a staff-editable notice template. +- `POST /api/v1/civicnotice/publication-proof` stores a durable publication proof workpaper. +- `GET /api/v1/civicnotice/publication-proof/{proof_id}` retrieves a persisted publication proof workpaper. - `POST /api/v1/civicnotice/publication-check` returns a publication-readiness checklist. +- `POST /api/v1/civicnotice/accessibility-review` returns accessibility and language-readiness flags. - `POST /api/v1/civicnotice/channels` returns channel planning flags. +- `POST /api/v1/civicnotice/subscribers/plan` returns a subscriber delivery plan. +- `POST /api/v1/civicnotice/archive-packet` returns archive and handoff readiness for a notice file. - `POST /api/v1/civicnotice/export` returns a notice and records export checklist. +## Start and Smoke-Check CivicNotice + +Install the package with its development dependencies, start the ASGI app target `civicnotice.main:app`, and open the local `/health` and `/civicnotice` routes. A fresh user can reach the core stateless API and public sample UI without a database, model server, account, or API key. + +Minimal workflow to smoke-check after startup: + +1. Confirm `/health` reports `service: civicnotice`, version `0.2.0`, and CivicCore `1.2.0`. +2. Post a registry stub to `/api/v1/civicnotice/registry` without a database and confirm the response includes `record_id: null`, registry notes, and the staff-responsibility disclaimer. +3. Open `/docs` or `/openapi.json` to inspect accepted fields, then post a rule check to `/api/v1/civicnotice/rule-check` with a supported notice type, event date, publication dates, channels, content fields, and statutory basis. Unsupported notice types return a 422 response with supported choices. +4. Open `/civicnotice` and confirm the page is a static public sample with boundary copy, not an official publication workflow. + +## Persistence and Durable Writes + +Without `CIVICNOTICE_WORKPAPER_DB_URL`, CivicNotice runs in deterministic stateless mode: registry and deadline POST requests return transient payloads, persisted GET routes return actionable 503 responses, and publication-proof storage is unavailable. + +With `CIVICNOTICE_WORKPAPER_DB_URL`, registry, deadline, and publication-proof workpapers are durable. Persistence-backed write routes also require `CIVICNOTICE_TRUSTED_WRITE_TOKEN` and the matching `X-CivicNotice-Write-Token` request header. This is a minimal trusted-mode guard for local deployments; it is not a replacement for a production identity system. + ## Local Development ```bash @@ -44,6 +76,8 @@ python -m pytest -q bash scripts/verify-release.sh ``` +The release gate requires `CIVICNOTICE_POSTGRES_TEST_URL` so PostgreSQL persistence coverage cannot be skipped. Plain unit tests may still run without PostgreSQL for local development. + ## License Code is Apache License 2.0. Documentation is CC BY 4.0. diff --git a/README.txt b/README.txt index 2fcbed2..14a92f8 100644 --- a/README.txt +++ b/README.txt @@ -1,17 +1,24 @@ CivicNotice =========== -CivicNotice is the CivicSuite module for public hearing notices, legal notices, bid notices, vacancy notices, statutory publication deadlines, publication-readiness review, channel planning, and notice-record export checklists. +CivicNotice is the CivicSuite module for public hearing notices, legal notices, bid notices, vacancy notices, statutory publication deadlines, statutory rule checks, notice drafting templates, accessibility and language-readiness review, publication-readiness review, channel planning, subscriber delivery planning, archive/handoff packets, and notice-record export checklists. -Current state: v0.1.2 notice compliance foundation release, aligned to the civiccore v0.9.0 release wheel. This repo ships a FastAPI package, health/root endpoints, documentation gates, deterministic sample notice registry, CivicCore-backed statutory deadline plans, optional database-backed registry/deadline workpapers via CIVICNOTICE_WORKPAPER_DB_URL, publication-readiness checklists, channel-planning helpers, notice/records export checklist, and accessible public sample UI at /civicnotice. +Current state: v0.2.0 notice compliance foundation release, aligned to the civiccore v1.2.0 release wheel. This repo ships a FastAPI package, health/root endpoints, documentation gates, deterministic sample notice registry, CivicCore-backed statutory deadline plans, deterministic statutory rule checks, notice drafting templates, accessibility and language-readiness review packets, optional database-backed registry/deadline/publication-proof workpapers via CIVICNOTICE_WORKPAPER_DB_URL, publication-readiness checklists, channel-planning helpers, subscriber delivery planning, archive/handoff packets, notice/records export checklist, and accessible public sample UI at /civicnotice. It does not ship legal sufficiency decisions, legal advice, live LLM calls, official notice publication, publication-system write-back, or notice system-of-record integrations. What CivicNotice does: - Create sample notice registry stubs. - Build statutory publication deadline reminder plans using the shared CivicCore notice helper. +- Check notice packets against deterministic staff-review rule packs for common notice types. +- Build staff-editable notice templates with required fields and unresolved placeholders. +- Build accessibility, plain-language, and human-approved translation readiness packets. +- Persist notice registry, deadline-plan, and publication-proof workpapers when CIVICNOTICE_WORKPAPER_DB_URL is configured. +- Store staff-reviewed publication proof packets linked to upstream source records. - Assemble publication-readiness checklists for staff review. - Plan notice channels and accessibility-review needs. +- Build subscriber delivery plans without sending notices or storing subscriber PII. +- Assemble archive/handoff packets for Clerk, Procure, Boards, Records, and manual staff files. - Produce notice and records export checklists. - Demonstrate a public notice-support UI at /civicnotice. @@ -19,12 +26,41 @@ API surface: - GET / - GET /health - GET /civicnotice +- GET /docs +- GET /openapi.json - POST /api/v1/civicnotice/registry - GET /api/v1/civicnotice/registry/{record_id} - POST /api/v1/civicnotice/deadlines - GET /api/v1/civicnotice/deadlines/{plan_id} +- POST /api/v1/civicnotice/rule-check +- POST /api/v1/civicnotice/templates +- POST /api/v1/civicnotice/publication-proof +- GET /api/v1/civicnotice/publication-proof/{proof_id} - POST /api/v1/civicnotice/publication-check +- POST /api/v1/civicnotice/accessibility-review - POST /api/v1/civicnotice/channels +- POST /api/v1/civicnotice/subscribers/plan +- POST /api/v1/civicnotice/archive-packet - POST /api/v1/civicnotice/export +Start and smoke-check CivicNotice: + +Install the package with development dependencies, start the ASGI app target civicnotice.main:app, and open the local /health and /civicnotice routes. A fresh user can reach the core stateless API and public sample UI without a database, model server, account, or API key. + +Minimal smoke check: +1. Confirm /health reports service civicnotice, version 0.2.0, and CivicCore 1.2.0. +2. Post a registry stub to /api/v1/civicnotice/registry without a database and confirm record_id is null with registry notes and the staff-responsibility disclaimer. +3. Open /docs or /openapi.json to inspect accepted fields, then post a rule check to /api/v1/civicnotice/rule-check with a supported notice type, event date, publication dates, channels, content fields, and statutory basis. Unsupported notice types return a 422 response with supported choices. +4. Open /civicnotice and confirm the page is a static public sample with boundary copy, not an official publication workflow. + +Persistence and durable writes: + +Without CIVICNOTICE_WORKPAPER_DB_URL, CivicNotice runs in deterministic stateless mode: registry and deadline POST requests return transient payloads, persisted GET routes return actionable 503 responses, and publication-proof storage is unavailable. + +With CIVICNOTICE_WORKPAPER_DB_URL, registry, deadline, and publication-proof workpapers are durable. Persistence-backed write routes also require CIVICNOTICE_TRUSTED_WRITE_TOKEN and the matching X-CivicNotice-Write-Token request header. This is a minimal trusted-mode guard for local deployments; it is not a replacement for a production identity system. + +Release gate: + +The full release gate requires CIVICNOTICE_POSTGRES_TEST_URL so PostgreSQL persistence coverage cannot be skipped. Plain unit tests may still run without PostgreSQL for local development. + License: Apache License 2.0 for code; CC BY 4.0 for documentation. diff --git a/SECURITY.md b/SECURITY.md index a6b63ad..72ae910 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,32 @@ # Security -CivicNotice is early-stage software. Current version: `0.1.2`. Do not deploy it as a system of record until a release explicitly says it is production-ready. +CivicNotice is early-stage software. Current version: `0.2.0`. Do not deploy it as a system of record until a release explicitly says it is production-ready. -Report suspected vulnerabilities privately to the project maintainer. Do not open public issues containing exploit details, secrets, or sensitive municipal data. +## Supported Versions +| Version | Security support | +| --- | --- | +| 0.2.x | Supported for private vulnerability reports | +| 0.1.x | Best-effort only | + +## Private Reporting + +Report suspected vulnerabilities through GitHub private vulnerability reporting for the `CivicSuite/civicnotice` repository when available. If private reporting is unavailable, contact the repository owner privately before opening a public issue. + +Do not include live municipal secrets, resident data, publication affidavits, subscriber lists, credentials, access tokens, database dumps, or exploit details in public issues, pull requests, screenshots, or discussion threads. + +## What To Include + +- Affected CivicNotice version and commit, if known. +- Whether `CIVICNOTICE_WORKPAPER_DB_URL` or `CIVICNOTICE_TRUSTED_WRITE_TOKEN` was configured. +- Steps to reproduce with synthetic data only. +- Expected and observed behavior. +- Impact assessment, including whether durable workpaper writes or public notice artifacts are exposed. + +## Response Expectations + +Maintainers should acknowledge private reports within 5 business days, triage severity, and coordinate a fix or advisory before public disclosure when the report is valid. Critical issues affecting durable writes, stored workpapers, or sensitive data handling should be prioritized before feature work. + +## Deployment Boundary + +CivicNotice 0.2.0 is designed for local, staff-reviewed municipal workflows. Persistence-backed writes require the trusted write token, but this token is only a minimal local guard. Production deployments should place CivicNotice behind the city's normal authentication, authorization, TLS, logging, backup, and network controls. diff --git a/SUPPORT.md b/SUPPORT.md index 0ecc59f..0cea827 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,8 +2,7 @@ CivicNotice is not a hosted service. Cities or implementers operate their own installation. -For bugs and documentation issues, open a GitHub issue. For security concerns, follow `SECURITY.md`. - +For bugs and documentation issues, open a GitHub issue with synthetic data only. For security concerns, follow `SECURITY.md` and use private vulnerability reporting or a private maintainer contact instead of a public issue. diff --git a/USER-MANUAL.md b/USER-MANUAL.md index f818940..595bc5c 100644 --- a/USER-MANUAL.md +++ b/USER-MANUAL.md @@ -2,27 +2,47 @@ ## For Non-Technical Users -CivicNotice helps city staff organize public hearing notices, legal notices, bid notices, vacancy notices, publication deadlines, channel planning notes, proof requirements, and export manifests. It can create a sample notice registry stub, build publication deadline reminders, retrieve saved registry/deadline workpapers when IT enables persistence, assemble publication-readiness checklists, summarize channel planning, and assemble a notice/records export checklist. +CivicNotice helps city staff organize public hearing notices, legal notices, bid notices, vacancy notices, publication deadlines, channel planning notes, accessibility and language-readiness checks, proof requirements, subscriber delivery plans, archive/handoff packets, and export manifests. It can create a sample notice registry stub, build publication deadline reminders, check a draft packet against deterministic staff-review rules, create a staff-editable notice template, retrieve saved registry/deadline/publication-proof workpapers when IT enables persistence, assemble publication-readiness checklists, summarize channel planning, build accessibility/plain-language/translation readiness packets, build subscriber delivery plans, assemble archive/handoff packets, and assemble a notice/records export checklist. -Current state: `0.1.2` notice compliance foundation release, aligned to the `civiccore v0.9.0` release wheel. CivicNotice uses the shared CivicCore notice-deadline helper for deterministic reminder plans, but it still does not decide legal sufficiency, publish official notices, provide legal advice, call live LLMs, write back to publication systems, or update a notice system of record. Staff own every decision. +Current state: `0.2.0` notice compliance foundation release, aligned to the `civiccore v1.2.0` release wheel. CivicNotice uses the shared CivicCore notice-deadline helper for deterministic reminder plans, but it still does not decide legal sufficiency, publish official notices, provide legal advice, call live LLMs, write back to publication systems, or update a notice system of record. Staff own every decision. ## For IT and Technical Staff -CivicNotice is a FastAPI Python package pinned to the `civiccore v0.9.0` release wheel. The current runtime exposes: +CivicNotice is a FastAPI Python package pinned to the `civiccore v1.2.0` release wheel. The current runtime exposes: -Set `CIVICNOTICE_WORKPAPER_DB_URL` to enable SQLAlchemy-backed notice registry and deadline-plan records. Leave it unset for deterministic stateless operation. +Set `CIVICNOTICE_WORKPAPER_DB_URL` to enable SQLAlchemy-backed notice registry, deadline-plan, and publication-proof records. Leave it unset for deterministic stateless operation: registry and deadline POST requests return transient payloads, persisted GET routes return actionable 503 responses, and publication-proof storage is unavailable. When persistence is enabled, set `CIVICNOTICE_TRUSTED_WRITE_TOKEN` and send the matching `X-CivicNotice-Write-Token` header for durable write routes. - `GET /` - `GET /health` - `GET /civicnotice` +- `GET /docs` +- `GET /openapi.json` - `POST /api/v1/civicnotice/registry` - `GET /api/v1/civicnotice/registry/{record_id}` - `POST /api/v1/civicnotice/deadlines` - `GET /api/v1/civicnotice/deadlines/{plan_id}` +- `POST /api/v1/civicnotice/rule-check` +- `POST /api/v1/civicnotice/templates` +- `POST /api/v1/civicnotice/publication-proof` +- `GET /api/v1/civicnotice/publication-proof/{proof_id}` - `POST /api/v1/civicnotice/publication-check` +- `POST /api/v1/civicnotice/accessibility-review` - `POST /api/v1/civicnotice/channels` +- `POST /api/v1/civicnotice/subscribers/plan` +- `POST /api/v1/civicnotice/archive-packet` - `POST /api/v1/civicnotice/export` +## Start and Smoke-Check CivicNotice + +A fresh user can reach the core stateless API and public sample UI without a database, model server, account, or API key. Start the ASGI app target `civicnotice.main:app`, then confirm `/health` reports CivicNotice `0.2.0` and CivicCore `1.2.0`. + +Minimal smoke-check workflow: + +1. Create a transient registry stub with `/api/v1/civicnotice/registry`. +2. Open `/docs` or `/openapi.json` to inspect accepted fields, then create a deterministic rule check with `/api/v1/civicnotice/rule-check`. Unsupported notice types return supported choices instead of silently falling back to a generic rule. +3. Open `/civicnotice` and confirm it is a static public sample with boundary copy. +4. If durable workpapers are enabled, confirm the trusted write token is configured before testing persistence-backed writes. + Run: ```bash @@ -36,10 +56,10 @@ bash scripts/verify-release.sh ```mermaid flowchart LR Staff["Clerk / communications / purchasing staff"] --> CivicNotice["CivicNotice"] - CivicNotice --> CivicCore["CivicCore v0.9.0"] + CivicNotice --> CivicCore["CivicCore v1.2.0"] CivicNotice -. future handoff .-> CivicClerk["CivicClerk v0.1.0"] CivicNotice -. future handoff .-> CivicProcure["CivicProcure v0.1.1"] CivicNotice --> Export["Notice and records export checklist"] ``` -CivicNotice depends on CivicCore. CivicCore does not depend on CivicNotice. CivicNotice v0.1.2 uses the shared CivicCore notice deadline helper plus deterministic sample notice data only; live agenda/procurement handoffs, legal sufficiency decisions, legal advice, official notice publication, publication-system write-back, and production notice-system integrations are future work. +CivicNotice depends on CivicCore. CivicCore does not depend on CivicNotice. CivicNotice v0.2.0 uses the shared CivicCore notice deadline helper plus deterministic sample notice data, staff-review rule packs, notice templates, accessibility and language-readiness packets, subscriber delivery plans, archive/handoff packets, and optional publication-proof workpapers only; live agenda/procurement handoffs, legal sufficiency decisions, legal advice, official notice publication, publication-system write-back, and production notice-system integrations are future work. diff --git a/USER-MANUAL.txt b/USER-MANUAL.txt index c5e0fd6..0bda902 100644 --- a/USER-MANUAL.txt +++ b/USER-MANUAL.txt @@ -4,16 +4,16 @@ CivicNotice User Manual For non-technical users ----------------------- -CivicNotice helps city staff organize public hearing notices, legal notices, bid notices, vacancy notices, publication deadlines, channel planning notes, proof requirements, and export manifests. It can create a sample notice registry stub, build publication deadline reminders, retrieve saved registry/deadline workpapers when IT enables persistence, assemble publication-readiness checklists, summarize channel planning, and assemble a notice/records export checklist. +CivicNotice helps city staff organize public hearing notices, legal notices, bid notices, vacancy notices, publication deadlines, channel planning notes, accessibility and language-readiness checks, proof requirements, subscriber delivery plans, archive/handoff packets, and export manifests. It can create a sample notice registry stub, build publication deadline reminders, check a draft packet against deterministic staff-review rules, create a staff-editable notice template, retrieve saved registry/deadline/publication-proof workpapers when IT enables persistence, assemble publication-readiness checklists, summarize channel planning, build accessibility/plain-language/translation readiness packets, build subscriber delivery plans, assemble archive/handoff packets, and assemble a notice/records export checklist. -Current state: 0.1.2 notice compliance foundation release, aligned to the civiccore v0.9.0 release wheel. CivicNotice uses the shared CivicCore notice deadline helper for deterministic reminder plans, but it does not decide legal sufficiency, publish official notices, provide legal advice, call live LLMs, write back to publication systems, or update a notice system of record. Staff own every decision. +Current state: 0.2.0 notice compliance foundation release, aligned to the civiccore v1.2.0 release wheel. CivicNotice uses the shared CivicCore notice deadline helper for deterministic reminder plans, but it does not decide legal sufficiency, publish official notices, provide legal advice, call live LLMs, write back to publication systems, or update a notice system of record. Staff own every decision. For IT and technical staff -------------------------- -CivicNotice is a FastAPI Python package pinned to the civiccore v0.9.0 release wheel. The current runtime exposes: +CivicNotice is a FastAPI Python package pinned to the civiccore v1.2.0 release wheel. The current runtime exposes: -Set CIVICNOTICE_WORKPAPER_DB_URL to enable SQLAlchemy-backed notice registry and deadline-plan records. Leave it unset for deterministic stateless operation. +Set CIVICNOTICE_WORKPAPER_DB_URL to enable SQLAlchemy-backed notice registry, deadline-plan, and publication-proof records. Leave it unset for deterministic stateless operation: registry and deadline POST requests return transient payloads, persisted GET routes return actionable 503 responses, and publication-proof storage is unavailable. When persistence is enabled, set CIVICNOTICE_TRUSTED_WRITE_TOKEN and send the matching X-CivicNotice-Write-Token header for durable write routes. - GET / - GET /health @@ -22,10 +22,28 @@ Set CIVICNOTICE_WORKPAPER_DB_URL to enable SQLAlchemy-backed notice registry and - GET /api/v1/civicnotice/registry/{record_id} - POST /api/v1/civicnotice/deadlines - GET /api/v1/civicnotice/deadlines/{plan_id} +- POST /api/v1/civicnotice/rule-check +- POST /api/v1/civicnotice/templates +- POST /api/v1/civicnotice/publication-proof +- GET /api/v1/civicnotice/publication-proof/{proof_id} - POST /api/v1/civicnotice/publication-check +- POST /api/v1/civicnotice/accessibility-review - POST /api/v1/civicnotice/channels +- POST /api/v1/civicnotice/subscribers/plan +- POST /api/v1/civicnotice/archive-packet - POST /api/v1/civicnotice/export +Start and smoke-check CivicNotice +--------------------------------- + +A fresh user can reach the core stateless API and public sample UI without a database, model server, account, or API key. Start the ASGI app target civicnotice.main:app, then confirm /health reports CivicNotice 0.2.0 and CivicCore 1.2.0. + +Minimal smoke-check workflow: +1. Create a transient registry stub with /api/v1/civicnotice/registry. +2. Create a deterministic rule check with /api/v1/civicnotice/rule-check. +3. Open /civicnotice and confirm it is a static public sample with boundary copy. +4. If durable workpapers are enabled, confirm the trusted write token is configured before testing persistence-backed writes. + Run: python -m pip install -e ".[dev]" @@ -35,8 +53,8 @@ bash scripts/verify-release.sh Architecture ------------ -Staff -> CivicNotice -> CivicCore v0.9.0 +Staff -> CivicNotice -> CivicCore v1.2.0 CivicNotice future handoffs -> CivicClerk, CivicProcure, CivicBoards, CivicRecords AI CivicNotice -> Notice and records export checklist -CivicNotice depends on CivicCore. CivicCore does not depend on CivicNotice. CivicNotice v0.1.2 uses the shared CivicCore notice deadline helper plus deterministic sample notice data only; live agenda/procurement handoffs, legal sufficiency decisions, legal advice, official notice publication, publication-system write-back, and production notice-system integrations are future work. +CivicNotice depends on CivicCore. CivicCore does not depend on CivicNotice. CivicNotice v0.2.0 uses the shared CivicCore notice deadline helper plus deterministic sample notice data, staff-review rule packs, notice templates, accessibility and language-readiness packets, subscriber delivery plans, archive/handoff packets, and optional publication-proof workpapers only; live agenda/procurement handoffs, legal sufficiency decisions, legal advice, official notice publication, publication-system write-back, and production notice-system integrations are future work. diff --git a/civicnotice/__init__.py b/civicnotice/__init__.py index f3843a8..b678fb9 100644 --- a/civicnotice/__init__.py +++ b/civicnotice/__init__.py @@ -1,6 +1,6 @@ """CivicNotice package.""" -__version__ = "0.1.2" +__version__ = "0.2.0" diff --git a/civicnotice/accessibility_review.py b/civicnotice/accessibility_review.py new file mode 100644 index 0000000..554115e --- /dev/null +++ b/civicnotice/accessibility_review.py @@ -0,0 +1,86 @@ +"""Accessibility and language-readiness review for CivicNotice v0.2.0.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from civicnotice.notice_registry import DISCLAIMER + + +@dataclass(frozen=True) +class NoticeAccessibilityReview: + notice_id: str + title: str + missing_accessibility_items: tuple[str, ...] + plain_language_flags: tuple[str, ...] + translation_tasks: tuple[str, ...] + attachment_review_required: bool + staff_review_required: bool + review_notes: tuple[str, ...] + disclaimer: str = DISCLAIMER + + +def build_accessibility_review( + *, + notice_id: str, + title: str, + notice_text: str, + target_languages: tuple[str, ...] = (), + attachments: tuple[str, ...] = (), + has_contact: bool = False, + has_event_date: bool = False, + has_plain_language_summary: bool = False, +) -> NoticeAccessibilityReview: + """Build a deterministic public-notice accessibility readiness packet.""" + + missing_items = [] + if not has_contact: + missing_items.append("public contact for accommodations or questions") + if not has_event_date: + missing_items.append("clear event, deadline, or effective date") + if not has_plain_language_summary: + missing_items.append("plain-language summary for public readers") + plain_language_flags = _plain_language_flags(notice_text) + normalized_languages = tuple( + language.strip() for language in target_languages if language.strip() + ) + translation_tasks = tuple( + f"Prepare human-approved {language} notice version or language-access note." + for language in normalized_languages + if language.lower() not in {"english", "en"} + ) + attachment_review_required = bool(attachments) + staff_review_required = bool( + missing_items or plain_language_flags or translation_tasks or attachment_review_required + ) + notes = ( + "This packet prepares staff review before public posting; it does not certify ADA, WCAG, or language-access compliance.", + "CivicAccess integration and live translation workflows are not active in this release.", + "Preserve the approved accessible notice version with the proof-of-publication record.", + ) + return NoticeAccessibilityReview( + notice_id=notice_id.strip() or "unassigned-notice", + title=title.strip() or "Untitled notice", + missing_accessibility_items=tuple(missing_items), + plain_language_flags=plain_language_flags, + translation_tasks=translation_tasks, + attachment_review_required=attachment_review_required, + staff_review_required=staff_review_required, + review_notes=notes, + ) + + +def _plain_language_flags(notice_text: str) -> tuple[str, ...]: + flags = [] + text = notice_text.strip() + if not text: + return ("notice text missing",) + sentences = [part.strip() for part in text.replace("\n", " ").split(".") if part.strip()] + if any(len(sentence) > 180 for sentence in sentences): + flags.append("long sentence review") + jargon_terms = ("hereinafter", "pursuant to", "aforementioned", "whereas") + if any(term in text.lower() for term in jargon_terms): + flags.append("legal or technical jargon review") + if len(text.split()) > 250: + flags.append("summary length review") + return tuple(flags) diff --git a/civicnotice/archive_packet.py b/civicnotice/archive_packet.py new file mode 100644 index 0000000..4fed849 --- /dev/null +++ b/civicnotice/archive_packet.py @@ -0,0 +1,84 @@ +"""Notice archive and handoff packets for CivicNotice v0.2.0.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from civicnotice.notice_registry import DISCLAIMER + + +@dataclass(frozen=True) +class NoticeArchivePacket: + notice_id: str + notice_type: str + source_module: str + source_record_id: str + evidence_items: tuple[str, ...] + missing_items: tuple[str, ...] + handoff_targets: tuple[str, ...] + readiness_status: str + staff_review_required: bool + disclaimer: str = DISCLAIMER + + +def build_notice_archive_packet( + *, + notice_id: str, + notice_type: str, + source_module: str, + source_record_id: str, + registry_record_id: str = "", + deadline_plan_id: str = "", + publication_proof_id: str = "", + rule_check_complete: bool = False, + template_complete: bool = False, + accessibility_review_complete: bool = False, + subscriber_delivery_complete: bool = False, + records_export_complete: bool = False, +) -> NoticeArchivePacket: + """Assemble a notice archive/handoff packet for staff review.""" + + required_items = { + "registry record": registry_record_id, + "deadline plan": deadline_plan_id, + "publication proof": publication_proof_id, + "rule check": "complete" if rule_check_complete else "", + "notice template": "complete" if template_complete else "", + "accessibility review": "complete" if accessibility_review_complete else "", + "subscriber delivery plan": "complete" if subscriber_delivery_complete else "", + "records export checklist": "complete" if records_export_complete else "", + } + evidence_items = tuple( + f"{label}: {value}" for label, value in required_items.items() if value + ) + missing_items = tuple(label for label, value in required_items.items() if not value) + readiness_status = ( + "archive_packet_ready_for_staff_final_review" + if not missing_items + else "archive_packet_incomplete_staff_review_required" + ) + return NoticeArchivePacket( + notice_id=notice_id.strip() or "unassigned-notice", + notice_type=notice_type.strip() or "general notice", + source_module=source_module.strip() or "manual", + source_record_id=source_record_id.strip() or "unlinked source record", + evidence_items=evidence_items, + missing_items=missing_items, + handoff_targets=_handoff_targets(source_module), + readiness_status=readiness_status, + staff_review_required=True, + ) + + +def _handoff_targets(source_module: str) -> tuple[str, ...]: + normalized = source_module.strip().lower() + targets = ["civicrecords"] + if normalized == "civicclerk": + targets.insert(0, "civicclerk") + elif normalized == "civicprocure": + targets.insert(0, "civicprocure") + elif normalized == "civicboards": + targets.insert(0, "civicboards") + else: + targets.insert(0, "manual staff archive") + return tuple(targets) diff --git a/civicnotice/channel_plan.py b/civicnotice/channel_plan.py index ad076fd..609761b 100644 --- a/civicnotice/channel_plan.py +++ b/civicnotice/channel_plan.py @@ -1,4 +1,4 @@ -"""Notice channel planning helpers for CivicNotice v0.1.2.""" +"""Notice channel planning helpers for CivicNotice v0.2.0.""" from __future__ import annotations diff --git a/civicnotice/deadline_tracker.py b/civicnotice/deadline_tracker.py index 93b5dee..875c98a 100644 --- a/civicnotice/deadline_tracker.py +++ b/civicnotice/deadline_tracker.py @@ -1,4 +1,4 @@ -"""Publication deadline helpers for CivicNotice v0.1.2.""" +"""Publication deadline helpers for CivicNotice v0.2.0.""" from __future__ import annotations diff --git a/civicnotice/main.py b/civicnotice/main.py index 3d38948..1365f38 100644 --- a/civicnotice/main.py +++ b/civicnotice/main.py @@ -4,18 +4,29 @@ import os from civiccore import __version__ as CIVICCORE_VERSION -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, Header, HTTPException from fastapi.responses import HTMLResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field +from sqlalchemy.exc import SQLAlchemyError from civicnotice import __version__ +from civicnotice.accessibility_review import build_accessibility_review +from civicnotice.archive_packet import build_notice_archive_packet from civicnotice.channel_plan import plan_notice_channels from civicnotice.deadline_tracker import build_deadline_plan +from civicnotice.notice_templates import build_notice_template from civicnotice.notice_registry import register_notice_stub -from civicnotice.persistence import NoticeWorkpaperRepository, StoredDeadlinePlan, StoredNoticeRecord +from civicnotice.persistence import ( + NoticeWorkpaperRepository, + StoredDeadlinePlan, + StoredNoticeRecord, + StoredPublicationProof, +) from civicnotice.public_ui import render_public_lookup_page from civicnotice.publication_check import build_publication_checklist from civicnotice.records_export import build_notice_records_export +from civicnotice.statutory_rules import check_statutory_notice_requirements, supported_notice_types +from civicnotice.subscriber_delivery import Subscriber, build_subscriber_delivery_plan app = FastAPI( @@ -26,34 +37,149 @@ _workpaper_repository: NoticeWorkpaperRepository | None = None _workpaper_db_url: str | None = None +PERSISTENCE_GET_RESPONSES = { + 404: {"description": "Persisted CivicNotice workpaper record was not found."}, + 503: {"description": "CivicNotice workpaper persistence is not configured."}, +} +PERSISTENCE_WRITE_RESPONSES = { + 403: {"description": "Durable CivicNotice write token is missing or invalid."}, + 503: {"description": "CivicNotice workpaper persistence or durable write guard is not configured."}, +} +SUPPORTED_NOTICE_TYPE_HELP = ( + "Supported rule/template notice types include planning hearing, public hearing, bid notice, " + "vacancy notice, adoption notice, special meeting, and general notice." +) class NoticeRegistryRequest(BaseModel): - notice_id: str - notice_type: str - owner: str + notice_id: str = Field(max_length=160) + notice_type: str = Field(max_length=160) + owner: str = Field(max_length=160) class DeadlineRequest(BaseModel): - notice_type: str + notice_type: str = Field(max_length=160) event_date: date - lead_days: int = 10 + lead_days: int = Field(default=10, ge=0, le=366) class PublicationRequest(BaseModel): - notice_type: str - channel: str + notice_type: str = Field(max_length=160) + channel: str = Field(max_length=160) + + +class RuleCheckRequest(BaseModel): + notice_type: str = Field( + max_length=160, + description=SUPPORTED_NOTICE_TYPE_HELP, + examples=["planning hearing", "public hearing", "bid notice"], + ) + event_date: date = Field( + description="Hearing, bid, vacancy, adoption, special-meeting, or other event date.", + examples=["2026-08-01"], + ) + publication_dates: list[date] = Field( + default_factory=list, + description="Dates when the notice is scheduled or was published.", + examples=[["2026-07-17"]], + ) + channels: list[str] = Field( + default_factory=list, + description="Publication channels staff plans to use or has already used.", + examples=[["city website", "posting board"]], + ) + content_fields: list[str] = Field( + default_factory=list, + description="Notice fields present in the draft packet.", + examples=[["title", "hearing date", "location", "case number", "statutory basis"]], + ) + statutory_basis: str = Field( + default="", + max_length=1000, + description="Staff-entered statutory citation or authority for review.", + examples=["staff-entered basis"], + ) + + +class NoticeTemplateRequest(BaseModel): + notice_type: str = Field( + max_length=160, + description=SUPPORTED_NOTICE_TYPE_HELP, + examples=["planning hearing", "public hearing", "bid notice"], + ) + matter_title: str = Field(max_length=240, examples=["Planning hearing"]) + event_date: date = Field(examples=["2026-08-01"]) + location: str = Field(default="", max_length=1000, examples=["Council Chambers"]) + contact: str = Field(default="", max_length=160, examples=["clerk@example.gov"]) + source_module: str = Field(default="manual", max_length=160, examples=["civicclerk"]) + statutory_basis: str = Field(default="", max_length=1000, examples=["staff-entered basis"]) class ChannelRequest(BaseModel): - notice_type: str - audience: str + notice_type: str = Field(max_length=160) + audience: str = Field(max_length=240) + + +class SubscriberRequest(BaseModel): + subscriber_id: str = Field(max_length=160) + name: str = Field(max_length=160) + email: str = Field(default="", max_length=254) + channels: list[str] = Field(default_factory=list) + language: str = Field(default="English", max_length=80) + active: bool = True + + +class SubscriberDeliveryRequest(BaseModel): + notice_id: str = Field(max_length=160) + notice_type: str = Field(max_length=160) + audience: str = Field(max_length=240) + subscribers: list[SubscriberRequest] = Field(default_factory=list) + required_channels: list[str] = Field(default_factory=lambda: ["email"]) + + +class AccessibilityReviewRequest(BaseModel): + notice_id: str = Field(max_length=160) + title: str = Field(max_length=240) + notice_text: str + target_languages: list[str] = Field(default_factory=list) + attachments: list[str] = Field(default_factory=list) + has_contact: bool = False + has_event_date: bool = False + has_plain_language_summary: bool = False + + +class ArchivePacketRequest(BaseModel): + notice_id: str = Field(max_length=160) + notice_type: str = Field(max_length=160) + source_module: str = Field(default="manual", max_length=160) + source_record_id: str = Field(default="", max_length=160) + registry_record_id: str = Field(default="", max_length=160) + deadline_plan_id: str = Field(default="", max_length=160) + publication_proof_id: str = Field(default="", max_length=160) + rule_check_complete: bool = False + template_complete: bool = False + accessibility_review_complete: bool = False + subscriber_delivery_complete: bool = False + records_export_complete: bool = False class RecordsExportRequest(BaseModel): - notice_id: str - title: str - format: str = "markdown" + notice_id: str = Field(max_length=160) + title: str = Field(max_length=240) + format: str = Field(default="markdown", max_length=40) + + +class PublicationProofRequest(BaseModel): + notice_id: str = Field(max_length=160) + notice_type: str = Field(max_length=160) + source_module: str = Field(default="manual", max_length=160) + source_record_id: str = Field(max_length=160) + channel: str = Field(max_length=160) + published_at: str = Field(max_length=160) + location: str = Field(max_length=1000) + confirmation_reference: str = Field(max_length=240) + statutory_basis: str = Field(max_length=1000) + reviewer: str = Field(max_length=160) @app.get("/") @@ -66,13 +192,13 @@ def root() -> dict[str, str]: "status": "notice compliance foundation", "message": ( "CivicNotice package, API foundation, sample notice registry, CivicCore-backed deadline plans, " - "publication-readiness checklist, channel planning, records export checklist, optional " - "database-backed registry/deadline workpapers, and public UI foundation are online; official " + "statutory rule checks, notice drafting templates, accessibility and language-readiness packets, archive/handoff packets, publication-readiness checklist, channel planning, records export checklist, optional " + "database-backed registry/deadline/publication-proof workpapers, and public UI foundation are online; official " "legal sufficiency decisions, official publication, legal " "advice, live LLM calls, publication-system write-back, and notice system-of-record integrations " "are not implemented yet." ), - "next_step": "Post-v0.1.2 roadmap: statutory rule packs, CivicClerk/CivicProcure/CivicNotice handoffs, and publication proof queues", + "next_step": "Post-v0.2.0 roadmap: jurisdiction-specific statutory rule packs, live CivicClerk/CivicProcure/CivicNotice handoffs, and publication proof queues", } @@ -95,14 +221,22 @@ def public_civicnotice_page() -> str: return render_public_lookup_page() -@app.post("/api/v1/civicnotice/registry") -def notice_registry(request: NoticeRegistryRequest) -> dict[str, object]: +@app.post("/api/v1/civicnotice/registry", responses=PERSISTENCE_WRITE_RESPONSES) +def notice_registry( + request: NoticeRegistryRequest, + x_civicnotice_write_token: str | None = Header(default=None), +) -> dict[str, object]: if _workpaper_database_url() is not None: - return _stored_notice_response(_get_workpaper_repository().create_notice_record( - notice_id=request.notice_id, - notice_type=request.notice_type, - owner=request.owner, - )) + _authorize_persistent_write(x_civicnotice_write_token) + return _with_persistence_errors( + lambda: _stored_notice_response( + _get_workpaper_repository().create_notice_record( + notice_id=request.notice_id, + notice_type=request.notice_type, + owner=request.owner, + ) + ) + ) payload = register_notice_stub( notice_id=request.notice_id, notice_type=request.notice_type, @@ -111,24 +245,45 @@ def notice_registry(request: NoticeRegistryRequest) -> dict[str, object]: payload["record_id"] = None return payload -@app.get("/api/v1/civicnotice/registry/{record_id}") + +@app.get("/api/v1/civicnotice/registry/{record_id}", responses=PERSISTENCE_GET_RESPONSES) def get_notice_registry(record_id: str) -> dict[str, object]: if _workpaper_database_url() is None: - raise HTTPException(status_code=503, detail={"message":"CivicNotice workpaper persistence is not configured.","fix":"Set CIVICNOTICE_WORKPAPER_DB_URL to retrieve persisted notice registry records."}) - stored = _get_workpaper_repository().get_notice_record(record_id) + raise HTTPException( + status_code=503, + detail={ + "message": "CivicNotice workpaper persistence is not configured.", + "fix": "Set CIVICNOTICE_WORKPAPER_DB_URL to retrieve persisted notice registry records.", + }, + ) + stored = _with_persistence_errors(lambda: _get_workpaper_repository().get_notice_record(record_id)) if stored is None: - raise HTTPException(status_code=404, detail={"message":"Notice registry record not found.","fix":"Use a record_id returned by POST /api/v1/civicnotice/registry."}) + raise HTTPException( + status_code=404, + detail={ + "message": "Notice registry record not found.", + "fix": "Use a record_id returned by POST /api/v1/civicnotice/registry.", + }, + ) return _stored_notice_response(stored) -@app.post("/api/v1/civicnotice/deadlines") -def deadline_plan(request: DeadlineRequest) -> dict[str, object]: +@app.post("/api/v1/civicnotice/deadlines", responses=PERSISTENCE_WRITE_RESPONSES) +def deadline_plan( + request: DeadlineRequest, + x_civicnotice_write_token: str | None = Header(default=None), +) -> dict[str, object]: if _workpaper_database_url() is not None: - return _stored_deadline_response(_get_workpaper_repository().create_deadline_plan( - notice_type=request.notice_type, - event_date=request.event_date, - lead_days=request.lead_days, - )) + _authorize_persistent_write(x_civicnotice_write_token) + return _with_persistence_errors( + lambda: _stored_deadline_response( + _get_workpaper_repository().create_deadline_plan( + notice_type=request.notice_type, + event_date=request.event_date, + lead_days=request.lead_days, + ) + ) + ) payload = build_deadline_plan( notice_type=request.notice_type, event_date=request.event_date, @@ -137,16 +292,86 @@ def deadline_plan(request: DeadlineRequest) -> dict[str, object]: payload["plan_id"] = None return payload -@app.get("/api/v1/civicnotice/deadlines/{plan_id}") + +@app.get("/api/v1/civicnotice/deadlines/{plan_id}", responses=PERSISTENCE_GET_RESPONSES) def get_deadline_plan(plan_id: str) -> dict[str, object]: if _workpaper_database_url() is None: - raise HTTPException(status_code=503, detail={"message":"CivicNotice workpaper persistence is not configured.","fix":"Set CIVICNOTICE_WORKPAPER_DB_URL to retrieve persisted deadline plans."}) - stored = _get_workpaper_repository().get_deadline_plan(plan_id) + raise HTTPException( + status_code=503, + detail={ + "message": "CivicNotice workpaper persistence is not configured.", + "fix": "Set CIVICNOTICE_WORKPAPER_DB_URL to retrieve persisted deadline plans.", + }, + ) + stored = _with_persistence_errors(lambda: _get_workpaper_repository().get_deadline_plan(plan_id)) if stored is None: - raise HTTPException(status_code=404, detail={"message":"Deadline plan record not found.","fix":"Use a plan_id returned by POST /api/v1/civicnotice/deadlines."}) + raise HTTPException( + status_code=404, + detail={ + "message": "Deadline plan record not found.", + "fix": "Use a plan_id returned by POST /api/v1/civicnotice/deadlines.", + }, + ) return _stored_deadline_response(stored) +@app.post("/api/v1/civicnotice/publication-proof", responses=PERSISTENCE_WRITE_RESPONSES) +def publication_proof( + request: PublicationProofRequest, + x_civicnotice_write_token: str | None = Header(default=None), +) -> dict[str, object]: + if _workpaper_database_url() is None: + raise HTTPException( + status_code=503, + detail={ + "message": "CivicNotice workpaper persistence is not configured.", + "fix": "Set CIVICNOTICE_WORKPAPER_DB_URL to store durable publication proof records.", + }, + ) + _authorize_persistent_write(x_civicnotice_write_token) + stored = _with_persistence_errors( + lambda: _get_workpaper_repository().create_publication_proof( + notice_id=request.notice_id, + notice_type=request.notice_type, + source_module=request.source_module, + source_record_id=request.source_record_id, + channel=request.channel, + published_at=request.published_at, + location=request.location, + confirmation_reference=request.confirmation_reference, + statutory_basis=request.statutory_basis, + reviewer=request.reviewer, + ) + ) + return _stored_publication_proof_response(stored) + + +@app.get( + "/api/v1/civicnotice/publication-proof/{proof_id}", responses=PERSISTENCE_GET_RESPONSES +) +def get_publication_proof(proof_id: str) -> dict[str, object]: + if _workpaper_database_url() is None: + raise HTTPException( + status_code=503, + detail={ + "message": "CivicNotice workpaper persistence is not configured.", + "fix": "Set CIVICNOTICE_WORKPAPER_DB_URL to retrieve persisted publication proof records.", + }, + ) + stored = _with_persistence_errors( + lambda: _get_workpaper_repository().get_publication_proof(proof_id) + ) + if stored is None: + raise HTTPException( + status_code=404, + detail={ + "message": "Publication proof record not found.", + "fix": "Use a proof_id returned by POST /api/v1/civicnotice/publication-proof.", + }, + ) + return _stored_publication_proof_response(stored) + + @app.post("/api/v1/civicnotice/publication-check") def publication_checklist(request: PublicationRequest) -> dict[str, object]: return build_publication_checklist( @@ -155,6 +380,43 @@ def publication_checklist(request: PublicationRequest) -> dict[str, object]: ).__dict__ +@app.post("/api/v1/civicnotice/rule-check") +def rule_check(request: RuleCheckRequest) -> dict[str, object]: + try: + result = check_statutory_notice_requirements( + notice_type=request.notice_type, + event_date=request.event_date, + publication_dates=tuple(request.publication_dates), + channels=tuple(request.channels), + content_fields=tuple(request.content_fields), + statutory_basis=request.statutory_basis, + ) + except ValueError as exc: + raise _unsupported_notice_type_error(str(exc)) from exc + payload = result.__dict__.copy() + payload["event_date"] = result.event_date.isoformat() + payload["required_deadline_date"] = result.required_deadline_date.isoformat() + payload["publication_dates"] = [value.isoformat() for value in result.publication_dates] + return payload + + +@app.post("/api/v1/civicnotice/templates") +def notice_template(request: NoticeTemplateRequest) -> dict[str, object]: + try: + result = build_notice_template( + notice_type=request.notice_type, + matter_title=request.matter_title, + event_date=request.event_date, + location=request.location, + contact=request.contact, + source_module=request.source_module, + statutory_basis=request.statutory_basis, + ) + except ValueError as exc: + raise _unsupported_notice_type_error(str(exc)) from exc + return result.__dict__ + + @app.post("/api/v1/civicnotice/channels") def channel_plan(request: ChannelRequest) -> dict[str, object]: return plan_notice_channels( @@ -163,6 +425,60 @@ def channel_plan(request: ChannelRequest) -> dict[str, object]: ).__dict__ +@app.post("/api/v1/civicnotice/subscribers/plan") +def subscriber_delivery_plan(request: SubscriberDeliveryRequest) -> dict[str, object]: + subscribers = tuple( + Subscriber( + subscriber_id=subscriber.subscriber_id, + name=subscriber.name, + email=subscriber.email, + channels=tuple(subscriber.channels), + language=subscriber.language, + active=subscriber.active, + ) + for subscriber in request.subscribers + ) + return build_subscriber_delivery_plan( + notice_id=request.notice_id, + notice_type=request.notice_type, + audience=request.audience, + subscribers=subscribers, + required_channels=tuple(request.required_channels), + ).__dict__ + + +@app.post("/api/v1/civicnotice/accessibility-review") +def accessibility_review(request: AccessibilityReviewRequest) -> dict[str, object]: + return build_accessibility_review( + notice_id=request.notice_id, + title=request.title, + notice_text=request.notice_text, + target_languages=tuple(request.target_languages), + attachments=tuple(request.attachments), + has_contact=request.has_contact, + has_event_date=request.has_event_date, + has_plain_language_summary=request.has_plain_language_summary, + ).__dict__ + + +@app.post("/api/v1/civicnotice/archive-packet") +def archive_packet(request: ArchivePacketRequest) -> dict[str, object]: + return build_notice_archive_packet( + notice_id=request.notice_id, + notice_type=request.notice_type, + source_module=request.source_module, + source_record_id=request.source_record_id, + registry_record_id=request.registry_record_id, + deadline_plan_id=request.deadline_plan_id, + publication_proof_id=request.publication_proof_id, + rule_check_complete=request.rule_check_complete, + template_complete=request.template_complete, + accessibility_review_complete=request.accessibility_review_complete, + subscriber_delivery_complete=request.subscriber_delivery_complete, + records_export_complete=request.records_export_complete, + ).__dict__ + + @app.post("/api/v1/civicnotice/export") def records_export(request: RecordsExportRequest) -> dict[str, object]: return build_notice_records_export( @@ -171,9 +487,35 @@ def records_export(request: RecordsExportRequest) -> dict[str, object]: format=request.format, ).__dict__ + def _workpaper_database_url() -> str | None: return os.environ.get("CIVICNOTICE_WORKPAPER_DB_URL") + +def _trusted_write_token() -> str | None: + return os.environ.get("CIVICNOTICE_TRUSTED_WRITE_TOKEN") + + +def _authorize_persistent_write(provided_token: str | None) -> None: + expected_token = _trusted_write_token() + if not expected_token: + raise HTTPException( + status_code=503, + detail={ + "message": "CivicNotice durable write guard is not configured.", + "fix": "Set CIVICNOTICE_TRUSTED_WRITE_TOKEN before enabling persistence-backed writes.", + }, + ) + if provided_token != expected_token: + raise HTTPException( + status_code=403, + detail={ + "message": "CivicNotice durable write token is missing or invalid.", + "fix": "Send the configured X-CivicNotice-Write-Token header for persistence-backed writes.", + }, + ) + + def _get_workpaper_repository() -> NoticeWorkpaperRepository: global _workpaper_db_url, _workpaper_repository db_url = _workpaper_database_url() @@ -185,14 +527,77 @@ def _get_workpaper_repository() -> NoticeWorkpaperRepository: _workpaper_repository = NoticeWorkpaperRepository(db_url=db_url) return _workpaper_repository + +def _with_persistence_errors(operation): + try: + return operation() + except SQLAlchemyError as exc: + _dispose_workpaper_repository() + raise HTTPException( + status_code=503, + detail={ + "message": "CivicNotice workpaper persistence is configured but unavailable.", + "fix": "Verify CIVICNOTICE_WORKPAPER_DB_URL, database reachability, credentials, and schema permissions.", + }, + ) from exc + + +def _unsupported_notice_type_error(message: str) -> HTTPException: + return HTTPException( + status_code=422, + detail={ + "message": message, + "supported_notice_types": list(supported_notice_types()), + }, + ) + + def _dispose_workpaper_repository() -> None: global _workpaper_repository if _workpaper_repository is not None: _workpaper_repository.engine.dispose() _workpaper_repository = None + def _stored_notice_response(stored: StoredNoticeRecord) -> dict[str, object]: - return {"record_id": stored.record_id, "notice_id": stored.notice_id, "notice_type": stored.notice_type, "owner": stored.owner, "registry_notes": list(stored.registry_notes), "disclaimer": stored.disclaimer, "created_at": stored.created_at.isoformat()} + return { + "record_id": stored.record_id, + "notice_id": stored.notice_id, + "notice_type": stored.notice_type, + "owner": stored.owner, + "registry_notes": list(stored.registry_notes), + "disclaimer": stored.disclaimer, + "created_at": stored.created_at.isoformat(), + } + def _stored_deadline_response(stored: StoredDeadlinePlan) -> dict[str, object]: - return {"plan_id": stored.plan_id, "notice_type": stored.notice_type, "event_date": stored.event_date.isoformat(), "reminders": list(stored.reminders), "staff_review_required": stored.staff_review_required, "disclaimer": stored.disclaimer, "created_at": stored.created_at.isoformat()} + return { + "plan_id": stored.plan_id, + "notice_type": stored.notice_type, + "event_date": stored.event_date.isoformat(), + "reminders": list(stored.reminders), + "staff_review_required": stored.staff_review_required, + "disclaimer": stored.disclaimer, + "created_at": stored.created_at.isoformat(), + } + + +def _stored_publication_proof_response(stored: StoredPublicationProof) -> dict[str, object]: + return { + "proof_id": stored.proof_id, + "notice_id": stored.notice_id, + "notice_type": stored.notice_type, + "source_module": stored.source_module, + "source_record_id": stored.source_record_id, + "channel": stored.channel, + "published_at": stored.published_at, + "location": stored.location, + "confirmation_reference": stored.confirmation_reference, + "statutory_basis": stored.statutory_basis, + "reviewer": stored.reviewer, + "proof_notes": list(stored.proof_notes), + "compliance_status": stored.compliance_status, + "disclaimer": stored.disclaimer, + "created_at": stored.created_at.isoformat(), + } diff --git a/civicnotice/notice_registry.py b/civicnotice/notice_registry.py index f602460..7d2e6e8 100644 --- a/civicnotice/notice_registry.py +++ b/civicnotice/notice_registry.py @@ -1,4 +1,4 @@ -"""Notice registry helpers for CivicNotice v0.1.2.""" +"""Notice registry helpers for CivicNotice v0.2.0.""" from __future__ import annotations diff --git a/civicnotice/notice_templates.py b/civicnotice/notice_templates.py new file mode 100644 index 0000000..44981aa --- /dev/null +++ b/civicnotice/notice_templates.py @@ -0,0 +1,63 @@ +"""Notice drafting templates for CivicNotice v0.2.0.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from civicnotice.notice_registry import DISCLAIMER +from civicnotice.statutory_rules import resolve_notice_rule + + +@dataclass(frozen=True) +class NoticeTemplate: + notice_type: str + title: str + source_module: str + template_lines: tuple[str, ...] + required_fields: tuple[str, ...] + placeholders_remaining: tuple[str, ...] + staff_review_required: bool + disclaimer: str = DISCLAIMER + + +def build_notice_template( + *, + notice_type: str, + matter_title: str, + event_date: date, + location: str, + contact: str, + source_module: str = "manual", + statutory_basis: str = "", +) -> NoticeTemplate: + """Build a staff-editable notice template without declaring it legally sufficient.""" + + rule = resolve_notice_rule(notice_type) + clean_title = matter_title.strip() or "Untitled notice matter" + clean_location = location.strip() or "[staff must enter location]" + clean_contact = contact.strip() or "[staff must enter contact]" + clean_basis = statutory_basis.strip() or "[staff must enter statutory basis]" + placeholders = tuple( + value + for value in (clean_location, clean_contact, clean_basis) + if value.startswith("[staff must enter") + ) + lines = ( + f"NOTICE: {clean_title}", + f"Notice type: {rule.notice_type}.", + f"Event or deadline date: {event_date.isoformat()}.", + f"Location or submission path: {clean_location}.", + f"Staff contact: {clean_contact}.", + f"Statutory basis: {clean_basis}.", + "Staff must verify required publication channels, lead time, accessibility, and proof before release.", + ) + return NoticeTemplate( + notice_type=rule.notice_type, + title=clean_title, + source_module=source_module.strip() or "manual", + template_lines=lines, + required_fields=rule.required_content, + placeholders_remaining=placeholders, + staff_review_required=True, + ) diff --git a/civicnotice/persistence.py b/civicnotice/persistence.py index 1521ef9..1cdb35e 100644 --- a/civicnotice/persistence.py +++ b/civicnotice/persistence.py @@ -9,6 +9,7 @@ from civicnotice.deadline_tracker import build_deadline_plan from civicnotice.notice_registry import register_notice_stub +from civicnotice.publication_proof import record_publication_proof metadata = sa.MetaData() @@ -39,6 +40,27 @@ schema="civicnotice", ) +publication_proof_records = sa.Table( + "publication_proof_records", + metadata, + sa.Column("proof_id", sa.String(36), primary_key=True), + sa.Column("notice_id", sa.String(160), nullable=False), + sa.Column("notice_type", sa.String(160), nullable=False), + sa.Column("source_module", sa.String(160), nullable=False), + sa.Column("source_record_id", sa.String(160), nullable=False), + sa.Column("channel", sa.String(160), nullable=False), + sa.Column("published_at", sa.String(160), nullable=False), + sa.Column("location", sa.Text(), nullable=False), + sa.Column("confirmation_reference", sa.String(240), nullable=False), + sa.Column("statutory_basis", sa.Text(), nullable=False), + sa.Column("reviewer", sa.String(160), nullable=False), + sa.Column("proof_notes", sa.JSON(), nullable=False), + sa.Column("compliance_status", sa.String(160), nullable=False), + sa.Column("disclaimer", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + schema="civicnotice", +) + @dataclass(frozen=True) class StoredNoticeRecord: @@ -62,6 +84,25 @@ class StoredDeadlinePlan: created_at: datetime +@dataclass(frozen=True) +class StoredPublicationProof: + proof_id: str + notice_id: str + notice_type: str + source_module: str + source_record_id: str + channel: str + published_at: str + location: str + confirmation_reference: str + statutory_basis: str + reviewer: str + proof_notes: tuple[str, ...] + compliance_status: str + disclaimer: str + created_at: datetime + + class NoticeWorkpaperRepository: def __init__(self, *, db_url: str | None = None, engine: Engine | None = None) -> None: base_engine = engine or create_engine(db_url or "sqlite+pysqlite:///:memory:", future=True) @@ -73,32 +114,203 @@ def __init__(self, *, db_url: str | None = None, engine: Engine | None = None) - connection.execute(sa.text("CREATE SCHEMA IF NOT EXISTS civicnotice")) metadata.create_all(self.engine) - def create_notice_record(self, *, notice_id: str, notice_type: str, owner: str) -> StoredNoticeRecord: + def create_notice_record( + self, *, notice_id: str, notice_type: str, owner: str + ) -> StoredNoticeRecord: record = register_notice_stub(notice_id=notice_id, notice_type=notice_type, owner=owner) - stored = StoredNoticeRecord(str(uuid4()), record.notice_id, record.notice_type, record.owner, record.registry_notes, record.disclaimer, datetime.now(UTC)) + stored = StoredNoticeRecord( + str(uuid4()), + record.notice_id, + record.notice_type, + record.owner, + record.registry_notes, + record.disclaimer, + datetime.now(UTC), + ) with self.engine.begin() as connection: - connection.execute(notice_registry_records.insert().values(record_id=stored.record_id, notice_id=stored.notice_id, notice_type=stored.notice_type, owner=stored.owner, registry_notes=list(stored.registry_notes), disclaimer=stored.disclaimer, created_at=stored.created_at)) + connection.execute( + notice_registry_records.insert().values( + record_id=stored.record_id, + notice_id=stored.notice_id, + notice_type=stored.notice_type, + owner=stored.owner, + registry_notes=list(stored.registry_notes), + disclaimer=stored.disclaimer, + created_at=stored.created_at, + ) + ) return stored def get_notice_record(self, record_id: str) -> StoredNoticeRecord | None: with self.engine.begin() as connection: - row = connection.execute(sa.select(notice_registry_records).where(notice_registry_records.c.record_id == record_id)).mappings().first() + row = ( + connection.execute( + sa.select(notice_registry_records).where( + notice_registry_records.c.record_id == record_id + ) + ) + .mappings() + .first() + ) if row is None: return None data = dict(row) - return StoredNoticeRecord(data["record_id"], data["notice_id"], data["notice_type"], data["owner"], tuple(data["registry_notes"]), data["disclaimer"], data["created_at"]) + return StoredNoticeRecord( + data["record_id"], + data["notice_id"], + data["notice_type"], + data["owner"], + tuple(data["registry_notes"]), + data["disclaimer"], + data["created_at"], + ) - def create_deadline_plan(self, *, notice_type: str, event_date: date, lead_days: int = 10) -> StoredDeadlinePlan: - plan = build_deadline_plan(notice_type=notice_type, event_date=event_date, lead_days=lead_days) - stored = StoredDeadlinePlan(str(uuid4()), plan.notice_type, plan.event_date, plan.reminders, plan.staff_review_required, plan.disclaimer, datetime.now(UTC)) + def create_deadline_plan( + self, *, notice_type: str, event_date: date, lead_days: int = 10 + ) -> StoredDeadlinePlan: + plan = build_deadline_plan( + notice_type=notice_type, event_date=event_date, lead_days=lead_days + ) + stored = StoredDeadlinePlan( + str(uuid4()), + plan.notice_type, + plan.event_date, + plan.reminders, + plan.staff_review_required, + plan.disclaimer, + datetime.now(UTC), + ) with self.engine.begin() as connection: - connection.execute(deadline_plan_records.insert().values(plan_id=stored.plan_id, notice_type=stored.notice_type, event_date=stored.event_date, reminders=list(stored.reminders), staff_review_required=stored.staff_review_required, disclaimer=stored.disclaimer, created_at=stored.created_at)) + connection.execute( + deadline_plan_records.insert().values( + plan_id=stored.plan_id, + notice_type=stored.notice_type, + event_date=stored.event_date, + reminders=list(stored.reminders), + staff_review_required=stored.staff_review_required, + disclaimer=stored.disclaimer, + created_at=stored.created_at, + ) + ) return stored def get_deadline_plan(self, plan_id: str) -> StoredDeadlinePlan | None: with self.engine.begin() as connection: - row = connection.execute(sa.select(deadline_plan_records).where(deadline_plan_records.c.plan_id == plan_id)).mappings().first() + row = ( + connection.execute( + sa.select(deadline_plan_records).where( + deadline_plan_records.c.plan_id == plan_id + ) + ) + .mappings() + .first() + ) + if row is None: + return None + data = dict(row) + return StoredDeadlinePlan( + data["plan_id"], + data["notice_type"], + data["event_date"], + tuple(data["reminders"]), + data["staff_review_required"], + data["disclaimer"], + data["created_at"], + ) + + def create_publication_proof( + self, + *, + notice_id: str, + notice_type: str, + source_module: str, + source_record_id: str, + channel: str, + published_at: str, + location: str, + confirmation_reference: str, + statutory_basis: str, + reviewer: str, + ) -> StoredPublicationProof: + proof = record_publication_proof( + notice_id=notice_id, + notice_type=notice_type, + source_module=source_module, + source_record_id=source_record_id, + channel=channel, + published_at=published_at, + location=location, + confirmation_reference=confirmation_reference, + statutory_basis=statutory_basis, + reviewer=reviewer, + ) + stored = StoredPublicationProof( + str(uuid4()), + proof.notice_id, + proof.notice_type, + proof.source_module, + proof.source_record_id, + proof.channel, + proof.published_at, + proof.location, + proof.confirmation_reference, + proof.statutory_basis, + proof.reviewer, + proof.proof_notes, + proof.compliance_status, + proof.disclaimer, + datetime.now(UTC), + ) + with self.engine.begin() as connection: + connection.execute( + publication_proof_records.insert().values( + proof_id=stored.proof_id, + notice_id=stored.notice_id, + notice_type=stored.notice_type, + source_module=stored.source_module, + source_record_id=stored.source_record_id, + channel=stored.channel, + published_at=stored.published_at, + location=stored.location, + confirmation_reference=stored.confirmation_reference, + statutory_basis=stored.statutory_basis, + reviewer=stored.reviewer, + proof_notes=list(stored.proof_notes), + compliance_status=stored.compliance_status, + disclaimer=stored.disclaimer, + created_at=stored.created_at, + ) + ) + return stored + + def get_publication_proof(self, proof_id: str) -> StoredPublicationProof | None: + with self.engine.begin() as connection: + row = ( + connection.execute( + sa.select(publication_proof_records).where( + publication_proof_records.c.proof_id == proof_id + ) + ) + .mappings() + .first() + ) if row is None: return None data = dict(row) - return StoredDeadlinePlan(data["plan_id"], data["notice_type"], data["event_date"], tuple(data["reminders"]), data["staff_review_required"], data["disclaimer"], data["created_at"]) + return StoredPublicationProof( + data["proof_id"], + data["notice_id"], + data["notice_type"], + data["source_module"], + data["source_record_id"], + data["channel"], + data["published_at"], + data["location"], + data["confirmation_reference"], + data["statutory_basis"], + data["reviewer"], + tuple(data["proof_notes"]), + data["compliance_status"], + data["disclaimer"], + data["created_at"], + ) diff --git a/civicnotice/public_ui.py b/civicnotice/public_ui.py index 735e405..21b70df 100644 --- a/civicnotice/public_ui.py +++ b/civicnotice/public_ui.py @@ -1,4 +1,4 @@ -"""Static public UI shell for CivicNotice v0.1.2.""" +"""Static public UI shell for CivicNotice v0.2.0.""" from __future__ import annotations @@ -25,6 +25,8 @@ def render_public_lookup_page() -> str: h1 { max-width:980px; margin:0; font-family:Georgia,"Times New Roman",serif; font-size:clamp(2.2rem,7vw,5.4rem); line-height:1.02; letter-spacing:-.04em; overflow-wrap:break-word; word-break:break-word; } .lede { max-width:840px; font-size:clamp(1.1rem,2.4vw,1.45rem); line-height:1.55; color:#31404a; } .badge { display:inline-flex; width:fit-content; padding:.45rem .75rem; border-radius:999px; background:var(--green); color:white; font-weight:900; } + .quick-links { display:flex; flex-wrap:wrap; gap:.75rem; margin-top:1.1rem; } + .quick-links a { display:inline-flex; align-items:center; min-height:44px; padding:.65rem .9rem; border:1px solid var(--blue); border-radius:999px; color:var(--blue); background:white; font-weight:800; text-decoration:none; } .grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:18px; min-width:0; } .card { grid-column:span 6; min-width:0; max-width:100%; padding:24px; border:1px solid var(--line); border-radius:28px; background:rgba(255,255,255,.92); box-shadow:0 18px 40px rgba(35,43,50,.10); } .card.large { grid-column:span 12; } @@ -33,7 +35,7 @@ def render_public_lookup_page() -> str: p, li { line-height:1.65; overflow-wrap:anywhere; } textarea, button { width:100%; max-width:100%; min-width:0; border:1px solid #b9c6cc; border-radius:16px; padding:.85rem 1rem; font:inherit; } textarea { background:#f7f8fb; color:var(--ink); } - button { width:fit-content; min-width:190px; border:0; background:var(--blue); color:white; font-weight:900; cursor:default; } + .sample-box { width:100%; max-width:100%; min-width:0; border:1px solid #b9c6cc; border-radius:16px; padding:.85rem 1rem; background:#f7f8fb; color:var(--ink); } .result { margin-top:18px; padding:18px; border-left:6px solid var(--green); border-radius:18px; background:white; } .warning { border-left-color:#b2603f; background:#fff8f4; } .kicker { color:var(--muted); font-size:.86rem; font-weight:900; letter-spacing:.08em; text-transform:uppercase; } @@ -47,19 +49,23 @@ def render_public_lookup_page() -> str:

CivicSuite / CivicNotice public sample

Public notices with fewer missed deadlines.

-

CivicNotice demonstrates notice administration support: registry stubs, deadline plans, publication-readiness checks, channel planning, and proof-preserving records exports without publishing official notice.

-

v0.1.2 notice compliance foundation

+

CivicNotice demonstrates notice administration support: registry stubs, deadline plans, rule checks, templates, accessibility review, subscriber delivery planning, archive packets, and proof-preserving records exports without publishing official notice.

+ +

v0.2.0 notice compliance foundation

Sample hearing notice

Planning hearing notice

- - +

Supported rule-check and template notice types include planning hearing, public hearing, bid notice, vacancy notice, adoption notice, special meeting, and general notice.

+
Public hearing on rezoning request. Confirm statutory authority, publication lead time, accessibility needs, and proof of publication.

Staff review packet

-
  • Confirm statutory authority, publication channel, lead time, and reviewer.
  • Route draft copy for clerk/legal review before publication.
  • Preserve final notice, proof, screenshots, invoices, and source matter.
+
  • Confirm statutory authority, publication channel, lead time, and reviewer.
  • Route draft copy for clerk/legal review before publication.
  • Check accessibility, language access, subscriber delivery, proof, and archive handoff before closing the file.

Deadlines

Lead-time reminders

CivicNotice calculates review milestones; staff verify the legal deadline.

diff --git a/civicnotice/publication_check.py b/civicnotice/publication_check.py index 6d2db74..96599f9 100644 --- a/civicnotice/publication_check.py +++ b/civicnotice/publication_check.py @@ -1,4 +1,4 @@ -"""Publication-readiness checks for CivicNotice v0.1.2.""" +"""Publication-readiness checks for CivicNotice v0.2.0.""" from __future__ import annotations diff --git a/civicnotice/publication_proof.py b/civicnotice/publication_proof.py new file mode 100644 index 0000000..9083591 --- /dev/null +++ b/civicnotice/publication_proof.py @@ -0,0 +1,66 @@ +"""Publication proof workpaper helpers for CivicNotice v0.2.0.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from civicnotice.notice_registry import DISCLAIMER + + +@dataclass(frozen=True) +class PublicationProof: + notice_id: str + notice_type: str + source_module: str + source_record_id: str + channel: str + published_at: str + location: str + confirmation_reference: str + statutory_basis: str + reviewer: str + proof_notes: tuple[str, ...] + compliance_status: str + disclaimer: str = DISCLAIMER + + +def record_publication_proof( + *, + notice_id: str, + notice_type: str, + source_module: str, + source_record_id: str, + channel: str, + published_at: str, + location: str, + confirmation_reference: str, + statutory_basis: str, + reviewer: str, +) -> PublicationProof: + """Return a staff-owned publication proof packet for durable storage.""" + + clean_channel = channel.strip() or "unspecified channel" + clean_location = location.strip() or "unspecified location" + clean_reference = confirmation_reference.strip() or "pending confirmation reference" + clean_basis = statutory_basis.strip() or "staff-provided statutory basis required" + clean_reviewer = reviewer.strip() or "staff review required" + proof_notes = ( + f"Preserve proof from {clean_channel} at {clean_location}.", + f"Link confirmation reference: {clean_reference}.", + f"Staff must verify statutory basis before relying on this packet: {clean_basis}.", + "This packet stores evidence for staff review; it does not certify legal sufficiency or publish an official notice.", + ) + return PublicationProof( + notice_id=notice_id.strip() or "unassigned notice", + notice_type=notice_type.strip() or "general notice", + source_module=source_module.strip() or "manual", + source_record_id=source_record_id.strip() or "unlinked source record", + channel=clean_channel, + published_at=published_at.strip() or "pending publication timestamp", + location=clean_location, + confirmation_reference=clean_reference, + statutory_basis=clean_basis, + reviewer=clean_reviewer, + proof_notes=proof_notes, + compliance_status="proof_recorded_staff_review_required", + ) diff --git a/civicnotice/records_export.py b/civicnotice/records_export.py index ade08d4..16a6d0e 100644 --- a/civicnotice/records_export.py +++ b/civicnotice/records_export.py @@ -1,4 +1,4 @@ -"""Notice records export helpers for CivicNotice v0.1.2.""" +"""Notice records export helpers for CivicNotice v0.2.0.""" from __future__ import annotations diff --git a/civicnotice/statutory_rules.py b/civicnotice/statutory_rules.py new file mode 100644 index 0000000..e8e25dd --- /dev/null +++ b/civicnotice/statutory_rules.py @@ -0,0 +1,176 @@ +"""Deterministic statutory notice rule checks for CivicNotice v0.2.0.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta + +from civicnotice.notice_registry import DISCLAIMER + + +@dataclass(frozen=True) +class NoticeRule: + notice_type: str + minimum_lead_days: int + required_publications: int + required_channels: tuple[str, ...] + required_content: tuple[str, ...] + emergency_path_allowed: bool = False + + +@dataclass(frozen=True) +class StatutoryRuleCheck: + notice_type: str + event_date: date + required_deadline_date: date + publication_dates: tuple[date, ...] + channels: tuple[str, ...] + required_publications: int + required_channels: tuple[str, ...] + missing_channels: tuple[str, ...] + required_content: tuple[str, ...] + missing_content: tuple[str, ...] + statutory_basis: str + deadline_status: str + publication_count_status: str + staff_review_required: bool + disclaimer: str = DISCLAIMER + + +RULE_PACKS: dict[str, NoticeRule] = { + "general notice": NoticeRule( + notice_type="general notice", + minimum_lead_days=0, + required_publications=1, + required_channels=(), + required_content=("title", "event date", "location", "statutory basis"), + ), + "planning hearing": NoticeRule( + notice_type="planning hearing", + minimum_lead_days=15, + required_publications=1, + required_channels=("city website", "posting board"), + required_content=("title", "hearing date", "location", "case number", "statutory basis"), + ), + "bid notice": NoticeRule( + notice_type="bid notice", + minimum_lead_days=14, + required_publications=1, + required_channels=("city website", "newspaper"), + required_content=("title", "bid deadline", "scope summary", "submission location"), + ), + "vacancy notice": NoticeRule( + notice_type="vacancy notice", + minimum_lead_days=10, + required_publications=1, + required_channels=("city website", "posting board"), + required_content=("title", "office", "application deadline", "eligibility summary"), + ), + "adoption notice": NoticeRule( + notice_type="adoption notice", + minimum_lead_days=5, + required_publications=1, + required_channels=("city website",), + required_content=("title", "adopted action", "effective date", "inspection location"), + ), + "special meeting": NoticeRule( + notice_type="special meeting", + minimum_lead_days=2, + required_publications=1, + required_channels=("city website", "posting board"), + required_content=("title", "meeting date", "location", "statutory basis"), + emergency_path_allowed=True, + ), +} + +NOTICE_TYPE_ALIASES: dict[str, str] = { + "public hearing": "planning hearing", + "hearing": "planning hearing", +} + + +def check_statutory_notice_requirements( + *, + notice_type: str, + event_date: date, + publication_dates: tuple[date, ...] = (), + channels: tuple[str, ...] = (), + content_fields: tuple[str, ...] = (), + statutory_basis: str = "", +) -> StatutoryRuleCheck: + """Check a notice packet against deterministic staff-review rules.""" + + rule = resolve_notice_rule(notice_type) + normalized_channels = tuple(_clean(value) for value in channels if _clean(value)) + normalized_content = tuple(_clean(value) for value in content_fields if _clean(value)) + required_deadline = event_date - timedelta(days=rule.minimum_lead_days) + missing_channels = tuple( + channel for channel in rule.required_channels if channel not in normalized_channels + ) + missing_content = tuple( + field for field in rule.required_content if field not in normalized_content + ) + deadline_status = _deadline_status( + publication_dates=publication_dates, + required_deadline=required_deadline, + ) + publication_count_status = ( + "required_publication_count_met" + if len(publication_dates) >= rule.required_publications + else "publication_count_staff_review_required" + ) + basis_missing = "statutory basis" in rule.required_content and not statutory_basis.strip() + staff_review_required = bool( + missing_channels + or missing_content + or basis_missing + or deadline_status != "meets_minimum_lead_time" + or publication_count_status != "required_publication_count_met" + ) + return StatutoryRuleCheck( + notice_type=rule.notice_type, + event_date=event_date, + required_deadline_date=required_deadline, + publication_dates=publication_dates, + channels=normalized_channels, + required_publications=rule.required_publications, + required_channels=rule.required_channels, + missing_channels=missing_channels, + required_content=rule.required_content, + missing_content=missing_content, + statutory_basis=statutory_basis.strip() or "staff-provided statutory basis required", + deadline_status=deadline_status, + publication_count_status=publication_count_status, + staff_review_required=staff_review_required, + ) + + +def supported_notice_types() -> tuple[str, ...]: + """Return user-facing notice type values accepted by rule checks.""" + + return tuple(sorted((*RULE_PACKS.keys(), *NOTICE_TYPE_ALIASES.keys()))) + + +def resolve_notice_rule(notice_type: str) -> NoticeRule: + """Return the matching rule or raise a helpful error for unsupported input.""" + + normalized = _clean(notice_type) + canonical = NOTICE_TYPE_ALIASES.get(normalized, normalized) + if canonical not in RULE_PACKS: + valid_types = ", ".join(supported_notice_types()) + raise ValueError(f"Unsupported notice type '{notice_type}'. Supported notice types: {valid_types}.") + return RULE_PACKS[canonical] + + +def _deadline_status(*, publication_dates: tuple[date, ...], required_deadline: date) -> str: + if not publication_dates: + return "publication_schedule_missing_staff_review_required" + latest_publication = max(publication_dates) + if latest_publication <= required_deadline: + return "meets_minimum_lead_time" + return "deadline_risk_staff_review_required" + + +def _clean(value: str) -> str: + normalized = value.replace("_", " ").replace("-", " ") + return " ".join(normalized.strip().lower().split()) diff --git a/civicnotice/subscriber_delivery.py b/civicnotice/subscriber_delivery.py new file mode 100644 index 0000000..224774a --- /dev/null +++ b/civicnotice/subscriber_delivery.py @@ -0,0 +1,93 @@ +"""Subscriber delivery planning for CivicNotice v0.2.0.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from civicnotice.notice_registry import DISCLAIMER + + +@dataclass(frozen=True) +class Subscriber: + subscriber_id: str + name: str + email: str + channels: tuple[str, ...] + language: str + active: bool = True + + +@dataclass(frozen=True) +class SubscriberDeliveryPlan: + notice_id: str + notice_type: str + audience: str + active_subscriber_count: int + suppressed_subscriber_count: int + delivery_channels: tuple[str, ...] + email_recipients: tuple[str, ...] + missing_required_channels: tuple[str, ...] + language_review_required: bool + staff_review_required: bool + delivery_notes: tuple[str, ...] + disclaimer: str = DISCLAIMER + + +def build_subscriber_delivery_plan( + *, + notice_id: str, + notice_type: str, + audience: str, + subscribers: tuple[Subscriber, ...], + required_channels: tuple[str, ...] = ("email",), +) -> SubscriberDeliveryPlan: + """Build a staff-reviewable subscriber delivery plan without sending notices.""" + + active_subscribers = tuple(subscriber for subscriber in subscribers if subscriber.active) + suppressed_count = len(subscribers) - len(active_subscribers) + normalized_required = tuple(_clean(channel) for channel in required_channels if _clean(channel)) + channel_set = { + channel + for subscriber in active_subscribers + for channel in subscriber.channels + if _clean(channel) + } + normalized_channels = tuple(sorted(_clean(channel) for channel in channel_set)) + email_recipients = tuple( + sorted( + { + subscriber.email.strip().lower() + for subscriber in active_subscribers + if subscriber.email.strip() + and "email" in {_clean(channel) for channel in subscriber.channels} + } + ) + ) + languages = {_clean(subscriber.language) for subscriber in active_subscribers if subscriber.language} + missing_required = tuple( + channel for channel in normalized_required if channel not in normalized_channels + ) + language_review_required = bool(languages - {"", "english", "en"}) + staff_review_required = bool(missing_required or language_review_required) + notes = ( + "Delivery plan only prepares recipients and channels; it does not send or publish notices.", + "Staff must verify opt-in status, suppression lists, bounced addresses, and legal delivery requirements.", + "Preserve delivery logs or third-party confirmations with the publication proof packet.", + ) + return SubscriberDeliveryPlan( + notice_id=notice_id.strip() or "unassigned-notice", + notice_type=notice_type.strip() or "general notice", + audience=audience.strip() or "general public", + active_subscriber_count=len(active_subscribers), + suppressed_subscriber_count=suppressed_count, + delivery_channels=normalized_channels, + email_recipients=email_recipients, + missing_required_channels=missing_required, + language_review_required=language_review_required, + staff_review_required=staff_review_required, + delivery_notes=notes, + ) + + +def _clean(value: str) -> str: + return " ".join(value.strip().lower().split()) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 0870318..17f2cde 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1,5 +1,5 @@ # CivicNotice Implementation Plan -CivicNotice v0.1.2 preserves the deterministic foundation and aligns it to the `civiccore v0.9.0` release wheel: runtime shell, sample notice registry, CivicCore-backed deadline tracking, publication readiness tracking helper, channel planning helper, notice and records export checklist, and public sample UI. +CivicNotice v0.2.0 preserves the deterministic foundation and aligns it to the `civiccore v1.2.0` release wheel: runtime shell, sample notice registry, CivicCore-backed deadline tracking, deterministic statutory rule checks, notice drafting templates, accessibility and language-readiness packets, publication readiness tracking helper, channel planning helper, subscriber delivery planning, archive/handoff packets, notice and records export checklist, and public sample UI. Later releases add statutory rule packs, CivicClerk/CivicProcure/CivicBoards handoffs, CivicRecords file links, proof queues, publication-system integrations, and production data governance. diff --git a/docs/RECONCILIATION.md b/docs/RECONCILIATION.md index f302f64..1a286e7 100644 --- a/docs/RECONCILIATION.md +++ b/docs/RECONCILIATION.md @@ -1,9 +1,9 @@ # CivicNotice Reconciliation -| Driver | CivicNotice v0.1.2 result | +| Driver | CivicNotice v0.2.0 result | |---|---| -| Unified spec CivicNotice catalog entry | notice registry, deadline tracking, publication readiness tracking, channel planning, and notice/records export checklists represented through deterministic helpers. | +| Unified spec CivicNotice catalog entry | notice registry, deadline tracking, deterministic statutory rule checks, notice drafting templates, publication readiness tracking, channel planning, and notice/records export checklists represented through deterministic helpers. | | Apache 2.0 code licensing | `LICENSE-CODE` and package metadata use Apache 2.0. | | Documentation licensing | `LICENSE-DOCS` uses CC BY 4.0. | -| CivicCore dependency | `pyproject.toml` pins the `civiccore v0.9.0` release wheel. | +| CivicCore dependency | `pyproject.toml` pins the `civiccore v1.2.0` release wheel. | | Shipped/planned honesty | Legal sufficiency decisions, legal advice, live LLM calls, official notice publication, publication-system write-back, and notice system-of-record integrations remain explicitly unshipped. | diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/REPORT.md b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/REPORT.md new file mode 100644 index 0000000..cb4bb99 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/REPORT.md @@ -0,0 +1,43 @@ +# GauntletGate Report: CivicNotice 0.2.0 + +Date: 2026-06-19 + +Reviewed release: CivicNotice 0.2.0 + +Final pushed PR head reviewed by CI: 0ed06639a8f2c746337b6568586f73ca21b4111a + +Product/runtime evidence source commit: 028a5954068d7423a1e1b6234723ddff5188908a + +Report metadata note: report-only commits may follow the product/runtime evidence commit. GitHub CI on the final pushed PR head is the source of truth that those metadata commits did not break the release gate. + +## Verdict + +Gate passed for the reviewed product/runtime evidence, and GitHub CI is green for pushed PR head 0ed06639a8f2c746337b6568586f73ca21b4111a. + +## Gate Scope + +- Principal engineering review +- UI/UX review +- Technical writing review +- Test engineering review +- QA/runtime evidence review + +## Issues Found and Resolved + +- Inert public UI controls were removed from `/civicnotice`; the public page is now a static sample with boundary copy and no misleading draft controls. +- Unknown statutory-rule notice types no longer silently fall back to `general notice`; common public-hearing aliases normalize to `planning hearing`, and unsupported types return 422 with supported choices. +- `/docs` and `/openapi.json` are now documented in first-use surfaces and linked from the docs landing page when served by FastAPI. +- Configured-but-unavailable persistence now returns actionable 503 responses instead of raw 500 errors. +- The release gate now requires `CIVICNOTICE_POSTGRES_TEST_URL` and verifies that the selected Python interpreter can see it before tests run. +- Historical QA notes are marked historical, and obsolete cloud-sync evidence paths were replaced with repository-relative references. + +## Verification Evidence + +- `artifacts/verify-release-postgres.log` shows the full release gate passing with PostgreSQL coverage, CivicCore wheel SHA verification, docs verification, placeholder import checks, Ruff, build artifacts, and 34 passing tests with no skips. +- `artifacts/api-smoke.json` shows root, health, public UI, OpenAPI, no-DB dependency behavior, notice-type aliasing, and unsupported notice type rejection. +- `artifacts/environment-attestation.txt` records the reviewed git head and runtime environment. +- `artifacts/public-ui.html`, `artifacts/openapi.json`, `artifacts/root.json`, `artifacts/health.json`, and `artifacts/dependency-absent-no-db.json` preserve runtime smoke outputs. + +## Remaining Watchlist + +None. No deferred findings are allowed for this stage. diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/api-smoke.json b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/api-smoke.json new file mode 100644 index 0000000..c4dbbf4 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/api-smoke.json @@ -0,0 +1,26 @@ +{ + "root_status": 200, + "health_status": 200, + "public_ui_status": 200, + "openapi_status": 200, + "dependency_absent_status": 503, + "public_ui_buttons": 0, + "public_ui_textareas": 0, + "public_ui_docs_links": 1, + "public_ui_openapi_links": 1, + "rule_alias_status": 200, + "rule_alias_notice_type": "planning hearing", + "unknown_notice_type_status": 422, + "unknown_notice_type_supported_choices": [ + "adoption notice", + "bid notice", + "general notice", + "hearing", + "planning hearing", + "public hearing", + "special meeting", + "vacancy notice" + ], + "final_pushed_pr_head": "0ed06639a8f2c746337b6568586f73ca21b4111a", + "product_runtime_evidence_head": "028a5954068d7423a1e1b6234723ddff5188908a" +} diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/dependency-absent-no-db.json b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/dependency-absent-no-db.json new file mode 100644 index 0000000..c0cc6a5 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/dependency-absent-no-db.json @@ -0,0 +1,9 @@ +{ + "status_code": 503, + "body": { + "detail": { + "message": "CivicNotice workpaper persistence is not configured.", + "fix": "Set CIVICNOTICE_WORKPAPER_DB_URL to retrieve persisted notice registry records." + } + } +} diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/environment-attestation.txt b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/environment-attestation.txt new file mode 100644 index 0000000..5872162 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/environment-attestation.txt @@ -0,0 +1,7 @@ +final_pushed_pr_head=0ed06639a8f2c746337b6568586f73ca21b4111a +product_runtime_evidence_head=028a5954068d7423a1e1b6234723ddff5188908a +python=3.13.13 +platform=Windows-11-10.0.26200-SP0 +dependency_absent_no_db=registry GET returns actionable 503 when persistence is not configured +postgres_release_gate=verify-release-postgres.log shows CIVICNOTICE_POSTGRES_TEST_URL visible and all tests passed with no skips +public_ui_guidance=/civicnotice links to /docs and /openapi.json diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/health.json b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/health.json new file mode 100644 index 0000000..59c6ff8 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/health.json @@ -0,0 +1,6 @@ +{ + "status": "ok", + "service": "civicnotice", + "version": "0.2.0", + "civiccore_version": "1.2.0" +} diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/openapi.json b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/openapi.json new file mode 100644 index 0000000..b255774 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/openapi.json @@ -0,0 +1,1344 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "CivicNotice", + "description": "Public hearing, legal notice, bid notice, vacancy notice, and statutory publication deadline support for CivicSuite.", + "version": "0.2.0" + }, + "paths": { + "/": { + "get": { + "summary": "Root", + "description": "Return current product state without overstating unshipped behavior.", + "operationId": "root__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Root Get" + } + } + } + } + } + } + }, + "/health": { + "get": { + "summary": "Health", + "description": "Return dependency/version health for deployment smoke checks.", + "operationId": "health_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Health Health Get" + } + } + } + } + } + } + }, + "/civicnotice": { + "get": { + "summary": "Public Civicnotice Page", + "description": "Return the public sample notice compliance support UI.", + "operationId": "public_civicnotice_page_civicnotice_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/registry": { + "post": { + "summary": "Notice Registry", + "operationId": "notice_registry_api_v1_civicnotice_registry_post", + "parameters": [ + { + "name": "x-civicnotice-write-token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Civicnotice-Write-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NoticeRegistryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Notice Registry Api V1 Civicnotice Registry Post" + } + } + } + }, + "403": { + "description": "Durable CivicNotice write token is missing or invalid." + }, + "503": { + "description": "CivicNotice workpaper persistence or durable write guard is not configured." + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/registry/{record_id}": { + "get": { + "summary": "Get Notice Registry", + "operationId": "get_notice_registry_api_v1_civicnotice_registry__record_id__get", + "parameters": [ + { + "name": "record_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Record Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Notice Registry Api V1 Civicnotice Registry Record Id Get" + } + } + } + }, + "404": { + "description": "Persisted CivicNotice workpaper record was not found." + }, + "503": { + "description": "CivicNotice workpaper persistence is not configured." + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/deadlines": { + "post": { + "summary": "Deadline Plan", + "operationId": "deadline_plan_api_v1_civicnotice_deadlines_post", + "parameters": [ + { + "name": "x-civicnotice-write-token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Civicnotice-Write-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeadlineRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Deadline Plan Api V1 Civicnotice Deadlines Post" + } + } + } + }, + "403": { + "description": "Durable CivicNotice write token is missing or invalid." + }, + "503": { + "description": "CivicNotice workpaper persistence or durable write guard is not configured." + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/deadlines/{plan_id}": { + "get": { + "summary": "Get Deadline Plan", + "operationId": "get_deadline_plan_api_v1_civicnotice_deadlines__plan_id__get", + "parameters": [ + { + "name": "plan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Plan Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Deadline Plan Api V1 Civicnotice Deadlines Plan Id Get" + } + } + } + }, + "404": { + "description": "Persisted CivicNotice workpaper record was not found." + }, + "503": { + "description": "CivicNotice workpaper persistence is not configured." + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/publication-proof": { + "post": { + "summary": "Publication Proof", + "operationId": "publication_proof_api_v1_civicnotice_publication_proof_post", + "parameters": [ + { + "name": "x-civicnotice-write-token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Civicnotice-Write-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicationProofRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Publication Proof Api V1 Civicnotice Publication Proof Post" + } + } + } + }, + "403": { + "description": "Durable CivicNotice write token is missing or invalid." + }, + "503": { + "description": "CivicNotice workpaper persistence or durable write guard is not configured." + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/publication-proof/{proof_id}": { + "get": { + "summary": "Get Publication Proof", + "operationId": "get_publication_proof_api_v1_civicnotice_publication_proof__proof_id__get", + "parameters": [ + { + "name": "proof_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Proof Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Publication Proof Api V1 Civicnotice Publication Proof Proof Id Get" + } + } + } + }, + "404": { + "description": "Persisted CivicNotice workpaper record was not found." + }, + "503": { + "description": "CivicNotice workpaper persistence is not configured." + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/publication-check": { + "post": { + "summary": "Publication Checklist", + "operationId": "publication_checklist_api_v1_civicnotice_publication_check_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Publication Checklist Api V1 Civicnotice Publication Check Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/rule-check": { + "post": { + "summary": "Rule Check", + "operationId": "rule_check_api_v1_civicnotice_rule_check_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleCheckRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Rule Check Api V1 Civicnotice Rule Check Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/templates": { + "post": { + "summary": "Notice Template", + "operationId": "notice_template_api_v1_civicnotice_templates_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NoticeTemplateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Notice Template Api V1 Civicnotice Templates Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/channels": { + "post": { + "summary": "Channel Plan", + "operationId": "channel_plan_api_v1_civicnotice_channels_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Channel Plan Api V1 Civicnotice Channels Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/subscribers/plan": { + "post": { + "summary": "Subscriber Delivery Plan", + "operationId": "subscriber_delivery_plan_api_v1_civicnotice_subscribers_plan_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriberDeliveryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Subscriber Delivery Plan Api V1 Civicnotice Subscribers Plan Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/accessibility-review": { + "post": { + "summary": "Accessibility Review", + "operationId": "accessibility_review_api_v1_civicnotice_accessibility_review_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessibilityReviewRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Accessibility Review Api V1 Civicnotice Accessibility Review Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/archive-packet": { + "post": { + "summary": "Archive Packet", + "operationId": "archive_packet_api_v1_civicnotice_archive_packet_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchivePacketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Archive Packet Api V1 Civicnotice Archive Packet Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/civicnotice/export": { + "post": { + "summary": "Records Export", + "operationId": "records_export_api_v1_civicnotice_export_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordsExportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Records Export Api V1 Civicnotice Export Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccessibilityReviewRequest": { + "properties": { + "notice_id": { + "type": "string", + "maxLength": 160, + "title": "Notice Id" + }, + "title": { + "type": "string", + "maxLength": 240, + "title": "Title" + }, + "notice_text": { + "type": "string", + "title": "Notice Text" + }, + "target_languages": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Target Languages" + }, + "attachments": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Attachments" + }, + "has_contact": { + "type": "boolean", + "title": "Has Contact", + "default": false + }, + "has_event_date": { + "type": "boolean", + "title": "Has Event Date", + "default": false + }, + "has_plain_language_summary": { + "type": "boolean", + "title": "Has Plain Language Summary", + "default": false + } + }, + "type": "object", + "required": [ + "notice_id", + "title", + "notice_text" + ], + "title": "AccessibilityReviewRequest" + }, + "ArchivePacketRequest": { + "properties": { + "notice_id": { + "type": "string", + "maxLength": 160, + "title": "Notice Id" + }, + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type" + }, + "source_module": { + "type": "string", + "maxLength": 160, + "title": "Source Module", + "default": "manual" + }, + "source_record_id": { + "type": "string", + "maxLength": 160, + "title": "Source Record Id", + "default": "" + }, + "registry_record_id": { + "type": "string", + "maxLength": 160, + "title": "Registry Record Id", + "default": "" + }, + "deadline_plan_id": { + "type": "string", + "maxLength": 160, + "title": "Deadline Plan Id", + "default": "" + }, + "publication_proof_id": { + "type": "string", + "maxLength": 160, + "title": "Publication Proof Id", + "default": "" + }, + "rule_check_complete": { + "type": "boolean", + "title": "Rule Check Complete", + "default": false + }, + "template_complete": { + "type": "boolean", + "title": "Template Complete", + "default": false + }, + "accessibility_review_complete": { + "type": "boolean", + "title": "Accessibility Review Complete", + "default": false + }, + "subscriber_delivery_complete": { + "type": "boolean", + "title": "Subscriber Delivery Complete", + "default": false + }, + "records_export_complete": { + "type": "boolean", + "title": "Records Export Complete", + "default": false + } + }, + "type": "object", + "required": [ + "notice_id", + "notice_type" + ], + "title": "ArchivePacketRequest" + }, + "ChannelRequest": { + "properties": { + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type" + }, + "audience": { + "type": "string", + "maxLength": 240, + "title": "Audience" + } + }, + "type": "object", + "required": [ + "notice_type", + "audience" + ], + "title": "ChannelRequest" + }, + "DeadlineRequest": { + "properties": { + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type" + }, + "event_date": { + "type": "string", + "format": "date", + "title": "Event Date" + }, + "lead_days": { + "type": "integer", + "maximum": 366.0, + "minimum": 0.0, + "title": "Lead Days", + "default": 10 + } + }, + "type": "object", + "required": [ + "notice_type", + "event_date" + ], + "title": "DeadlineRequest" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "NoticeRegistryRequest": { + "properties": { + "notice_id": { + "type": "string", + "maxLength": 160, + "title": "Notice Id" + }, + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type" + }, + "owner": { + "type": "string", + "maxLength": 160, + "title": "Owner" + } + }, + "type": "object", + "required": [ + "notice_id", + "notice_type", + "owner" + ], + "title": "NoticeRegistryRequest" + }, + "NoticeTemplateRequest": { + "properties": { + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type", + "description": "Supported rule/template notice types include planning hearing, public hearing, bid notice, vacancy notice, adoption notice, special meeting, and general notice.", + "examples": [ + "planning hearing", + "public hearing", + "bid notice" + ] + }, + "matter_title": { + "type": "string", + "maxLength": 240, + "title": "Matter Title", + "examples": [ + "Planning hearing" + ] + }, + "event_date": { + "type": "string", + "format": "date", + "title": "Event Date", + "examples": [ + "2026-08-01" + ] + }, + "location": { + "type": "string", + "maxLength": 1000, + "title": "Location", + "default": "", + "examples": [ + "Council Chambers" + ] + }, + "contact": { + "type": "string", + "maxLength": 160, + "title": "Contact", + "default": "", + "examples": [ + "clerk@example.gov" + ] + }, + "source_module": { + "type": "string", + "maxLength": 160, + "title": "Source Module", + "default": "manual", + "examples": [ + "civicclerk" + ] + }, + "statutory_basis": { + "type": "string", + "maxLength": 1000, + "title": "Statutory Basis", + "default": "", + "examples": [ + "staff-entered basis" + ] + } + }, + "type": "object", + "required": [ + "notice_type", + "matter_title", + "event_date" + ], + "title": "NoticeTemplateRequest" + }, + "PublicationProofRequest": { + "properties": { + "notice_id": { + "type": "string", + "maxLength": 160, + "title": "Notice Id" + }, + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type" + }, + "source_module": { + "type": "string", + "maxLength": 160, + "title": "Source Module", + "default": "manual" + }, + "source_record_id": { + "type": "string", + "maxLength": 160, + "title": "Source Record Id" + }, + "channel": { + "type": "string", + "maxLength": 160, + "title": "Channel" + }, + "published_at": { + "type": "string", + "maxLength": 160, + "title": "Published At" + }, + "location": { + "type": "string", + "maxLength": 1000, + "title": "Location" + }, + "confirmation_reference": { + "type": "string", + "maxLength": 240, + "title": "Confirmation Reference" + }, + "statutory_basis": { + "type": "string", + "maxLength": 1000, + "title": "Statutory Basis" + }, + "reviewer": { + "type": "string", + "maxLength": 160, + "title": "Reviewer" + } + }, + "type": "object", + "required": [ + "notice_id", + "notice_type", + "source_record_id", + "channel", + "published_at", + "location", + "confirmation_reference", + "statutory_basis", + "reviewer" + ], + "title": "PublicationProofRequest" + }, + "PublicationRequest": { + "properties": { + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type" + }, + "channel": { + "type": "string", + "maxLength": 160, + "title": "Channel" + } + }, + "type": "object", + "required": [ + "notice_type", + "channel" + ], + "title": "PublicationRequest" + }, + "RecordsExportRequest": { + "properties": { + "notice_id": { + "type": "string", + "maxLength": 160, + "title": "Notice Id" + }, + "title": { + "type": "string", + "maxLength": 240, + "title": "Title" + }, + "format": { + "type": "string", + "maxLength": 40, + "title": "Format", + "default": "markdown" + } + }, + "type": "object", + "required": [ + "notice_id", + "title" + ], + "title": "RecordsExportRequest" + }, + "RuleCheckRequest": { + "properties": { + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type", + "description": "Supported rule/template notice types include planning hearing, public hearing, bid notice, vacancy notice, adoption notice, special meeting, and general notice.", + "examples": [ + "planning hearing", + "public hearing", + "bid notice" + ] + }, + "event_date": { + "type": "string", + "format": "date", + "title": "Event Date", + "description": "Hearing, bid, vacancy, adoption, special-meeting, or other event date.", + "examples": [ + "2026-08-01" + ] + }, + "publication_dates": { + "items": { + "type": "string", + "format": "date" + }, + "type": "array", + "title": "Publication Dates", + "description": "Dates when the notice is scheduled or was published.", + "examples": [ + [ + "2026-07-17" + ] + ] + }, + "channels": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Channels", + "description": "Publication channels staff plans to use or has already used.", + "examples": [ + [ + "city website", + "posting board" + ] + ] + }, + "content_fields": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Content Fields", + "description": "Notice fields present in the draft packet.", + "examples": [ + [ + "title", + "hearing date", + "location", + "case number", + "statutory basis" + ] + ] + }, + "statutory_basis": { + "type": "string", + "maxLength": 1000, + "title": "Statutory Basis", + "description": "Staff-entered statutory citation or authority for review.", + "default": "", + "examples": [ + "staff-entered basis" + ] + } + }, + "type": "object", + "required": [ + "notice_type", + "event_date" + ], + "title": "RuleCheckRequest" + }, + "SubscriberDeliveryRequest": { + "properties": { + "notice_id": { + "type": "string", + "maxLength": 160, + "title": "Notice Id" + }, + "notice_type": { + "type": "string", + "maxLength": 160, + "title": "Notice Type" + }, + "audience": { + "type": "string", + "maxLength": 240, + "title": "Audience" + }, + "subscribers": { + "items": { + "$ref": "#/components/schemas/SubscriberRequest" + }, + "type": "array", + "title": "Subscribers" + }, + "required_channels": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Required Channels" + } + }, + "type": "object", + "required": [ + "notice_id", + "notice_type", + "audience" + ], + "title": "SubscriberDeliveryRequest" + }, + "SubscriberRequest": { + "properties": { + "subscriber_id": { + "type": "string", + "maxLength": 160, + "title": "Subscriber Id" + }, + "name": { + "type": "string", + "maxLength": 160, + "title": "Name" + }, + "email": { + "type": "string", + "maxLength": 254, + "title": "Email", + "default": "" + }, + "channels": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Channels" + }, + "language": { + "type": "string", + "maxLength": 80, + "title": "Language", + "default": "English" + }, + "active": { + "type": "boolean", + "title": "Active", + "default": true + } + }, + "type": "object", + "required": [ + "subscriber_id", + "name" + ], + "title": "SubscriberRequest" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/public-ui.html b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/public-ui.html new file mode 100644 index 0000000..7752397 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/public-ui.html @@ -0,0 +1,71 @@ + + + + + +CivicNotice Public Notice Support + + + + +
+

CivicSuite / CivicNotice public sample

+

Public notices with fewer missed deadlines.

+

CivicNotice demonstrates notice administration support: registry stubs, deadline plans, rule checks, templates, accessibility review, subscriber delivery planning, archive packets, and proof-preserving records exports without publishing official notice.

+ +

v0.2.0 notice compliance foundation

+
+
+
+
+

Sample hearing notice

+

Planning hearing notice

+

Supported rule-check and template notice types include planning hearing, public hearing, bid notice, vacancy notice, adoption notice, special meeting, and general notice.

+
Public hearing on rezoning request. Confirm statutory authority, publication lead time, accessibility needs, and proof of publication.
+
+

Staff review packet

+
  • Confirm statutory authority, publication channel, lead time, and reviewer.
  • Route draft copy for clerk/legal review before publication.
  • Check accessibility, language access, subscriber delivery, proof, and archive handoff before closing the file.
+
+
+

Deadlines

Lead-time reminders

CivicNotice calculates review milestones; staff verify the legal deadline.

+

Channels

Publication path

Channel helpers surface website, posting board, newspaper, agenda page, and accessibility review needs.

+

Proof

Records-ready

Exports preserve draft, final notice, approvals, proof, source matter, and reviewer metadata.

+

Boundary

No official publication

CivicNotice does not determine legal sufficiency, publish official notices, provide legal advice, call live LLMs, write back to publication systems, or replace the notice system of record.

+
+
+

CivicNotice is part of the Apache 2.0 CivicSuite open-source municipal AI project.

+ + diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/root.json b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/root.json new file mode 100644 index 0000000..f9ca701 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/root.json @@ -0,0 +1,7 @@ +{ + "name": "CivicNotice", + "version": "0.2.0", + "status": "notice compliance foundation", + "message": "CivicNotice package, API foundation, sample notice registry, CivicCore-backed deadline plans, statutory rule checks, notice drafting templates, accessibility and language-readiness packets, archive/handoff packets, publication-readiness checklist, channel planning, records export checklist, optional database-backed registry/deadline/publication-proof workpapers, and public UI foundation are online; official legal sufficiency decisions, official publication, legal advice, live LLM calls, publication-system write-back, and notice system-of-record integrations are not implemented yet.", + "next_step": "Post-v0.2.0 roadmap: jurisdiction-specific statutory rule packs, live CivicClerk/CivicProcure/CivicNotice handoffs, and publication proof queues" +} diff --git a/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/verify-release-postgres.log b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/verify-release-postgres.log new file mode 100644 index 0000000..a8e37f9 --- /dev/null +++ b/docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/verify-release-postgres.log @@ -0,0 +1,54 @@ +==> Version surface check +PASS: version surfaces synchronized +==> CivicCore wheel provenance +PASS: CivicCore 1.2.0 wheel SHA-256 verified +==> Test suite +PASS: PostgreSQL test URL is visible to the selected Python interpreter +.................................... [100%] +=============================== tests coverage ================================ +______________ coverage: platform win32, python 3.13.13-final-0 _______________ + +Name Stmts Miss Branch BrPart Cover +----------------------------------------------------------------------- +civicnotice\__init__.py 1 0 0 0 100% +civicnotice\accessibility_review.py 43 4 14 6 82% +civicnotice\archive_packet.py 32 0 6 0 100% +civicnotice\channel_plan.py 14 0 0 0 100% +civicnotice\deadline_tracker.py 15 0 0 0 100% +civicnotice\main.py 235 4 28 4 97% +civicnotice\notice_registry.py 13 0 0 0 100% +civicnotice\notice_templates.py 24 0 0 0 100% +civicnotice\persistence.py 96 1 8 1 98% +civicnotice\public_ui.py 3 0 0 0 100% +civicnotice\publication_check.py 12 0 0 0 100% +civicnotice\publication_proof.py 26 0 0 0 100% +civicnotice\records_export.py 13 0 0 0 100% +civicnotice\statutory_rules.py 62 0 6 0 100% +civicnotice\subscriber_delivery.py 40 0 0 0 100% +----------------------------------------------------------------------- +TOTAL 629 9 62 11 97% +Required test coverage of 90% reached. Total coverage: 97.11% +36 passed in 3.50s +==> Documentation gate +==> Required-artifact check +==> Current-facing shipped/planned truth check +VERIFY-DOCS: PASSED +==> Placeholder import gate +PLACEHOLDER-IMPORT-CHECK: PASSED (15 source files scanned) +==> Ruff +All checks passed! +==> Build artifacts +* Creating isolated environment: venv+pip... +* Installing packages in isolated environment: + - hatchling>=1.27.0 +* Getting build dependencies for sdist... +* Building sdist... +* Building wheel from sdist +* Creating isolated environment: venv+pip... +* Installing packages in isolated environment: + - hatchling>=1.27.0 +* Getting build dependencies for wheel... +* Building wheel... +Successfully built civicnotice-0.2.0.tar.gz and civicnotice-0.2.0-py3-none-any.whl +PASS: build artifacts and SHA256SUMS.txt created +VERIFY-RELEASE: PASSED diff --git a/docs/browser-qa-civicnotice-v0.1.1-alignment-summary.md b/docs/browser-qa-civicnotice-v0.1.1-alignment-summary.md index 67dbe79..c3b2dbd 100644 --- a/docs/browser-qa-civicnotice-v0.1.1-alignment-summary.md +++ b/docs/browser-qa-civicnotice-v0.1.1-alignment-summary.md @@ -1,4 +1,6 @@ -# Browser QA - CivicNotice v0.1.1 Alignment +# Historical Browser QA - CivicNotice v0.1.1 Alignment + +This archived QA note records the v0.1.1 browser pass from 2026-04-28. It is not current release evidence for CivicNotice v0.2.0; current release evidence is under `docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/`. Date: 2026-04-28 @@ -16,8 +18,8 @@ Evidence: Checks: -- Version labels show `v0.1.1`. -- Dependency copy names `civiccore==0.3.0`. +- Historical version labels showed `v0.1.1`. +- Historical dependency copy named `civiccore==0.3.0`. - Public UI boundary copy still states CivicNotice does not determine legal sufficiency, publish official notices, provide legal advice, call live LLMs, write back to publication systems, or replace the notice system of record. - Desktop and mobile captures report no horizontal overflow (`scrollWidth == innerWidth`) in both docs and public UI. diff --git a/docs/browser-qa-production-depth-notice-workpaper-persistence-summary.md b/docs/browser-qa-production-depth-notice-workpaper-persistence-summary.md index af30e66..dc9f57a 100644 --- a/docs/browser-qa-production-depth-notice-workpaper-persistence-summary.md +++ b/docs/browser-qa-production-depth-notice-workpaper-persistence-summary.md @@ -1,10 +1,10 @@ # Browser QA: Production Depth Notice Workpaper Persistence -Target: file:///C:/Users/scott/OneDrive/Desktop/Claude/civicnotice/docs/index.html +Target: docs/index.html from the local repository checkout. ## desktop -Screenshot: C:\Users\scott\OneDrive\Desktop\Claude\civicnotice\docs\browser-qa-production-depth-notice-workpaper-persistence-desktop.png +Screenshot: docs/browser-qa-production-depth-notice-workpaper-persistence-desktop.png - versionVisible: PASS - persistenceVisible: PASS @@ -16,7 +16,7 @@ Screenshot: C:\Users\scott\OneDrive\Desktop\Claude\civicnotice\docs\browser-qa-p ## mobile -Screenshot: C:\Users\scott\OneDrive\Desktop\Claude\civicnotice\docs\browser-qa-production-depth-notice-workpaper-persistence-mobile.png +Screenshot: docs/browser-qa-production-depth-notice-workpaper-persistence-mobile.png - versionVisible: PASS - persistenceVisible: PASS diff --git a/docs/browser-qa-summary.md b/docs/browser-qa-summary.md index 879d809..3b4c788 100644 --- a/docs/browser-qa-summary.md +++ b/docs/browser-qa-summary.md @@ -1,6 +1,8 @@ -# Browser QA Summary +# Historical Browser QA Summary -Status: PASS +This archived browser QA note records the 2026-04-27 pre-0.2.0 browser pass. It is not current release evidence for CivicNotice v0.2.0; current release evidence is under `docs/audits/gauntletgate-civicnotice-0.2.0-2026-06-19/artifacts/`. + +Historical status: PASS Checked at: 2026-04-27T22:25:26.910Z diff --git a/docs/index.html b/docs/index.html index 656fe74..35486c2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -12,15 +12,16 @@

CivicSuite / CivicNotice

Public notices with fewer missed deadlines.

-

CivicNotice v0.1.2 ships a foundation for notice registry, CivicCore-backed statutory deadline plans, optional registry/deadline workpaper persistence, publication-readiness checks, channel planning, notice-record exports, an accessible sample UI, and the civiccore v0.9.0 release-wheel alignment.

-

Shipping v0.1.2

+

CivicNotice v0.2.0 ships a foundation for notice registry, CivicCore-backed statutory deadline plans, deterministic statutory rule checks, notice drafting templates, accessibility and language-readiness packets, optional registry/deadline/publication-proof workpaper persistence, publication-readiness checks, channel planning, subscriber delivery planning, archive/handoff packets, notice-record exports, an accessible sample UI, and the civiccore v1.2.0 release-wheel alignment.

+

Shipping v0.2.0

-

What ships

  • Sample notice registry.
  • Statutory deadline plans.
  • Optional persistence with CIVICNOTICE_WORKPAPER_DB_URL.
  • Publication-readiness checklists.
  • Channel planning flags.
  • Notice and records export checklist.
  • Public sample UI at /civicnotice.
+

What ships

  • Sample notice registry.
  • Statutory deadline plans.
  • Statutory rule checks.
  • Notice drafting templates.
  • Accessibility and language-readiness packets.
  • Optional persistence with CIVICNOTICE_WORKPAPER_DB_URL, including publication-proof workpapers.
  • Publication-readiness checklists.
  • Channel planning flags.
  • Subscriber delivery planning.
  • Archive/handoff packets.
  • Notice and records export checklist.
  • Public sample UI at /civicnotice.

What does not ship

CivicNotice does not decide legal sufficiency, publish official notices, provide legal advice, call live LLMs, write back to publication systems, or replace a notice system of record.

-

Dependency

Pinned to the civiccore v0.9.0 release wheel. CivicCore now supplies the shared deadline-plan helper; it still never imports from CivicNotice.

+ +

Dependency

Pinned to the civiccore v1.2.0 release wheel. CivicCore now supplies the shared deadline-plan helper; it still never imports from CivicNotice.

Apache 2.0 code. CC BY 4.0 docs. Run locally by the city.

diff --git a/pyproject.toml b/pyproject.toml index 1e25671..61b1bc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "civicnotice" -version = "0.1.2" -description = "CivicNotice runtime foundation for notice registry, deadline tracking, publication readiness tracking, channel planning, and public-records-aware export support." +version = "0.2.0" +description = "CivicNotice runtime foundation for notice registry, deadlines, rule checks, templates, proof workpapers, delivery planning, accessibility review, and archive handoffs." readme = "README.md" requires-python = ">=3.11" license = { text = "Apache-2.0" } @@ -13,7 +13,7 @@ authors = [ { name = "CivicSuite contributors" } ] dependencies = [ - "civiccore @ https://github.com/CivicSuite/civiccore/releases/download/v0.9.0/civiccore-0.9.0-py3-none-any.whl", + "civiccore @ https://github.com/CivicSuite/civiccore/releases/download/v1.2.0/civiccore-1.2.0-py3-none-any.whl#sha256=a94ce958e36fb03c8d961e4db4672ce5bcfa25765c57d75886e999cf15703ec7", "fastapi>=0.115.0,<1.0.0", "sqlalchemy>=2.0.0,<3.0.0", "uvicorn[standard]>=0.30.0,<1.0.0", @@ -28,6 +28,7 @@ dev = [ "httpx>=0.27.0,<1.0.0", "psycopg2-binary>=2.9.0,<3.0.0", "pytest>=8.0.0,<9.0.0", + "pytest-cov>=5.0.0,<7.0.0", "ruff>=0.11.0", ] diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh index 20522d8..4f0531f 100644 --- a/scripts/verify-docs.sh +++ b/scripts/verify-docs.sh @@ -28,10 +28,15 @@ required=( "pyproject.toml" "civicnotice/__init__.py" "civicnotice/main.py" + "civicnotice/accessibility_review.py" + "civicnotice/archive_packet.py" "civicnotice/notice_registry.py" "civicnotice/deadline_tracker.py" + "civicnotice/statutory_rules.py" + "civicnotice/notice_templates.py" "civicnotice/publication_check.py" "civicnotice/channel_plan.py" + "civicnotice/subscriber_delivery.py" "civicnotice/records_export.py" "civicnotice/public_ui.py" ) @@ -56,13 +61,28 @@ bad_markers=( "production staff-review queues are available" ) -for file in "${current_files[@]}"; do - for marker in "${bad_markers[@]}"; do - if grep -Fqi "$marker" "$file"; then - echo "FAIL: stale/planned-as-shipped marker '$marker' found in $file" >&2 - exit 1 - fi - done -done +DOCS_PYTHON="${CIVICNOTICE_RELEASE_PYTHON:-python}" +"${DOCS_PYTHON}" - <<'PY' +from pathlib import Path + +current_files = ("README.md", "README.txt", "USER-MANUAL.md", "docs/index.html") +bad_markers = ( + "official legal sufficiency decisions are available", + "legal advice is available", + "official publication is available", + "live LLM calls are available", + "publication-system write-back is available", + "system-of-record integrations are available", + "production staff-review queues are available", +) + +for filename in current_files: + text = Path(filename).read_text(encoding="utf-8").casefold() + for marker in bad_markers: + if marker.casefold() in text: + raise SystemExit( + f"FAIL: stale/planned-as-shipped marker '{marker}' found in {filename}" + ) +PY echo "VERIFY-DOCS: PASSED" diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index bdd1329..623bc5f 100644 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -VERSION="0.1.2" +VERSION="0.2.0" +CIVICCORE_WHEEL_URL="https://github.com/CivicSuite/civiccore/releases/download/v1.2.0/civiccore-1.2.0-py3-none-any.whl" +CIVICCORE_WHEEL_SHA256="a94ce958e36fb03c8d961e4db4672ce5bcfa25765c57d75886e999cf15703ec7" find_python() { local candidates=() @@ -40,7 +42,7 @@ ${PYTHON_BIN} - <<'PY' from pathlib import Path import tomllib -version = "0.1.2" +version = "0.2.0" root = Path(".") pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) assert pyproject["project"]["version"] == version, pyproject["project"]["version"] @@ -56,13 +58,42 @@ for path in [ "SECURITY.md", ]: text = (root / path).read_text(encoding="utf-8") - assert "0.1.2" in text, f"missing release version in {path}" + assert "0.2.0" in text, f"missing release version in {path}" assert "0.1.0.dev0" not in text, f"stale dev version in {path}" print("PASS: version surfaces synchronized") PY +echo "==> CivicCore wheel provenance" +${PYTHON_BIN} - < Test suite" -${PYTHON_BIN} -m pytest -q +if [[ -z "${CIVICNOTICE_POSTGRES_TEST_URL:-}" ]]; then + echo "FAIL: CIVICNOTICE_POSTGRES_TEST_URL is required for the release gate so PostgreSQL persistence coverage cannot be skipped." >&2 + exit 1 +fi +${PYTHON_BIN} - <<'PY' +import os +assert os.environ.get("CIVICNOTICE_POSTGRES_TEST_URL"), ( + "CIVICNOTICE_POSTGRES_TEST_URL is not visible to the selected Python interpreter." +) +print("PASS: PostgreSQL test URL is visible to the selected Python interpreter") +PY +${PYTHON_BIN} -m pytest -q --cov=civicnotice --cov-branch --cov-fail-under=90 echo "==> Documentation gate" bash scripts/verify-docs.sh @@ -81,8 +112,8 @@ from pathlib import Path import hashlib dist = Path("dist") -wheel = dist / "civicnotice-0.1.2-py3-none-any.whl" -sdist = dist / "civicnotice-0.1.2.tar.gz" +wheel = dist / "civicnotice-0.2.0-py3-none-any.whl" +sdist = dist / "civicnotice-0.2.0.tar.gz" assert wheel.exists(), f"missing {wheel}" assert sdist.exists(), f"missing {sdist}" lines = [] diff --git a/tests/test_notice_foundation.py b/tests/test_notice_foundation.py index 4618589..277888d 100644 --- a/tests/test_notice_foundation.py +++ b/tests/test_notice_foundation.py @@ -2,12 +2,17 @@ from fastapi.testclient import TestClient +from civicnotice.accessibility_review import build_accessibility_review +from civicnotice.archive_packet import build_notice_archive_packet from civicnotice.channel_plan import plan_notice_channels from civicnotice.deadline_tracker import build_deadline_plan from civicnotice.main import app +from civicnotice.notice_templates import build_notice_template from civicnotice.notice_registry import register_notice_stub from civicnotice.publication_check import build_publication_checklist from civicnotice.records_export import build_notice_records_export +from civicnotice.statutory_rules import check_statutory_notice_requirements +from civicnotice.subscriber_delivery import Subscriber, build_subscriber_delivery_plan client = TestClient(app) @@ -48,6 +53,85 @@ def test_publication_checklist_requires_proof() -> None: assert "publication proof" in result.checklist[3] +def test_statutory_rule_check_flags_deadline_and_missing_requirements() -> None: + result = check_statutory_notice_requirements( + notice_type="planning hearing", + event_date=date(2026, 6, 15), + publication_dates=(date(2026, 6, 5),), + channels=("city website",), + content_fields=("title", "hearing date", "location"), + ) + assert result.required_deadline_date == date(2026, 5, 31) + assert result.deadline_status == "deadline_risk_staff_review_required" + assert result.publication_count_status == "required_publication_count_met" + assert result.missing_channels == ("posting board",) + assert "case number" in result.missing_content + assert result.staff_review_required is True + + +def test_notice_template_preserves_staff_review_placeholders() -> None: + result = build_notice_template( + notice_type="bid notice", + matter_title="Water main replacement bid", + event_date=date(2026, 7, 1), + location="", + contact="clerk@example.gov", + source_module="civicprocure", + ) + assert result.notice_type == "bid notice" + assert result.source_module == "civicprocure" + assert "submission location" in result.required_fields + assert "[staff must enter location]" in result.placeholders_remaining + assert "Staff must verify required publication channels" in result.template_lines[-1] + + +def test_notice_type_aliases_resolve_without_generic_downgrade() -> None: + rule_check = check_statutory_notice_requirements( + notice_type="public_hearing", + event_date=date(2026, 8, 1), + content_fields=("title", "hearing date", "location", "case number", "statutory basis"), + channels=("city website", "posting board"), + statutory_basis="staff-entered basis", + ) + template = build_notice_template( + notice_type="public-hearing", + matter_title="Planning hearing", + event_date=date(2026, 8, 1), + location="Council Chambers", + contact="clerk@example.gov", + ) + assert rule_check.notice_type == "planning hearing" + assert "case number" not in rule_check.missing_content + assert rule_check.staff_review_required is True + assert template.notice_type == "planning hearing" + assert "case number" in template.required_fields + + +def test_unknown_notice_type_is_rejected_with_supported_choices() -> None: + response = client.post( + "/api/v1/civicnotice/rule-check", + json={ + "notice_type": "river festival notice", + "event_date": "2026-08-01", + "content_fields": ["title"], + }, + ) + template = client.post( + "/api/v1/civicnotice/templates", + json={ + "notice_type": "river festival notice", + "matter_title": "River festival street closure", + "event_date": "2026-08-01", + "location": "Main Street", + "contact": "clerk@example.gov", + }, + ) + assert response.status_code == 422 + assert template.status_code == 422 + assert "Supported notice types" in response.json()["detail"]["message"] + assert "planning hearing" in response.json()["detail"]["supported_notice_types"] + + def test_channel_plan_flags_accessibility_review() -> None: result = plan_notice_channels( notice_type="public hearing", @@ -58,6 +142,96 @@ def test_channel_plan_flags_accessibility_review() -> None: assert any("ADA format" in note for note in result.accessibility_notes) +def test_subscriber_delivery_plan_dedupes_and_flags_review() -> None: + result = build_subscriber_delivery_plan( + notice_id="hear-001", + notice_type="planning hearing", + audience="planning subscribers", + subscribers=( + Subscriber( + subscriber_id="1", + name="Alex Resident", + email="alex@example.gov", + channels=("Email",), + language="English", + ), + Subscriber( + subscriber_id="2", + name="Alex Duplicate", + email="ALEX@example.gov", + channels=("email",), + language="Spanish", + ), + Subscriber( + subscriber_id="3", + name="Inactive Resident", + email="inactive@example.gov", + channels=("email",), + language="English", + active=False, + ), + ), + required_channels=("email", "postal mail"), + ) + assert result.active_subscriber_count == 2 + assert result.suppressed_subscriber_count == 1 + assert result.email_recipients == ("alex@example.gov",) + assert result.missing_required_channels == ("postal mail",) + assert result.language_review_required is True + assert result.staff_review_required is True + + +def test_accessibility_review_flags_language_and_plain_language_needs() -> None: + result = build_accessibility_review( + notice_id="hear-001", + title="Planning hearing", + notice_text="Pursuant to the aforementioned provisions, the city hereby notices a hearing.", + target_languages=("English", "Spanish"), + attachments=("notice.pdf",), + has_contact=False, + has_event_date=True, + has_plain_language_summary=False, + ) + assert "public contact for accommodations or questions" in result.missing_accessibility_items + assert "plain-language summary for public readers" in result.missing_accessibility_items + assert "legal or technical jargon review" in result.plain_language_flags + assert result.translation_tasks == ( + "Prepare human-approved Spanish notice version or language-access note.", + ) + assert result.attachment_review_required is True + assert result.staff_review_required is True + + +def test_archive_packet_tracks_missing_and_complete_evidence() -> None: + incomplete = build_notice_archive_packet( + notice_id="hear-001", + notice_type="planning hearing", + source_module="civicclerk", + source_record_id="meeting-42", + registry_record_id="registry-1", + deadline_plan_id="deadline-1", + ) + complete = build_notice_archive_packet( + notice_id="hear-001", + notice_type="planning hearing", + source_module="civicclerk", + source_record_id="meeting-42", + registry_record_id="registry-1", + deadline_plan_id="deadline-1", + publication_proof_id="proof-1", + rule_check_complete=True, + template_complete=True, + accessibility_review_complete=True, + subscriber_delivery_complete=True, + records_export_complete=True, + ) + assert incomplete.readiness_status == "archive_packet_incomplete_staff_review_required" + assert "publication proof" in incomplete.missing_items + assert complete.readiness_status == "archive_packet_ready_for_staff_final_review" + assert complete.missing_items == () + assert complete.handoff_targets == ("civicclerk", "civicrecords") + + def test_records_export_preserves_notice_context() -> None: result = build_notice_records_export( notice_id="hear-001", @@ -85,10 +259,88 @@ def test_notice_support_apis_success_shape() -> None: "/api/v1/civicnotice/publication-check", json={"notice_type": "vacancy notice", "channel": "newspaper"}, ) + rule_check = client.post( + "/api/v1/civicnotice/rule-check", + json={ + "notice_type": "planning hearing", + "event_date": "2026-06-15", + "publication_dates": ["2026-05-30"], + "channels": ["city website", "posting board"], + "content_fields": [ + "title", + "hearing date", + "location", + "case number", + "statutory basis", + ], + "statutory_basis": "staff-entered basis", + }, + ) + template = client.post( + "/api/v1/civicnotice/templates", + json={ + "notice_type": "planning hearing", + "matter_title": "Planning hearing", + "event_date": "2026-06-15", + "location": "Council Chambers", + "contact": "clerk@example.gov", + "source_module": "civicclerk", + "statutory_basis": "staff-entered basis", + }, + ) channels = client.post( "/api/v1/civicnotice/channels", json={"notice_type": "public hearing", "audience": "residents"}, ) + subscribers = client.post( + "/api/v1/civicnotice/subscribers/plan", + json={ + "notice_id": "hear-001", + "notice_type": "public hearing", + "audience": "residents", + "subscribers": [ + { + "subscriber_id": "1", + "name": "Alex Resident", + "email": "alex@example.gov", + "channels": ["email"], + "language": "English", + "active": True, + } + ], + "required_channels": ["email"], + }, + ) + accessibility = client.post( + "/api/v1/civicnotice/accessibility-review", + json={ + "notice_id": "hear-001", + "title": "Planning hearing", + "notice_text": "Pursuant to the applicable provisions, a hearing is scheduled.", + "target_languages": ["Spanish"], + "attachments": ["notice.pdf"], + "has_contact": True, + "has_event_date": True, + "has_plain_language_summary": False, + }, + ) + archive = client.post( + "/api/v1/civicnotice/archive-packet", + json={ + "notice_id": "hear-001", + "notice_type": "public hearing", + "source_module": "civicclerk", + "source_record_id": "meeting-42", + "registry_record_id": "registry-1", + "deadline_plan_id": "deadline-1", + "publication_proof_id": "proof-1", + "rule_check_complete": True, + "template_complete": True, + "accessibility_review_complete": True, + "subscriber_delivery_complete": True, + "records_export_complete": True, + }, + ) export = client.post( "/api/v1/civicnotice/export", json={"title": "Planning hearing notice archive", "notice_id": "hear-001"}, @@ -99,8 +351,25 @@ def test_notice_support_apis_success_shape() -> None: assert len(deadlines.json()["reminders"]) == 4 assert publication.status_code == 200 assert publication.json()["proof_required"] is True + assert rule_check.status_code == 200 + assert rule_check.json()["deadline_status"] == "meets_minimum_lead_time" + assert rule_check.json()["missing_channels"] == [] + assert template.status_code == 200 + assert template.json()["source_module"] == "civicclerk" + assert template.json()["staff_review_required"] is True assert channels.status_code == 200 assert channels.json()["staff_review_required"] is True + assert subscribers.status_code == 200 + assert subscribers.json()["email_recipients"] == ["alex@example.gov"] + assert subscribers.json()["missing_required_channels"] == [] + assert accessibility.status_code == 200 + assert accessibility.json()["translation_tasks"] == [ + "Prepare human-approved Spanish notice version or language-access note." + ] + assert accessibility.json()["staff_review_required"] is True + assert archive.status_code == 200 + assert archive.json()["readiness_status"] == "archive_packet_ready_for_staff_final_review" + assert archive.json()["handoff_targets"] == ["civicclerk", "civicrecords"] assert export.status_code == 200 assert export.json()["notice_id"] == "hear-001" @@ -112,6 +381,12 @@ def test_public_ui_route_is_accessible_and_honest() -> None: text = response.text assert '' in text assert '
' in text - assert "v0.1.2 notice compliance foundation" in text + assert " None: + db_url = os.environ["CIVICNOTICE_POSTGRES_TEST_URL"] + engine = sa.create_engine(db_url, future=True) + with engine.begin() as connection: + connection.execute(sa.text("DROP SCHEMA IF EXISTS civicnotice CASCADE")) + engine.dispose() + + repository = NoticeWorkpaperRepository(db_url=db_url) + record = repository.create_notice_record( + notice_id="PG-N-1", notice_type="planning hearing", owner="Clerk" + ) + plan = repository.create_deadline_plan( + notice_type="planning hearing", event_date=date(2026, 6, 15) + ) + proof = repository.create_publication_proof( + notice_id="PG-N-1", + notice_type="planning hearing", + source_module="civicclerk", + source_record_id="meeting-pg-1", + channel="newspaper", + published_at="2026-05-20T09:00:00-06:00", + location="Daily Gazette", + confirmation_reference="PG-AFF-1", + statutory_basis="staff-entered basis", + reviewer="City Clerk", + ) + + assert repository.get_notice_record(record.record_id).notice_id == "PG-N-1" + assert repository.get_deadline_plan(plan.plan_id).event_date == date(2026, 6, 15) + assert repository.get_publication_proof(proof.proof_id).source_module == "civicclerk" + + with repository.engine.begin() as connection: + schema_exists = connection.execute( + sa.text("select exists(select 1 from information_schema.schemata where schema_name='civicnotice')") + ).scalar_one() + repository.engine.dispose() + assert schema_exists is True diff --git a/tests/test_production_depth_notice_persistence.py b/tests/test_production_depth_notice_persistence.py index 3e4555d..3e50cf3 100644 --- a/tests/test_production_depth_notice_persistence.py +++ b/tests/test_production_depth_notice_persistence.py @@ -18,12 +18,29 @@ def test_repository_persists_registry_and_deadline(tmp_path: Path) -> None: repository = NoticeWorkpaperRepository(db_url=db_url) record = repository.create_notice_record(notice_id="N-1", notice_type="hearing", owner="Clerk") plan = repository.create_deadline_plan(notice_type="hearing", event_date=date(2026, 5, 20)) + proof = repository.create_publication_proof( + notice_id="N-1", + notice_type="hearing", + source_module="civicclerk", + source_record_id="meeting-42", + channel="newspaper", + published_at="2026-05-10T09:00:00-06:00", + location="Daily Gazette", + confirmation_reference="DG-12345", + statutory_basis="MCA hearing notice", + reviewer="Deputy Clerk", + ) repository.engine.dispose() reloaded = NoticeWorkpaperRepository(db_url=db_url) assert reloaded.get_notice_record(record.record_id).owner == "Clerk" stored_plan = reloaded.get_deadline_plan(plan.plan_id) + stored_proof = reloaded.get_publication_proof(proof.proof_id) assert stored_plan.staff_review_required is True assert "official notice record" in stored_plan.disclaimer + assert stored_proof.source_module == "civicclerk" + assert stored_proof.source_record_id == "meeting-42" + assert stored_proof.confirmation_reference == "DG-12345" + assert stored_proof.compliance_status == "proof_recorded_staff_review_required" reloaded.engine.dispose() db_path.unlink() @@ -31,15 +48,25 @@ def test_repository_persists_registry_and_deadline(tmp_path: Path) -> None: def test_notice_persistence_api_round_trip(monkeypatch, tmp_path: Path) -> None: db_path = tmp_path / "civicnotice-api.db" monkeypatch.setenv("CIVICNOTICE_WORKPAPER_DB_URL", f"sqlite+pysqlite:///{db_path.as_posix()}") + monkeypatch.setenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN", "test-token") _dispose_workpaper_repository() - created_record = client.post("/api/v1/civicnotice/registry", json={"notice_id":"N-1","notice_type":"hearing","owner":"Clerk"}) + created_record = client.post( + "/api/v1/civicnotice/registry", + json={"notice_id": "N-1", "notice_type": "hearing", "owner": "Clerk"}, + headers={"X-CivicNotice-Write-Token": "test-token"}, + ) record_id = created_record.json()["record_id"] fetched_record = client.get(f"/api/v1/civicnotice/registry/{record_id}") - created_plan = client.post("/api/v1/civicnotice/deadlines", json={"notice_type":"hearing","event_date":"2026-05-20","lead_days":10}) + created_plan = client.post( + "/api/v1/civicnotice/deadlines", + json={"notice_type": "hearing", "event_date": "2026-05-20", "lead_days": 10}, + headers={"X-CivicNotice-Write-Token": "test-token"}, + ) plan_id = created_plan.json()["plan_id"] fetched_plan = client.get(f"/api/v1/civicnotice/deadlines/{plan_id}") _dispose_workpaper_repository() monkeypatch.delenv("CIVICNOTICE_WORKPAPER_DB_URL") + monkeypatch.delenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN") assert fetched_record.status_code == 200 assert fetched_record.json()["notice_id"] == "N-1" assert fetched_plan.status_code == 200 @@ -48,6 +75,42 @@ def test_notice_persistence_api_round_trip(monkeypatch, tmp_path: Path) -> None: db_path.unlink() +def test_publication_proof_api_round_trip(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "civicnotice-proof.db" + monkeypatch.setenv("CIVICNOTICE_WORKPAPER_DB_URL", f"sqlite+pysqlite:///{db_path.as_posix()}") + monkeypatch.setenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN", "test-token") + _dispose_workpaper_repository() + created_proof = client.post( + "/api/v1/civicnotice/publication-proof", + json={ + "notice_id": "N-2", + "notice_type": "hearing", + "source_module": "civicclerk", + "source_record_id": "meeting-99", + "channel": "public website", + "published_at": "2026-05-10T09:00:00-06:00", + "location": "city.example.gov/notices/N-2", + "confirmation_reference": "CMS-98765", + "statutory_basis": "staff-entered hearing notice basis", + "reviewer": "City Clerk", + }, + headers={"X-CivicNotice-Write-Token": "test-token"}, + ) + proof_id = created_proof.json()["proof_id"] + fetched_proof = client.get(f"/api/v1/civicnotice/publication-proof/{proof_id}") + _dispose_workpaper_repository() + monkeypatch.delenv("CIVICNOTICE_WORKPAPER_DB_URL") + monkeypatch.delenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN") + assert fetched_proof.status_code == 200 + payload = fetched_proof.json() + assert payload["notice_id"] == "N-2" + assert payload["source_module"] == "civicclerk" + assert payload["source_record_id"] == "meeting-99" + assert payload["confirmation_reference"] == "CMS-98765" + assert "does not certify legal sufficiency" in " ".join(payload["proof_notes"]) + db_path.unlink() + + def test_get_registry_without_persistence_returns_actionable_503(monkeypatch) -> None: monkeypatch.delenv("CIVICNOTICE_WORKPAPER_DB_URL", raising=False) _dispose_workpaper_repository() @@ -66,3 +129,104 @@ def test_get_deadline_missing_id_returns_actionable_404(monkeypatch, tmp_path: P assert response.status_code == 404 assert "POST /api/v1/civicnotice/deadlines" in response.json()["detail"]["fix"] db_path.unlink() + + +def test_get_publication_proof_missing_id_returns_actionable_404( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "civicnotice-proof-missing.db" + monkeypatch.setenv("CIVICNOTICE_WORKPAPER_DB_URL", f"sqlite+pysqlite:///{db_path.as_posix()}") + _dispose_workpaper_repository() + response = client.get("/api/v1/civicnotice/publication-proof/missing") + _dispose_workpaper_repository() + monkeypatch.delenv("CIVICNOTICE_WORKPAPER_DB_URL") + assert response.status_code == 404 + assert "POST /api/v1/civicnotice/publication-proof" in response.json()["detail"]["fix"] + db_path.unlink() + + +def test_publication_proof_without_persistence_returns_actionable_503(monkeypatch) -> None: + monkeypatch.delenv("CIVICNOTICE_WORKPAPER_DB_URL", raising=False) + _dispose_workpaper_repository() + response = client.post( + "/api/v1/civicnotice/publication-proof", + json={ + "notice_id": "N-3", + "notice_type": "hearing", + "source_record_id": "meeting-100", + "channel": "newspaper", + "published_at": "2026-05-10", + "location": "Daily Gazette", + "confirmation_reference": "DG-333", + "statutory_basis": "staff-entered basis", + "reviewer": "City Clerk", + }, + ) + assert response.status_code == 503 + assert "Set CIVICNOTICE_WORKPAPER_DB_URL" in response.json()["detail"]["fix"] + + +def test_persistence_backed_write_requires_trusted_token(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "civicnotice-token.db" + monkeypatch.setenv("CIVICNOTICE_WORKPAPER_DB_URL", f"sqlite+pysqlite:///{db_path.as_posix()}") + monkeypatch.setenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN", "test-token") + _dispose_workpaper_repository() + missing = client.post( + "/api/v1/civicnotice/registry", + json={"notice_id": "N-4", "notice_type": "hearing", "owner": "Clerk"}, + ) + wrong = client.post( + "/api/v1/civicnotice/registry", + json={"notice_id": "N-4", "notice_type": "hearing", "owner": "Clerk"}, + headers={"X-CivicNotice-Write-Token": "wrong"}, + ) + _dispose_workpaper_repository() + monkeypatch.delenv("CIVICNOTICE_WORKPAPER_DB_URL") + monkeypatch.delenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN") + assert missing.status_code == 403 + assert wrong.status_code == 403 + if db_path.exists(): + db_path.unlink() + + +def test_persistence_backed_write_requires_guard_configuration( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "civicnotice-token-missing.db" + monkeypatch.setenv("CIVICNOTICE_WORKPAPER_DB_URL", f"sqlite+pysqlite:///{db_path.as_posix()}") + monkeypatch.delenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN", raising=False) + _dispose_workpaper_repository() + response = client.post( + "/api/v1/civicnotice/deadlines", + json={"notice_type": "hearing", "event_date": "2026-05-20", "lead_days": 10}, + headers={"X-CivicNotice-Write-Token": "test-token"}, + ) + _dispose_workpaper_repository() + monkeypatch.delenv("CIVICNOTICE_WORKPAPER_DB_URL") + assert response.status_code == 503 + assert "CIVICNOTICE_TRUSTED_WRITE_TOKEN" in response.json()["detail"]["fix"] + if db_path.exists(): + db_path.unlink() + + +def test_configured_but_unavailable_persistence_returns_actionable_503( + monkeypatch, tmp_path: Path +) -> None: + missing_parent = tmp_path / "missing-parent" / "civicnotice.db" + monkeypatch.setenv( + "CIVICNOTICE_WORKPAPER_DB_URL", + f"sqlite+pysqlite:///{missing_parent.as_posix()}", + ) + monkeypatch.setenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN", "test-token") + _dispose_workpaper_repository() + response = client.post( + "/api/v1/civicnotice/registry", + json={"notice_id": "N-5", "notice_type": "hearing", "owner": "Clerk"}, + headers={"X-CivicNotice-Write-Token": "test-token"}, + ) + _dispose_workpaper_repository() + monkeypatch.delenv("CIVICNOTICE_WORKPAPER_DB_URL") + monkeypatch.delenv("CIVICNOTICE_TRUSTED_WRITE_TOKEN") + assert response.status_code == 503 + assert "configured but unavailable" in response.json()["detail"]["message"] + assert "database reachability" in response.json()["detail"]["fix"] diff --git a/tests/test_release_contracts.py b/tests/test_release_contracts.py new file mode 100644 index 0000000..1cd4003 --- /dev/null +++ b/tests/test_release_contracts.py @@ -0,0 +1,150 @@ +from datetime import date, timedelta +from pathlib import Path +import tomllib + +from fastapi.testclient import TestClient + +from civicnotice.archive_packet import build_notice_archive_packet +from civicnotice.main import app +from civicnotice.publication_proof import record_publication_proof +from civicnotice.statutory_rules import RULE_PACKS, check_statutory_notice_requirements +from civicnotice.subscriber_delivery import Subscriber, build_subscriber_delivery_plan + + +client = TestClient(app) + + +def test_civiccore_dependency_declares_release_wheel_hash() -> None: + pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + civiccore_dependency = next( + dependency + for dependency in pyproject["project"]["dependencies"] + if dependency.startswith("civiccore @ ") + ) + assert "releases/download/v1.2.0/civiccore-1.2.0-py3-none-any.whl" in civiccore_dependency + assert "#sha256=a94ce958e36fb03c8d961e4db4672ce5bcfa25765c57d75886e999cf15703ec7" in civiccore_dependency + + +def test_openapi_documents_persistence_failure_contracts() -> None: + openapi = client.get("/openapi.json").json() + paths = openapi["paths"] + persistence_gets = [ + ("/api/v1/civicnotice/registry/{record_id}", "get"), + ("/api/v1/civicnotice/deadlines/{plan_id}", "get"), + ("/api/v1/civicnotice/publication-proof/{proof_id}", "get"), + ] + persistence_writes = [ + ("/api/v1/civicnotice/registry", "post"), + ("/api/v1/civicnotice/deadlines", "post"), + ("/api/v1/civicnotice/publication-proof", "post"), + ] + for path, method in persistence_gets: + responses = paths[path][method]["responses"] + assert "404" in responses + assert "503" in responses + for path, method in persistence_writes: + responses = paths[path][method]["responses"] + assert "403" in responses + assert "503" in responses + + +def test_openapi_guides_first_use_notice_type_values() -> None: + openapi = client.get("/openapi.json").json() + schemas = openapi["components"]["schemas"] + rule_notice = schemas["RuleCheckRequest"]["properties"]["notice_type"] + template_notice = schemas["NoticeTemplateRequest"]["properties"]["notice_type"] + assert "Supported rule/template notice types" in rule_notice["description"] + assert "planning hearing" in rule_notice["examples"] + assert "public hearing" in template_notice["examples"] + + +def test_api_rejects_fields_longer_than_storage_contract() -> None: + too_long = "x" * 161 + response = client.post( + "/api/v1/civicnotice/registry", + json={"notice_id": too_long, "notice_type": "hearing", "owner": "Clerk"}, + ) + assert response.status_code == 422 + + +def test_all_rule_packs_return_serializable_deadline_checks() -> None: + for notice_type, rule in RULE_PACKS.items(): + event_date = date(2026, 7, 1) + publication_date = event_date - timedelta(days=rule.minimum_lead_days) + result = check_statutory_notice_requirements( + notice_type=notice_type, + event_date=event_date, + publication_dates=(publication_date,), + channels=rule.required_channels, + content_fields=rule.required_content, + statutory_basis="staff-entered basis", + ) + assert result.notice_type == notice_type + assert result.publication_count_status == "required_publication_count_met" + assert result.missing_channels == () + assert result.missing_content == () + + +def test_publication_proof_fallbacks_are_staff_reviewable() -> None: + result = record_publication_proof( + notice_id="", + notice_type="", + source_module="", + source_record_id="", + channel="", + published_at="", + location="", + confirmation_reference="", + statutory_basis="", + reviewer="", + ) + assert result.notice_id == "unassigned notice" + assert result.source_record_id == "unlinked source record" + assert result.confirmation_reference == "pending confirmation reference" + assert result.compliance_status == "proof_recorded_staff_review_required" + + +def test_archive_packet_handoff_targets_cover_source_modules() -> None: + expected = { + "civicclerk": ("civicclerk", "civicrecords"), + "civicprocure": ("civicprocure", "civicrecords"), + "civicboards": ("civicboards", "civicrecords"), + "manual": ("manual staff archive", "civicrecords"), + } + for source_module, targets in expected.items(): + result = build_notice_archive_packet( + notice_id="N-1", + notice_type="hearing", + source_module=source_module, + source_record_id="source-1", + ) + assert result.handoff_targets == targets + + +def test_subscriber_delivery_empty_and_required_channel_variants() -> None: + empty = build_subscriber_delivery_plan( + notice_id="N-1", + notice_type="hearing", + audience="residents", + subscribers=(), + required_channels=("email",), + ) + postal = build_subscriber_delivery_plan( + notice_id="N-1", + notice_type="hearing", + audience="residents", + subscribers=( + Subscriber( + subscriber_id="1", + name="Resident", + email="resident@example.gov", + channels=("postal mail",), + language="English", + ), + ), + required_channels=("postal mail",), + ) + assert empty.active_subscriber_count == 0 + assert empty.missing_required_channels == ("email",) + assert postal.missing_required_channels == () + assert postal.email_recipients == () diff --git a/tests/test_runtime_foundation.py b/tests/test_runtime_foundation.py index 53cbffe..a8cba98 100644 --- a/tests/test_runtime_foundation.py +++ b/tests/test_runtime_foundation.py @@ -8,7 +8,7 @@ def test_package_version_is_012() -> None: - assert civicnotice.__version__ == "0.1.2" + assert civicnotice.__version__ == "0.2.0" def test_root_endpoint_states_runtime_boundary() -> None: @@ -18,9 +18,10 @@ def test_root_endpoint_states_runtime_boundary() -> None: assert data["name"] == "CivicNotice" assert data["status"] == "notice compliance foundation" assert "CivicCore-backed deadline plans" in data["message"] - assert "database-backed registry/deadline workpapers" in data["message"] + assert "archive/handoff packets" in data["message"] + assert "database-backed registry/deadline/publication-proof workpapers" in data["message"] assert "official publication" in data["message"] - assert "Post-v0.1.2 roadmap" in data["next_step"] + assert "Post-v0.2.0 roadmap" in data["next_step"] def test_health_endpoint_reports_versions() -> None: @@ -29,5 +30,5 @@ def test_health_endpoint_reports_versions() -> None: data = response.json() assert data["status"] == "ok" assert data["service"] == "civicnotice" - assert data["version"] == "0.1.2" - assert data["civiccore_version"] == "0.9.0" + assert data["version"] == "0.2.0" + assert data["civiccore_version"] == "1.2.0"