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.
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.
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.
+
+
+
+
+
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.