Native command boundary
These Rust sources are the fixed transport/IPC contracts from the candidate companion. They are not a new command service. Use the existing signed shell; native proof remains owned by that shell.
code_client.rs
use crate::domain_client::{send, DomainError};
use reqwest::{Method, Url};
use serde::Deserialize;
use serde_json::Value;
const BASE: &str = "http://127.0.0.1:15480";
#[derive(Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum CodeRequest {
Connections {},
Connection {
id: String,
},
SendProposal {
payload: Value,
},
CloseConnection {
id: String,
payload: Value,
},
Workspace {},
Sources {},
Import {
payload: Value,
},
Lookup {
section_number: String,
as_of: Option<String>,
},
Citation {
section_number: String,
as_of: Option<String>,
},
Search {
query: String,
},
History {
id: String,
},
Export {
section_ref: String,
as_of: Option<String>,
},
Question {
question: String,
section_number: Option<String>,
as_of: Option<String>,
},
ImportTree {
id: String,
},
ImportProvenance {
id: String,
},
}
/// Encode an identifier as a single URL path segment, rejecting values that
/// could escape the intended path.
pub(crate) fn encode_segment(id: &str) -> Result<String, DomainError> {
if id.is_empty()
|| id == "."
|| id == ".."
|| id.contains('/')
|| id.contains('\\')
|| id.chars().any(|c| c.is_control())
{
return Err(DomainError::denied());
}
let mut out = String::with_capacity(id.len());
for b in id.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
Ok(out)
}
pub async fn execute(request: CodeRequest, proof: String) -> Result<Value, DomainError> {
let base = Url::parse(BASE).map_err(|_| DomainError::denied())?;
let (method, segment, body, params): (Method, String, Option<Value>, Vec<(&str, String)>) =
match request {
CodeRequest::Connections {} => {
(Method::GET, "connections".to_string(), None, Vec::new())
}
CodeRequest::Connection { id } => (
Method::GET,
format!("connections/{}", encode_segment(&id)?),
None,
Vec::new(),
),
CodeRequest::SendProposal { payload } => (
Method::POST,
"connections".to_string(),
Some(serde_json::json!(payload)),
Vec::new(),
),
CodeRequest::CloseConnection { id, payload } => (
Method::POST,
format!("connections/{}/closeout", encode_segment(&id)?),
Some(serde_json::json!(payload)),
Vec::new(),
),
CodeRequest::Workspace {} => (Method::GET, "workspace".to_string(), None, Vec::new()),
CodeRequest::Sources {} => (Method::GET, "sources".to_string(), None, Vec::new()),
CodeRequest::Import { payload } => (
Method::POST,
"imports".to_string(),
Some(payload),
Vec::new(),
),
CodeRequest::Lookup {
section_number,
as_of,
} => {
let mut q = vec![("section_number", section_number)];
if let Some(a) = as_of {
q.push(("as_of", a));
}
(Method::GET, "lookup".to_string(), None, q)
}
CodeRequest::Citation {
section_number,
as_of,
} => {
let mut q = vec![("section_number", section_number)];
if let Some(a) = as_of {
q.push(("as_of", a));
}
(Method::GET, "citation".to_string(), None, q)
}
CodeRequest::Search { query } => {
(Method::GET, "search".to_string(), None, vec![("q", query)])
}
CodeRequest::Export { section_ref, as_of } => {
let mut params: Vec<(&str, String)> = vec![("section_ref", section_ref.clone())];
if let Some(v) = as_of.clone() {
params.push(("as_of", v));
}
(reqwest::Method::GET, "export".to_string(), None, params)
}
CodeRequest::Question {
question,
section_number,
as_of,
} => {
let body = serde_json::json!({
"question": question,
"section_number": section_number,
"as_of": as_of,
});
(
reqwest::Method::POST,
"questions".to_string(),
Some(body),
Vec::new(),
)
}
CodeRequest::ImportTree { id } => (
reqwest::Method::GET,
format!("imports/{}/tree", encode_segment(&id)?),
None,
Vec::new(),
),
CodeRequest::ImportProvenance { id } => (
reqwest::Method::GET,
format!("imports/{}/provenance", encode_segment(&id)?),
None,
Vec::new(),
),
CodeRequest::History { id } => (
Method::GET,
format!("sections/{}/history", encode_segment(&id)?),
None,
Vec::new(),
),
};
let mut url = base
.join(&format!("/desktop/v1/code/{}", segment))
.map_err(|_| DomainError::denied())?;
if !params.is_empty() {
let mut pairs = url.query_pairs_mut();
for (k, v) in ¶ms {
pairs.append_pair(k, v);
}
}
let path_and_query = match url.query() {
Some(q) => format!("{}?{}", url.path(), q),
None => url.path().to_string(),
};
send(method, path_and_query, body, &proof).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires paired disposable runtime at 127.0.0.1:15480"]
fn native_code_roundtrip_with_real_runtime() {
let proof = std::env::var("TOWNLIGHT_TEST_SESSION_PROOF")
.expect("TOWNLIGHT_TEST_SESSION_PROOF must be set");
let bundle_path = std::env::var("TOWNLIGHT_TEST_CODE_BUNDLE")
.expect("TOWNLIGHT_TEST_CODE_BUNDLE must be set");
let raw =
std::fs::read_to_string(&bundle_path).expect("failed to read synthetic Code bundle");
let payload: serde_json::Value =
serde_json::from_str(&raw).expect("valid synthetic bundle JSON");
tauri::async_runtime::block_on(async move {
let workspace_err = execute(CodeRequest::Workspace {}, "0".repeat(64)).await;
assert!(
workspace_err.is_err(),
"Workspace with bogus proof must fail"
);
let imported = execute(CodeRequest::Import { payload }, proof.clone())
.await
.expect("Import should succeed against real runtime");
assert_eq!(
imported["status"], "completed",
"Import did not complete: {}",
imported
);
let lookup = execute(
CodeRequest::Lookup {
section_number: "6.12.040".to_string(),
as_of: Some("2025-06-01".to_string()),
},
proof.clone(),
)
.await
.expect("Lookup should succeed");
let body = lookup["version"]["body"]
.as_str()
.expect("Lookup version body");
assert!(body.contains("four"), "Historical body: {}", body);
let history = execute(
CodeRequest::History {
id: "sec_6_12_040".to_string(),
},
proof.clone(),
)
.await
.expect("History should succeed");
assert_eq!(
history["versions"]
.as_array()
.expect("versions array")
.len(),
2
);
let citation = execute(
CodeRequest::Citation {
section_number: "6.12.040".to_string(),
as_of: None,
},
proof,
)
.await
.expect("Citation should succeed");
assert_eq!(citation["status"], "ok");
assert_eq!(citation["citation"]["source_id"], "csv_active");
});
}
}domain_client.rs
//! Local transport only: no municipal rules, retries, JSON-state fallback,
//! caller-selected URL, or session proof exposed to the webview.
use reqwest::{Client, Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
use uuid::Uuid;
const DOMAIN_ORIGIN: &str = "http://127.0.0.1:15480";
const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
#[derive(Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum MeetingsRequest {
CodeConnections {},
CodeConnection { id: String },
RecordCodeDecision { id: String, payload: Value },
RecordMeetingStage { id: Uuid, payload: MeetingStage },
CapturePacket { id: Uuid, payload: PacketInput },
DeliverPacket { id: Uuid },
GetNoticeTemplate { id: Uuid },
DeliverNotice { id: Uuid, payload: NoticeDocument },
CaptureTranscript { id: Uuid, payload: TranscriptInput },
CaptureActionItem { id: Uuid, payload: ActionItemInput },
ArchiveMeeting { id: Uuid, payload: ArchiveInput },
CheckNotice { id: Uuid, payload: NoticeInput },
RecordNoticeProof { id: Uuid, payload: NoticeProof },
RecordMinutesDecision { id: Uuid, payload: MinutesDecision },
RecordAgendaReview { id: Uuid, payload: AgendaReview },
AssignAgendaMeeting { id: Uuid, payload: AgendaAssignment },
CreateMeeting { payload: MeetingIntake },
GetMeeting { id: Uuid },
CreateAgendaItem { payload: AgendaIntake },
GetAgendaItem { id: Uuid },
CaptureMotion { id: Uuid, payload: MotionInput },
CorrectMotion { id: Uuid, payload: MotionCorrection },
CaptureVote { id: Uuid, payload: VoteInput },
CorrectVote { id: Uuid, payload: VoteCorrection },
CreateMinutesDraft { id: Uuid, payload: MinutesInput },
GetProceedings { id: Uuid },
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MinutesDecision {
action: String,
note: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PacketInput {
agenda_item_ids: Vec<Uuid>,
#[serde(default)]
source_paths: Vec<String>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MeetingStage {
to_status: String,
statutory_basis: Option<String>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NoticeInput {
notice_type: String,
posted_at: String,
minimum_notice_hours: u32,
statutory_basis: Option<String>,
approved_by: Option<String>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NoticeProof {
location: String,
note: String,
document_delivery_id: Option<String>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NoticeDocument {
text: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgendaReview {
to_status: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgendaAssignment {
meeting_id: Uuid,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MotionInput {
text: String,
seconded_by: Option<String>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MotionCorrection {
text: String,
reason: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct VoteInput {
voter_name: String,
vote: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct VoteCorrection {
vote: String,
reason: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MinutesSource {
source_id: String,
label: String,
text: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MinutesSentence {
text: String,
citations: Vec<String>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MinutesInput {
source_materials: Vec<MinutesSource>,
sentences: Vec<MinutesSentence>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MeetingIntake {
title: String,
scheduled_start: String,
location: String,
#[serde(default = "regular_meeting_type")]
meeting_type: String,
}
fn regular_meeting_type() -> String {
"regular".into()
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TranscriptInput {
source_label: String,
transcript_text: String,
correction_of_id: Option<Uuid>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ActionItemInput {
description: String,
assigned_to: Option<String>,
source_motion_id: Option<Uuid>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ArchiveInput {
visibility: String,
packet_id: Option<Uuid>,
minutes_id: Option<Uuid>,
plain_language_summary: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgendaIntake {
title: String,
department_name: String,
}
pub async fn execute_meetings(
request: MeetingsRequest,
proof: String,
) -> Result<Value, DomainError> {
let (method, path, body) = match request {
MeetingsRequest::CodeConnections {} => (
Method::GET,
"/desktop/v1/meetings/code-connections".to_string(),
None,
),
MeetingsRequest::CodeConnection { id } => (
Method::GET,
format!(
"/desktop/v1/meetings/code-connections/{}",
crate::code_client::encode_segment(&id)?
),
None,
),
MeetingsRequest::RecordCodeDecision { id, payload } => (
Method::POST,
format!(
"/desktop/v1/meetings/code-connections/{}/decisions",
crate::code_client::encode_segment(&id)?
),
Some(serde_json::json!(payload)),
),
MeetingsRequest::GetNoticeTemplate { id } => (
Method::GET,
format!("/desktop/v1/meetings/notices/{id}/template"),
None,
),
MeetingsRequest::DeliverNotice { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/notices/{id}/document"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CaptureTranscript { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/meetings/{id}/transcripts"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CaptureActionItem { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/meetings/{id}/action-items"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::ArchiveMeeting { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/meetings/{id}/archive"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CheckNotice { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/meetings/{id}/notice-checks"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::RecordNoticeProof { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/notices/{id}/proof"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::RecordMeetingStage { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/meetings/{id}/stages"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CapturePacket { id, payload } => {
let mut files = Vec::new();
for path in payload.source_paths {
files.push(crate::domain_files::stage_meetings(&path).map_err(|_| {
DomainError::new(
"file-unavailable",
"The selected source file could not be staged; no packet was submitted.",
false,
)
})?);
}
(
Method::POST,
format!("/desktop/v1/meetings/meetings/{id}/packet-snapshots"),
Some(
serde_json::json!({"agenda_item_ids":payload.agenda_item_ids,"source_files":files}),
),
)
}
MeetingsRequest::DeliverPacket { id } => (
Method::POST,
format!("/desktop/v1/meetings/packets/{id}/deliver"),
None,
),
MeetingsRequest::RecordMinutesDecision { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/minutes/{id}/decisions"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::RecordAgendaReview { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/agenda-items/{id}/reviews"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::AssignAgendaMeeting { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/agenda-items/{id}/meeting"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CaptureMotion { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/meetings/{id}/motions"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CorrectMotion { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/motions/{id}/corrections"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CaptureVote { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/motions/{id}/votes"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CorrectVote { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/votes/{id}/corrections"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::CreateMinutesDraft { id, payload } => (
Method::POST,
format!("/desktop/v1/meetings/meetings/{id}/minutes/drafts"),
Some(serde_json::json!(payload)),
),
MeetingsRequest::GetProceedings { id } => (
Method::GET,
format!("/desktop/v1/meetings/meetings/{id}/proceedings"),
None,
),
MeetingsRequest::CreateMeeting { payload } => (
Method::POST,
"/desktop/v1/meetings/meetings".into(),
Some(serde_json::json!(payload)),
),
MeetingsRequest::GetMeeting { id } => (
Method::GET,
format!("/desktop/v1/meetings/meetings/{id}"),
None,
),
MeetingsRequest::CreateAgendaItem { payload } => (
Method::POST,
"/desktop/v1/meetings/agenda-items".into(),
Some(serde_json::json!(payload)),
),
MeetingsRequest::GetAgendaItem { id } => (
Method::GET,
format!("/desktop/v1/meetings/agenda-items/{id}"),
None,
),
};
send(method, path, body, &proof).await
}
#[derive(Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum SuiteRequest {
State {},
Search { query: String },
}
pub async fn execute_suite(request: SuiteRequest, proof: String) -> Result<Value, DomainError> {
let (method, path, body) = match request {
SuiteRequest::State {} => (Method::GET, "/desktop/v1/suite/state", None),
SuiteRequest::Search { query } => (
Method::POST,
"/desktop/v1/suite/search",
Some(serde_json::json!({"query":query})),
),
};
send(method, path.into(), body, &proof).await
}
#[derive(Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum AccessRequest {
State {},
Review { payload: AccessReview },
DeleteReview { review_id: Uuid },
ExportReview { review_id: Uuid },
Tool { payload: AccessTool },
Rewrite { payload: AccessRewrite },
}
#[derive(Deserialize, Serialize)]
#[serde(
tag = "action",
rename_all = "kebab-case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum AccessTool {
FormPlan {
form_name: String,
fields: Vec<String>,
},
PublishingWorkflow {
title: String,
has_review: bool,
has_plain_language: bool,
has_translation_review: bool,
},
AdaTitleIi {
service_area: String,
has_coordinator_review: bool,
},
TaggedPdf {
heading_levels: Vec<u32>,
},
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RewriteAction {
PlainLanguage,
LanguageVariant,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AccessRewrite {
action: RewriteAction,
text: String,
#[serde(default)]
language: String,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccessReview {
#[serde(default)]
title: String,
#[serde(default)]
body: String,
#[serde(default)]
has_alt_text: bool,
#[serde(default)]
language: String,
}
pub async fn execute_access(request: AccessRequest, proof: String) -> Result<Value, DomainError> {
match request {
AccessRequest::ExportReview { review_id } => {
let plan = send(
Method::POST,
format!("/desktop/v1/access/reviews/{review_id}/export"),
None,
&proof,
)
.await?;
let plan: crate::domain_files::ArtifactPlan =
serde_json::from_value(plan).map_err(|_| DomainError::transport(true))?;
let artifact_id = plan.artifact_id;
let path = tauri::async_runtime::spawn_blocking(move || {
crate::domain_files::write_access_export(plan)
})
.await
.map_err(|_| DomainError::transport(true))?
.map_err(|_| {
DomainError::new(
"export-incomplete",
"Access export was not finalized. Prepared evidence is retained.",
true,
)
})?;
send(
Method::POST,
format!("/desktop/v1/access/exports/{artifact_id}/complete"),
Some(serde_json::json!({"export_path":path})),
&proof,
)
.await
}
AccessRequest::Tool { payload } => {
send(
Method::POST,
"/desktop/v1/access/tools".into(),
Some(serde_json::json!(payload)),
&proof,
)
.await
}
AccessRequest::Rewrite { payload } => {
let context = send(
Method::POST,
"/desktop/v1/access/rewrite-context".into(),
Some(serde_json::json!(&payload)),
&proof,
)
.await?;
let prompt = context
.get("prompt")
.and_then(Value::as_str)
.ok_or_else(|| DomainError::transport(false))?
.to_owned();
let generated = tauri::async_runtime::spawn_blocking(move || {
if crate::model::local_generation_available()? {
crate::model::generate_local_text(&prompt).map(Some)
} else { Ok(None) }
}).await.map_err(|_| DomainError::transport(false))?
.map_err(|_| DomainError::new("model-unavailable", "Local AI generation failed. No rewrite was recorded; no sample was substituted.", false))?;
let mut body = serde_json::json!(payload);
if let Some((model, text)) = generated {
body["outcome"] = "generated".into();
body["generated"] = text.into();
body["model"] = model.into();
} else {
body["outcome"] = "unavailable".into();
}
send(
Method::POST,
"/desktop/v1/access/rewrite-result".into(),
Some(body),
&proof,
)
.await
}
AccessRequest::State {} => {
send(Method::GET, "/desktop/v1/access/state".into(), None, &proof).await
}
AccessRequest::DeleteReview { review_id } => {
send(
Method::POST,
format!("/desktop/v1/access/reviews/{review_id}/delete"),
None,
&proof,
)
.await
}
AccessRequest::Review { payload } => {
let context = send(
Method::POST,
"/desktop/v1/access/review-context".into(),
Some(serde_json::json!(&payload)),
&proof,
)
.await?;
let prompt = context
.get("prompt")
.and_then(Value::as_str)
.ok_or_else(|| DomainError::transport(false))?
.to_owned();
// Runtime availability and generation only; findings/prompt and
// persistence belong to Access. Failed AI must not lose a review.
let generated = tauri::async_runtime::spawn_blocking(move || {
if crate::model::local_generation_available().unwrap_or(false) {
Some(crate::model::generate_local_text(&prompt))
} else {
None
}
})
.await;
let mut body = serde_json::json!(&payload);
match generated {
Ok(Some(Ok((model, text)))) => {
body["ai_outcome"] = "generated".into();
body["ai_analysis"] = text.into();
body["ai_analysis_model"] = model.into();
}
Ok(None) => body["ai_outcome"] = "unavailable".into(),
_ => body["ai_outcome"] = "failed".into(),
}
send(
Method::POST,
"/desktop/v1/access/reviews".into(),
Some(body),
&proof,
)
.await
}
}
}
#[derive(Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum PublicRecordsRequest {
State {},
Submit { payload: PublicIntake },
Lookup { payload: PublicLookup },
Message { payload: PublicMessage },
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PublicIntake {
requester: String,
requester_contact: String,
summary: String,
#[serde(default)]
deadline: String,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PublicLookup {
tracking_number: String,
requester_contact: String,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PublicMessage {
tracking_number: String,
requester_contact: String,
public_request_message: String,
}
pub async fn execute_public(
request: PublicRecordsRequest,
proof: String,
) -> Result<Value, DomainError> {
let (method, path, body) = match request {
PublicRecordsRequest::State {} => (Method::GET, "records-state", None),
PublicRecordsRequest::Submit { payload } => {
(Method::POST, "requests", Some(serde_json::json!(payload)))
}
PublicRecordsRequest::Lookup { payload } => {
(Method::POST, "lookup", Some(serde_json::json!(payload)))
}
PublicRecordsRequest::Message { payload } => {
(Method::POST, "messages", Some(serde_json::json!(payload)))
}
};
send_with_header(
method,
format!("/desktop/v1/public/{path}"),
body,
&proof,
"X-Townlight-Public",
)
.await
}
#[derive(Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum RecordsRequest {
SearchPlanning {
query: String,
},
AttachPlanningSource {
request_id: Uuid,
source_id: String,
expected_sha256: String,
},
SearchZoning {
query: String,
},
AttachZoningSource {
request_id: Uuid,
source_id: String,
expected_sha256: String,
},
SearchCode {
query: String,
as_of: Option<String>,
},
AttachCodeSource {
request_id: Uuid,
source_id: String,
expected_sha256: String,
as_of: Option<String>,
},
SearchMeetings {
query: String,
},
AttachMeetingsSource {
request_id: Uuid,
source_id: String,
expected_sha256: String,
},
State {},
MarkNotification {
notification_id: Uuid,
},
Suggest {
request_id: Uuid,
},
Export {
request_id: Uuid,
kind: ArtifactKind,
},
AttachDocument {
request_id: Uuid,
title: String,
source_path: String,
#[serde(default)]
citation: String,
},
AttachReleaseCopy {
request_id: Uuid,
attachment_id: Uuid,
source_path: String,
status: ReleaseCopyStatus,
#[serde(default)]
note: String,
#[serde(default)]
added_by: String,
},
Create {
payload: Intake,
},
View {
request_id: Uuid,
},
Apply {
request_id: Uuid,
action: RecordsAction,
payload: Value,
},
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Intake {
requester: String,
summary: String,
deadline: String,
#[serde(default)]
deadline_basis: String,
}
#[derive(Deserialize, Serialize)]
pub enum ReleaseCopyStatus {
#[serde(rename = "release-ready copy")]
Ready,
#[serde(rename = "redacted copy")]
Redacted,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ArtifactKind {
Response,
ReleasePackage,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RecordsAction {
AssignRecordsRequest,
SetRecordsDeadline,
CalculateRecordsDeadline,
RequestRecordsClarification,
AddRecordsMessage,
RecordRecordsSearch,
RecordRecordsSearchSession,
AddRecordsExemptionReview,
AddRecordsExemptionDecision,
EstimateRecordsFee,
AddRecordsFeeLine,
WaiveRecordsFee,
DraftRecordsResponse,
ApproveRecordsResponse,
FulfillRecordsRequest,
CloseRecordsRequest,
}
#[derive(Debug, Serialize)]
pub struct DomainError {
pub code: &'static str,
pub message: &'static str,
pub outcome_unknown: bool,
}
impl DomainError {
pub fn maintenance() -> Self {
Self::new("profile-maintenance", "Profile maintenance is in progress. No Records operation was started; retry after maintenance finishes.", false)
}
fn new(code: &'static str, message: &'static str, outcome_unknown: bool) -> Self {
Self {
code,
message,
outcome_unknown,
}
}
pub fn denied() -> Self {
Self::new(
"access-denied",
"Sign in with an authorized Records account and enable Records in this profile.",
false,
)
}
fn transport(writing: bool) -> Self {
Self::new("service-unavailable", "Records could not confirm the result. Restore service availability and refresh before repeating a change.", writing)
}
}
impl RecordsRequest {
fn wire(self) -> Result<(Method, String, Option<Value>), DomainError> {
let stage = |source: &str, title: &str, note: &str| {
crate::domain_files::stage(source, title, note).map_err(|_| {
DomainError::new(
"file-unavailable",
"The Records file could not be staged; no domain change was submitted.",
false,
)
})
};
Ok(match self {
Self::SearchPlanning { query } => (
Method::POST,
"/desktop/v1/planning-sources/search".into(),
Some(serde_json::json!({"query":query})),
),
Self::AttachPlanningSource {
request_id,
source_id,
expected_sha256,
} => (
Method::POST,
format!("/desktop/v1/requests/{request_id}/planning-sources"),
Some(serde_json::json!({"source_id":source_id,"expected_sha256":expected_sha256})),
),
Self::SearchZoning { query } => (
Method::POST,
"/desktop/v1/zoning-sources/search".into(),
Some(serde_json::json!({ "query": query })),
),
Self::AttachZoningSource {
request_id,
source_id,
expected_sha256,
} => (
Method::POST,
format!("/desktop/v1/requests/{request_id}/zoning-sources"),
Some(
serde_json::json!({ "source_id": source_id, "expected_sha256": expected_sha256 }),
),
),
Self::SearchCode { query, as_of } => (
Method::POST,
"/desktop/v1/code-sources/search".into(),
Some(serde_json::json!({"query":query,"as_of":as_of})),
),
Self::AttachCodeSource {
request_id,
source_id,
expected_sha256,
as_of,
} => (
Method::POST,
format!("/desktop/v1/requests/{request_id}/code-sources"),
Some(
serde_json::json!({"source_id":source_id,"expected_sha256":expected_sha256,"as_of":as_of}),
),
),
Self::SearchMeetings { query } => (
Method::POST,
"/desktop/v1/meetings-sources/search".into(),
Some(serde_json::json!({"query":query})),
),
Self::AttachMeetingsSource {
request_id,
source_id,
expected_sha256,
} => (
Method::POST,
format!("/desktop/v1/requests/{request_id}/meetings-sources"),
Some(serde_json::json!({"source_id":source_id,"expected_sha256":expected_sha256})),
),
Self::Export { .. } | Self::Suggest { .. } => {
return Err(DomainError::transport(false))
}
Self::State {} => (Method::GET, "/desktop/v1/records-state".into(), None),
Self::MarkNotification { notification_id } => (
Method::POST,
format!("/desktop/v1/notifications/{notification_id}/sent"),
None,
),
Self::AttachDocument {
request_id,
title,
source_path,
citation,
} => {
let file = stage(&source_path, &title, &citation)?;
(
Method::POST,
format!("/desktop/v1/requests/{request_id}/documents"),
Some(serde_json::json!({"title":title, "citation":citation, "file":file})),
)
}
Self::AttachReleaseCopy {
request_id,
attachment_id,
source_path,
status,
note,
added_by,
} => {
let file = stage(&source_path, "Records release copy", ¬e)?;
(
Method::POST,
format!(
"/desktop/v1/requests/{request_id}/documents/{attachment_id}/release-copy"
),
Some(
serde_json::json!({"status":status, "note":note, "added_by":added_by, "file":file}),
),
)
}
Self::View { request_id } => (
Method::GET,
format!("/desktop/v1/requests/{request_id}/view"),
None,
),
Self::Create { payload } => (
Method::POST,
"/desktop/v1/requests".into(),
Some(serde_json::json!(payload)),
),
Self::Apply {
request_id,
action,
payload,
} => (
Method::POST,
format!("/desktop/v1/requests/{request_id}/actions"),
Some(serde_json::json!({"action": action, "payload": payload})),
),
})
}
}
fn status_error(status: StatusCode, writing: bool) -> DomainError {
match status.as_u16() {
401 | 403 => DomainError::denied(),
400 | 422 => DomainError::new("invalid-input", "Records rejected the input. Review the required fields; no change was accepted.", false),
404 => DomainError::new("not-found", "The Records request or operation was not found. Refresh before continuing.", false),
409 => DomainError::new("conflict", "Records reported a conflict. Refresh before continuing.", false),
503 => DomainError::new("not-ready", "Records is not ready or migration is not activated. Refresh after service readiness is restored.", writing),
_ => DomainError::transport(writing),
}
}
pub async fn execute(request: RecordsRequest, proof: String) -> Result<Value, DomainError> {
let request = match request {
RecordsRequest::Suggest { request_id } => {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct DraftContext {
prompt: String,
previous_response_sha256: String,
}
let context = send(
Method::GET,
format!("/desktop/v1/requests/{request_id}/draft-context"),
None,
&proof,
)
.await?;
let context: DraftContext =
serde_json::from_value(context).map_err(|_| DomainError::transport(false))?;
let generated = tauri::async_runtime::spawn_blocking(move || crate::model::generate_local_text(&context.prompt))
.await.map_err(|_| DomainError::transport(false))?
.map_err(|_| DomainError::new("model-unavailable", "Local AI could not generate a draft. The existing text and approval were not changed.", false))?;
return send(Method::POST, format!("/desktop/v1/requests/{request_id}/generated-draft"),
Some(serde_json::json!({"text":generated.1, "model":generated.0, "previous_response_sha256":context.previous_response_sha256})), &proof).await;
}
RecordsRequest::Export { request_id, kind } => {
let plan = send(
Method::POST,
format!("/desktop/v1/requests/{request_id}/artifacts"),
Some(serde_json::json!({"kind":kind})),
&proof,
)
.await?;
let plan: crate::domain_files::ArtifactPlan =
serde_json::from_value(plan).map_err(|_| DomainError::transport(true))?;
let artifact_id = plan.artifact_id;
let path = tauri::async_runtime::spawn_blocking(move || crate::domain_files::write_export(plan))
.await.map_err(|_| DomainError::transport(true))?
.map_err(|_| DomainError::new("export-incomplete", "The Records export was not finalized. Prepared evidence is retained; refresh before retrying.", true))?;
return send(
Method::POST,
format!("/desktop/v1/requests/{request_id}/artifacts/{artifact_id}/complete"),
Some(serde_json::json!({"export_path":path})),
&proof,
)
.await;
}
other => other,
};
let (method, path, body) = tauri::async_runtime::spawn_blocking(move || request.wire())
.await
.map_err(|_| DomainError::transport(false))??;
send(method, path, body, &proof).await
}
pub(crate) async fn send(
method: Method,
path: String,
body: Option<Value>,
proof: &str,
) -> Result<Value, DomainError> {
send_with_header(method, path, body, proof, "X-Townlight-Session").await
}
async fn send_with_header(
method: Method,
path: String,
body: Option<Value>,
proof: &str,
proof_header: &'static str,
) -> Result<Value, DomainError> {
let writing = method == Method::POST;
// Do not forward the proof through a system proxy or a server redirect.
let client = Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.connect_timeout(Duration::from_secs(3))
.timeout(Duration::from_secs(60))
.build()
.map_err(|_| DomainError::transport(false))?;
let mut outgoing = client
.request(method, format!("{DOMAIN_ORIGIN}{path}"))
.header(proof_header, proof);
if let Some(body) = body {
outgoing = outgoing.json(&body);
}
let mut response = outgoing
.send()
.await
.map_err(|_| DomainError::transport(writing))?;
if !response.status().is_success() {
return Err(status_error(response.status(), writing));
}
// Error bodies are deliberately not echoed: framework errors can contain
// internal details. Successful responses must be bounded JSON.
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| DomainError::transport(writing))?
{
if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
return Err(DomainError::new("response-too-large", "Records returned more data than this client can safely load. No partial state was applied.", writing));
}
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).map_err(|_| {
DomainError::new(
"invalid-response",
"Records returned an unreadable result. Refresh before repeating a change.",
writing,
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires the paired disposable Python runtime on fixed loopback port 15480"]
fn native_records_roundtrip_with_real_runtime() {
let proof = std::env::var("TOWNLIGHT_TEST_SESSION_PROOF")
.expect("synthetic proof supplied by paired test");
let source = std::env::var("TOWNLIGHT_TEST_SOURCE_FILE")
.expect("synthetic source supplied by paired test");
tauri::async_runtime::block_on(async {
let denied = execute(RecordsRequest::State {}, "0".repeat(64)).await;
assert!(denied.is_err(), "invalid proof must fail");
let create: RecordsRequest = serde_json::from_value(serde_json::json!({
"operation":"create", "payload":{"requester":"Native transport fixture", "summary":"Synthetic transport acceptance", "deadline":"2026-10-15"}
})).unwrap();
let created = execute(create, proof.clone())
.await
.expect("real native create");
let id = created["id"].as_str().expect("canonical id");
let attach: RecordsRequest = serde_json::from_value(serde_json::json!({
"operation":"attach-document", "request_id":id, "title":"Synthetic document", "source_path":source, "citation":"Synthetic transport citation"
})).unwrap();
let attached = execute(attach, proof.clone())
.await
.expect("native copy and SQL attachment");
let stored = attached["request"]["documents"][0]["stored_path"]
.as_str()
.unwrap();
assert_eq!(
std::fs::read_to_string(stored).unwrap(),
"Synthetic source bytes"
);
for (action, payload) in [
(
"draft-records-response",
serde_json::json!({"responseDraft":"Synthetic native response", "citation":"Synthetic transport citation"}),
),
(
"approve-records-response",
serde_json::json!({"approvalNote":"Synthetic human review"}),
),
] {
let command = serde_json::from_value(serde_json::json!({"operation":"apply", "request_id":id, "action":action, "payload":payload})).unwrap();
execute(command, proof.clone())
.await
.expect("native lifecycle action");
}
let export = serde_json::from_value(
serde_json::json!({"operation":"export", "request_id":id, "kind":"response"}),
)
.unwrap();
let exported = execute(export, proof.clone())
.await
.expect("prepare/native write/finalize");
let path = exported["request"]["exports"][0].as_str().unwrap();
assert!(std::fs::read_to_string(path)
.unwrap()
.contains("Synthetic native response"));
assert!(std::path::Path::new(&format!("{path}.manifest.json")).is_file());
let view = execute(
RecordsRequest::View {
request_id: Uuid::parse_str(id).unwrap(),
},
proof,
)
.await
.expect("canonical native readback");
assert_eq!(view["exports"][0], path);
assert_eq!(view["documents"][0]["stored_path"], stored);
});
}
#[test]
fn routes_are_fixed_and_ids_are_not_paths() {
let request: RecordsRequest = serde_json::from_value(serde_json::json!({
"operation": "view", "request_id": "a7abf8da-7c0b-47a5-b4ab-4fc1b33aab78"
}))
.unwrap();
let (method, path, body) = request.wire().unwrap();
assert_eq!(method, Method::GET);
assert_eq!(
path,
"/desktop/v1/requests/a7abf8da-7c0b-47a5-b4ab-4fc1b33aab78/view"
);
assert!(body.is_none());
for bad in [
serde_json::json!({"operation":"view", "request_id":"../admin"}),
serde_json::json!({"operation":"state", "url":"https://example.invalid"}),
serde_json::json!({"operation":"create", "payload":{"requester":"x", "summary":"x", "deadline":"2026-10-15", "actor":"admin"}}),
serde_json::json!({"operation":"apply", "request_id":"a7abf8da-7c0b-47a5-b4ab-4fc1b33aab78", "action":"create-meeting", "payload":{}}),
] {
assert!(serde_json::from_value::<RecordsRequest>(bad).is_err());
}
}
#[test]
fn failures_do_not_encourage_duplicate_mutations() {
assert!(DomainError::transport(true).outcome_unknown);
assert!(!DomainError::transport(false).outcome_unknown);
assert!(status_error(StatusCode::INTERNAL_SERVER_ERROR, true).outcome_unknown);
assert!(!status_error(StatusCode::UNAUTHORIZED, true).outcome_unknown);
assert!(!status_error(StatusCode::UNPROCESSABLE_ENTITY, true).outcome_unknown);
}
}inspections_client.rs
//! Fixed native transport. No inspection rules or caller-supplied URLs/paths.
use crate::domain_client::{send, DomainError};
use reqwest::Method;
use serde::Deserialize;
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum InspectionsRequest {
RepeatCases { payload: Value },
DraftNotice { payload: Value },
Cases { offset: u32 },
Case { id: Uuid },
CreateCase { payload: Value },
ReviseCase { id: Uuid, payload: Value },
History { id: Uuid },
CreatePacket { id: Uuid, payload: Value },
ExportPacket { id: Uuid, expected_sha256: String },
SearchConnections { payload: Value },
SupportedTypes {},
RetainEvidence { payload: Value },
AnalyseMedia { payload: Value },
AttachRecords { request_id: Uuid, payload: Value },
}
pub fn route(request: InspectionsRequest) -> (Method, String, Option<Value>) {
use InspectionsRequest::*;
let (method, path, body) = match request {
RepeatCases { payload } => (Method::POST, "/repeat-cases".into(), Some(payload)),
DraftNotice { payload } => (Method::POST, "/notice-draft".into(), Some(payload)),
Cases { offset } => (Method::GET, format!("/cases?offset={offset}"), None),
Case { id } => (Method::GET, format!("/cases/{id}"), None),
CreateCase { payload } => (Method::POST, "/cases".into(), Some(payload)),
ReviseCase { id, payload } => (
Method::POST,
format!("/cases/{id}/revisions"),
Some(payload),
),
History { id } => (Method::GET, format!("/cases/{id}/history"), None),
CreatePacket { id, payload } => {
(Method::POST, format!("/cases/{id}/packets"), Some(payload))
}
ExportPacket { id, .. } => (Method::GET, format!("/packets/{id}"), None),
SearchConnections { payload } => {
(Method::POST, "/connections/search".into(), Some(payload))
}
SupportedTypes {} => (Method::GET, "/evidence/supported-types".into(), None),
RetainEvidence { payload } => (Method::POST, "/evidence/retain".into(), Some(payload)),
AnalyseMedia { payload } => (Method::POST, "/media/analyse".into(), Some(payload)),
AttachRecords {
request_id,
payload,
} => {
return (
Method::POST,
format!("/desktop/v1/requests/{request_id}/inspections-sources"),
Some(payload),
)
}
};
(method, format!("/desktop/v1/inspections{path}"), body)
}
pub async fn execute(request: InspectionsRequest, proof: String) -> Result<Value, DomainError> {
let export = match &request {
InspectionsRequest::ExportPacket {
id,
expected_sha256,
} => Some((*id, expected_sha256.clone())),
_ => None,
};
let (method, path, body) = route(request);
let result = send(method, path, body, &proof).await?;
if let Some((id, expected)) = export {
let content = result
.get("text")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
let digest = result
.get("sha256")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
if digest != expected {
return Err(DomainError::denied());
}
let path = crate::domain_files::write_inspections_file(
&format!("inspections-{id}-{}.json", Uuid::new_v4()),
content.as_bytes(),
digest,
)
.map_err(|_| DomainError::denied())?;
return Ok(serde_json::json!({"path": path, "sha256": digest}));
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inspections_transport_is_fixed_and_does_not_accept_a_url_or_unknown_operation() {
assert!(serde_json::from_value::<InspectionsRequest>(
serde_json::json!({"operation":"approve-permit"})
)
.is_err());
assert!(serde_json::from_value::<InspectionsRequest>(
serde_json::json!({"operation":"cases","offset":0,"url":"https://example.com"})
)
.is_err());
let (_, path, _) = route(InspectionsRequest::Cases { offset: 10 });
assert_eq!(path, "/desktop/v1/inspections/cases?offset=10");
}
}main.rs
mod atomic_io;
mod auth;
mod code_client;
mod demo_fixture;
mod domain_client;
mod domain_files;
mod first_run;
mod inspections_client;
mod local_paths;
mod local_shell;
mod model;
mod module_registry;
mod permitting_client;
mod planning_client;
mod profile_gate;
mod records_authority;
mod supervisor;
mod workflows;
mod zoning_client;
use auth::{AccessState, AuthActionResult, LocalUserSummary};
use first_run::{FirstRunActionResult, FirstRunState, SavedCityProfile};
use model::{ModelActionResult, ModelState};
use module_registry::{ModuleProfileSummary, ModuleSelectionState, ModuleSummary};
use serde::Serialize;
use serde_json::Value;
use std::panic::{catch_unwind, AssertUnwindSafe};
use supervisor::{RuntimeHealthItem, SupervisorActionResult};
use workflows::{CityWorkActionResult, CityWorkState};
const MODULES_JSON: &str = include_str!("../../../installer/modules.json");
#[derive(Serialize)]
struct NavigationItem {
id: &'static str,
label: &'static str,
description: &'static str,
}
#[derive(Serialize)]
struct AppState {
domain_authority: &'static str,
product_name: &'static str,
status_label: &'static str,
local_only: bool,
navigation: Vec<NavigationItem>,
modules: Vec<ModuleSummary>,
module_profiles: Vec<ModuleProfileSummary>,
module_selection: ModuleSelectionState,
installer_steps: Vec<&'static str>,
first_run: FirstRunState,
city_profile: Option<SavedCityProfile>,
users: Vec<LocalUserSummary>,
access: AccessState,
model: ModelState,
health: Vec<RuntimeHealthItem>,
city_work: CityWorkState,
}
#[derive(Serialize)]
struct ModuleActionResult {
accepted: bool,
action: String,
status: &'static str,
message: String,
next_action: &'static str,
selection: ModuleSelectionState,
modules: Vec<ModuleSummary>,
}
fn navigation() -> Vec<NavigationItem> {
vec![
NavigationItem {
id: "home",
label: "Home",
description: "Work that needs attention",
},
NavigationItem {
id: "meetings",
label: "Meetings & Notices",
description: "Agendas, notices, minutes, votes",
},
NavigationItem {
id: "records",
label: "Records Requests",
description: "Intake, review, response, exports",
},
NavigationItem {
id: "code",
label: "Code & Ordinances",
description: "Search, imports, guidance, handoffs",
},
NavigationItem {
id: "notice",
label: "Public Notices",
description: "Deadlines, proof, archive packets",
},
NavigationItem {
id: "zoning".into(),
label: "Zoning".into(),
description: "Parcel information and cited rules".into(),
},
NavigationItem {
id: "access",
label: "Accessibility",
description: "WCAG review, plain language, multilingual, exports",
},
NavigationItem {
id: "permitting",
label: "Permitting",
description: "Pre-application intake and reviewed workpapers",
},
NavigationItem {
id: "inspections",
label: "Inspections",
description: "Reviewed inspection workpapers and Records copies",
},
NavigationItem {
id: "planning",
label: "Planning",
description: "Adopted policy sources and reviewed staff analysis",
},
NavigationItem {
id: "search",
label: "Search City Knowledge",
description: "Cross-module local search with citations",
},
NavigationItem {
id: "health",
label: "System Health",
description: "Local services, model, backup, repair",
},
NavigationItem {
id: "settings",
label: "Settings",
description: "City profile, users, modules",
},
]
}
fn installer_steps() -> Vec<&'static str> {
vec![
"Choose install and local data locations.",
"Install Townlight Core and the selected Townlight product profile.",
"Create city profile and first admin user.",
"Download and verify Gemma 4 12B quantization-aware weights.",
"Verify local health, backup, repair, and uninstall entry points.",
]
}
fn module_summaries() -> Result<Vec<ModuleSummary>, String> {
let _registry_source = MODULES_JSON;
module_registry::module_summaries()
}
fn access_is_local_admin(access: &AccessState) -> bool {
access.signed_in && access.role.as_deref() == Some("local-admin")
}
fn access_is_signed_in(access: &AccessState) -> bool {
access.signed_in
}
fn require_role_for_city_work(
access: &AccessState,
action: &str,
payload: Option<&Value>,
) -> Result<(), String> {
let Some(role) = access.role.as_deref() else {
return Err(
"Sign in with a staff or Townlight admin account before changing city work."
.to_string(),
);
};
let Some((module_ids, allow_any)) = city_work_action_module_requirement(action, payload) else {
return Ok(());
};
if auth::role_allows_modules(role, &module_ids, allow_any) {
return Ok(());
}
Err(format!(
"Your local role ({role}) is not allowed to use this module workflow. Ask a Townlight admin to adjust your account."
))
}
fn public_model_state(mut model: ModelState) -> ModelState {
model.artifact.local_path =
"Sign in as Townlight admin to view the model file path.".to_string();
model
}
fn public_runtime_health(mut health: Vec<RuntimeHealthItem>) -> Vec<RuntimeHealthItem> {
for item in &mut health {
item.admin_detail.clear();
}
health
}
fn city_work_action_module_requirement(
action: &str,
payload: Option<&Value>,
) -> Option<(Vec<&'static str>, bool)> {
match action {
"create-meeting-body"
| "add-meeting-member"
| "create-meeting"
| "add-agenda-item"
| "submit-agenda-intake"
| "review-agenda-intake"
| "promote-agenda-intake"
| "record-staff-report"
| "add-meeting-attachment"
| "finalize-meeting-packet"
| "add-code-handoff-agenda"
| "calculate-notice-deadline"
| "complete-notice-checklist"
| "post-notice"
| "record-minutes"
| "record-motion"
| "add-minute-citation"
| "suggest-minutes-draft"
| "record-vote"
| "record-member-vote"
| "record-meeting-attendance"
| "record-quorum-check"
| "add-action-item"
| "record-resident-comment"
| "submit-public-comment"
| "review-public-comment"
| "redact-public-comment"
| "adopt-minutes"
| "sign-minutes"
| "record-closed-session"
| "export-meeting-packet"
| "archive-meeting" => Some((vec!["townlight_meetings"], false)),
"townlight_notice-calculate-deadline"
| "townlight_notice-complete-checklist"
| "townlight_notice-post-notice"
| "townlight_notice-export-archive-packet" => Some((vec!["townlight_meetings", "townlight_notice"], false)),
"record-adopted-legislation" => Some((vec!["townlight_meetings", "townlight_code"], false)),
"create-records-request"
| "load-demo-town"
| "submit-public-records-request"
| "lookup-public-records-request"
| "add-public-records-message"
| "set-records-deadline"
| "calculate-records-deadline"
| "request-records-clarification"
| "add-records-message"
| "assign-records-request"
| "record-records-search"
| "record-records-search-session"
| "add-records-document"
| "add-records-release-copy"
| "add-records-exemption-review"
| "add-records-exemption-decision"
| "estimate-records-fee"
| "add-records-fee-line"
| "waive-records-fee"
| "suggest-records-response"
| "draft-records-response"
| "approve-records-response"
| "build-records-release-package"
| "export-records-response"
| "fulfill-records-request"
| "close-records-request"
| "mark-notification-sent" => Some((vec!["townlight_records"], false)),
"import-code-source"
| "record-codifier-sync"
| "record-codifier-sync-failure"
| "retry-codifier-sync"
| "mark-code-stale"
| "suggest-code-guidance"
| "draft-code-guidance"
| "approve-code-guidance"
| "publish-code-source"
| "unpublish-code-source"
| "create-code-handoff"
| "answer-code-question" => Some((vec!["townlight_code"], false)),
"search-city-knowledge" => Some((
vec![
"townlight_meetings",
"townlight_records",
"townlight_code",
"townlight_notice",
"townlight_access",
],
true,
)),
"open-exports-folder" => match payload
.and_then(|value| value.get("folder"))
.and_then(Value::as_str)
{
Some("meetings") => Some((vec!["townlight_meetings"], false)),
Some("records") => Some((vec!["townlight_records"], false)),
Some("code") => Some((vec!["townlight_code"], false)),
Some("notice") => Some((vec!["townlight_notice"], false)),
Some("access") => Some((vec!["townlight_access"], false)),
Some("planning") => Some((vec!["townlight_planning"], false)),
Some("permitting") => Some((vec!["townlight_permitting"], false)),
Some("inspections") => Some((vec!["townlight_inspections"], false)),
_ => Some((
vec!["townlight_meetings", "townlight_records", "townlight_code", "townlight_notice"],
true,
)),
},
"accessibility-review"
| "townlight_access-records-export"
| "townlight_access-delete-review"
| "townlight_access-plain-language"
| "townlight_access-language-variant"
| "townlight_access-form-plan"
| "townlight_access-publishing-workflow"
| "townlight_access-ada-title-ii"
| "townlight_access-tagged-pdf" => Some((vec!["townlight_access"], false)),
_ => None,
}
}
fn module_display_name(module_id: &str) -> String {
module_summaries()
.ok()
.and_then(|modules| {
modules
.into_iter()
.find(|module| module.id == module_id)
.map(|module| module.display_name)
})
.unwrap_or_else(|| module_id.to_string())
}
fn module_exports_folder(module_id: &str) -> Option<(&'static str, &'static str)> {
match module_id {
"townlight_meetings" => Some(("meetings", "meeting exports")),
"townlight_records" => Some(("records", "records exports")),
"townlight_code" => Some(("code", "code exports")),
"townlight_notice" => Some(("notice", "notice exports")),
"townlight_access" => Some(("access", "accessibility/records exports")),
_ => None,
}
}
fn require_enabled_city_modules(action: &str, payload: Option<&Value>) -> Result<(), String> {
let Some((module_ids, allow_any)) = city_work_action_module_requirement(action, payload) else {
return Ok(());
};
let selection = module_registry::module_selection_state()?;
let enabled = module_ids
.iter()
.filter(|module_id| {
selection
.enabled_module_ids
.iter()
.any(|enabled_id| enabled_id == **module_id)
})
.copied()
.collect::<Vec<_>>();
if (allow_any && !enabled.is_empty()) || (!allow_any && enabled.len() == module_ids.len()) {
return Ok(());
}
let missing = module_ids
.iter()
.filter(|module_id| !enabled.iter().any(|enabled_id| enabled_id == *module_id))
.map(|module_id| module_display_name(module_id))
.collect::<Vec<_>>();
Err(format!(
"{} {} not enabled in this local profile. Install or enable {} in Settings before using this workflow.",
missing.join(", "),
if missing.len() == 1 { "is" } else { "are" },
if missing.len() == 1 { "it" } else { "them" }
))
}
fn filter_search_results_for_enabled_modules(
result: &mut CityWorkActionResult,
) -> Result<(), String> {
let selection = module_registry::module_selection_state()?;
result.search_results.retain(|search_result| {
selection
.enabled_module_ids
.iter()
.any(|module_id| module_id == &search_result.module_id)
});
Ok(())
}
fn filter_search_results_for_access(result: &mut CityWorkActionResult, access: &AccessState) {
let Some(role) = access.role.as_deref() else {
return;
};
result
.search_results
.retain(|search_result| auth::role_allows_module(role, &search_result.module_id));
}
fn first_run_action_requires_admin_after_setup(action: &str) -> bool {
matches!(
action,
"choose-location"
| "select-modules"
| "download-model"
| "defer-model"
| "create-city-profile"
| "create-admin"
| "choose-backup"
| "verify-health"
| "open-app"
| "repair"
| "backup"
| "uninstall"
)
}
fn app_state() -> Result<AppState, String> {
let access = auth::access_state()?;
let admin_signed_in = access_is_local_admin(&access);
let signed_in = access_is_signed_in(&access);
let authority = records_authority::read()?;
let domain_authority = match authority.as_ref().map(|journal| journal.phase.as_str()) {
Some("active") => "python-records",
Some(_) => "records-migration-pending",
None => "legacy",
};
let city_work = if authority.is_some() {
// The typed async client loads canonical state; never read old JSON here.
CityWorkState::default()
} else if signed_in {
workflows::city_work_state()?
} else {
workflows::public_city_work_state()?
};
let users = if admin_signed_in {
auth::saved_users()?
} else {
Vec::new()
};
let model = if admin_signed_in {
model::model_state()?
} else {
public_model_state(model::model_state()?)
};
let health = if admin_signed_in {
supervisor::runtime_health()?
} else {
public_runtime_health(supervisor::runtime_health()?)
};
Ok(AppState {
domain_authority,
product_name: "Townlight",
status_label: "Windows Local 1.0 desktop",
local_only: true,
navigation: navigation(),
modules: module_summaries()?,
module_profiles: module_registry::module_profiles()?,
module_selection: module_registry::module_selection_state()?,
installer_steps: installer_steps(),
first_run: first_run::first_run_state(&[])?,
city_profile: first_run::saved_city_profile()?,
users,
access,
model,
health,
city_work,
})
}
#[tauri::command]
async fn get_app_state() -> Result<AppState, String> {
tauri::async_runtime::spawn_blocking(move || {
catch_unwind(AssertUnwindSafe(app_state)).map_err(|_| {
"App state stopped unexpectedly. Restart Townlight and retry; if this repeats, use Repair from System Health."
.to_string()
})?
})
.await
.map_err(|error| format!("App state could not load: {error}"))?
}
#[tauri::command]
fn get_model_state() -> Result<ModelState, String> {
let access = auth::access_state()?;
let model = model::model_state()?;
if access_is_local_admin(&access) {
Ok(model)
} else {
Ok(public_model_state(model))
}
}
fn model_action_authorized(action: String) -> Result<ModelActionResult, String> {
let access = auth::access_state()?;
if !access_is_local_admin(&access) {
if !access.configured {
return Err(
"Create the first Townlight admin and sign in before changing local model setup."
.to_string(),
);
}
return Err(
"Sign in as the Townlight admin before changing local model setup.".to_string(),
);
}
model::model_action(&action)
}
#[tauri::command]
async fn model_action(action: String) -> Result<ModelActionResult, String> {
tauri::async_runtime::spawn_blocking(move || {
catch_unwind(AssertUnwindSafe(|| model_action_authorized(action)))
.map_err(|_| "Model setup action stopped unexpectedly. Restart Townlight and retry; if this repeats, use Repair from System Health.".to_string())?
})
.await
.map_err(|error| format!("Model setup action could not complete: {error}"))?
}
#[tauri::command]
fn preview_first_run_state(completed_step_ids: Vec<String>) -> Result<FirstRunState, String> {
first_run::first_run_state(&completed_step_ids)
}
fn first_run_action_authorized(
action: String,
step_id: Option<String>,
payload: Option<Value>,
) -> Result<FirstRunActionResult, String> {
let access = auth::access_state()?;
if access.configured
&& !access_is_local_admin(&access)
&& first_run_action_requires_admin_after_setup(&action)
{
return Err(
"Sign in as the Townlight admin before changing setup, profile, model, backup, or runtime settings."
.to_string(),
);
}
first_run::first_run_action(&action, step_id.as_deref(), payload.as_ref())
}
#[tauri::command]
async fn first_run_action(
action: String,
step_id: Option<String>,
payload: Option<Value>,
) -> Result<FirstRunActionResult, String> {
tauri::async_runtime::spawn_blocking(move || {
catch_unwind(AssertUnwindSafe(|| {
first_run_action_authorized(action, step_id, payload)
}))
.map_err(|_| {
"First-run action stopped unexpectedly. Restart Townlight and retry; if this repeats, use Repair from System Health."
.to_string()
})?
})
.await
.map_err(|error| format!("First-run action could not complete: {error}"))?
}
#[tauri::command]
fn auth_action(action: String, payload: Option<Value>) -> Result<AuthActionResult, String> {
auth::auth_action(&action, payload.as_ref())
}
fn supervisor_action_authorized(
action: String,
service_id: Option<String>,
) -> Result<SupervisorActionResult, String> {
if action != "health" {
auth::require_admin_session()?;
}
supervisor::supervisor_action(&action, service_id.as_deref())
}
#[tauri::command]
async fn supervisor_action(
action: String,
service_id: Option<String>,
) -> Result<SupervisorActionResult, String> {
tauri::async_runtime::spawn_blocking(move || {
catch_unwind(AssertUnwindSafe(|| {
supervisor_action_authorized(action, service_id)
}))
.map_err(|_| {
"System Health action stopped unexpectedly. Restart Townlight and retry; if this repeats, use Repair from System Health."
.to_string()
})?
})
.await
.map_err(|error| format!("System Health action could not complete: {error}"))?
}
fn picked_file_path_for_desktop() -> Result<Option<String>, String> {
if let Ok(path) = std::env::var("TOWNLIGHT_TEST_FILE_PICKER_PATH") {
let trimmed = path.trim();
return Ok(if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
});
}
#[cfg(target_os = "windows")]
{
Ok(rfd::FileDialog::new()
.set_title("Choose Townlight evidence file")
.pick_file()
.map(|path| path.display().to_string()))
}
#[cfg(not(target_os = "windows"))]
{
Err("Native file selection is only available in the Windows desktop app.".to_string())
}
}
fn picked_folder_path_for_desktop() -> Result<Option<String>, String> {
if let Ok(path) = std::env::var("TOWNLIGHT_TEST_FOLDER_PICKER_PATH") {
let trimmed = path.trim();
return Ok(if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
});
}
#[cfg(target_os = "windows")]
{
Ok(rfd::FileDialog::new()
.set_title("Choose Townlight folder")
.pick_folder()
.map(|path| path.display().to_string()))
}
#[cfg(not(target_os = "windows"))]
{
Err("Native folder selection is only available in the Windows desktop app.".to_string())
}
}
#[tauri::command]
fn choose_file_path() -> Result<Option<String>, String> {
auth::require_signed_in_session()?;
picked_file_path_for_desktop()
}
#[tauri::command]
fn choose_folder_path() -> Result<Option<String>, String> {
let access = auth::access_state()?;
if access.configured && !access_is_local_admin(&access) {
return Err("Sign in as the Townlight admin before choosing folders.".to_string());
}
picked_folder_path_for_desktop()
}
#[tauri::command]
fn module_action(action: String, module_id: String) -> Result<ModuleActionResult, String> {
let access = auth::access_state()?;
if access.configured && !access_is_local_admin(&access) {
return Err(
"Sign in as the Townlight admin before changing installed modules.".to_string(),
);
}
let previous_selection = module_registry::module_selection_state()?;
let was_installed = previous_selection
.installed_module_ids
.iter()
.any(|installed_id| installed_id == &module_id);
let display_name = module_display_name(&module_id);
let mut removal_backup_message = None;
let selection = match action.as_str() {
"enable-module" => module_registry::set_module_enabled(&module_id, true)?,
"disable-module" => module_registry::set_module_enabled(&module_id, false)?,
"install-module" => module_registry::set_module_installed(&module_id, true)?,
"remove-module" => {
if was_installed {
let backup = supervisor::supervisor_action("backup", None)?;
removal_backup_message = Some(backup.message);
module_registry::set_module_installed(&module_id, false)?
} else {
previous_selection
}
}
"update-module" => previous_selection,
"open-module-exports" => {
if !was_installed {
return Err(format!(
"{display_name} is not installed in this local profile. Install it before opening its exports folder."
));
}
let Some((folder, _label)) = module_exports_folder(&module_id) else {
return Err(format!(
"{display_name} does not have a module exports folder."
));
};
let path = local_paths::data_root().join("exports").join(folder);
local_shell::open_local_folder(&path)?;
previous_selection
}
_ => return Err(format!("Unsupported module action: {action}")),
};
let modules = module_summaries()?;
let is_installed = selection
.installed_module_ids
.iter()
.any(|installed_id| installed_id == &module_id);
let is_enabled = selection
.enabled_module_ids
.iter()
.any(|enabled_id| enabled_id == &module_id);
let (status, message, next_action) = match action.as_str() {
"enable-module" => (
"Module enabled",
format!("{display_name} is enabled in the local Townlight shell."),
"Review the module list or continue city work.",
),
"disable-module" => (
"Module disabled",
format!("{display_name} is disabled in the local Townlight shell. Its data remains installed and can be re-enabled."),
"Review the module list or continue city work.",
),
"install-module" if was_installed => (
"Module already installed",
format!("{display_name} is already installed in this local profile."),
"Enable the module if it is disabled, or continue city work.",
),
"install-module" => (
"Module installed",
format!("{display_name} is installed and enabled in this local profile."),
"Review the module list or open the module work area.",
),
"remove-module" if !was_installed => (
"Module not installed",
format!("{display_name} is not installed in this local profile."),
"Choose an available module to install.",
),
"remove-module" => (
"Module removed",
format!(
"{display_name} is removed from the active local profile after a verified profile backup. Existing module data was not deleted and remains covered by profile backup/restore. {}",
removal_backup_message
.as_deref()
.unwrap_or("The profile backup completed before removal.")
),
"Install the module again if staff need this work area.",
),
"update-module" => (
"Module current",
if is_installed {
format!("{display_name} is already on the pinned version for this module manifest.")
} else {
format!("{display_name} is not installed; install it before checking updates.")
},
"Review module versions in Settings.",
),
"open-module-exports" => {
let (folder, label) =
module_exports_folder(&module_id).expect("module export folder already checked");
let path = local_paths::data_root().join("exports").join(folder);
(
"Module exports opened",
format!(
"Opened the local {label} folder for {display_name}: {}",
path.display()
),
"Use generated export files for clerk review, public records response, or local backup evidence.",
)
}
_ => unreachable!("unsupported module action already returned"),
};
Ok(ModuleActionResult {
accepted: true,
action,
status,
message,
next_action: if is_enabled || !matches!(status, "Module installed") {
next_action
} else {
"Enable required dependencies before opening this module."
},
selection,
modules,
})
}
#[tauri::command]
fn get_city_work_state() -> Result<CityWorkState, String> {
let access = auth::access_state()?;
if access_is_signed_in(&access) {
workflows::city_work_state()
} else {
workflows::public_city_work_state()
}
}
/// Typed domain transport remains inactive until coherent profile activation.
/// Retired JSON municipal actions cannot substitute for that activation.
#[tauri::command]
async fn records_domain_request(
request: domain_client::RecordsRequest,
) -> Result<Value, domain_client::DomainError> {
let _operation =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
let access =
auth::require_signed_in_session().map_err(|_| domain_client::DomainError::denied())?;
require_role_for_city_work(&access, "create-records-request", None)
.map_err(|_| domain_client::DomainError::denied())?;
require_enabled_city_modules("create-records-request", None)
.map_err(|_| domain_client::DomainError::denied())?;
let proof = auth::domain_session_proof().map_err(|_| domain_client::DomainError::denied())?;
domain_client::execute(request, proof).await
}
#[tauri::command]
async fn records_public_request(
request: domain_client::PublicRecordsRequest,
) -> Result<Value, domain_client::DomainError> {
let _operation =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
require_enabled_city_modules("submit-public-records-request", None)
.map_err(|_| domain_client::DomainError::denied())?;
let proof = auth::domain_public_proof().map_err(|_| domain_client::DomainError::denied())?;
domain_client::execute_public(request, proof).await
}
#[tauri::command]
async fn access_domain_request(
request: domain_client::AccessRequest,
) -> Result<Value, domain_client::DomainError> {
let _operation =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
let access =
auth::require_signed_in_session().map_err(|_| domain_client::DomainError::denied())?;
require_role_for_city_work(&access, "accessibility-review", None)
.map_err(|_| domain_client::DomainError::denied())?;
require_enabled_city_modules("accessibility-review", None)
.map_err(|_| domain_client::DomainError::denied())?;
let proof = auth::domain_session_proof().map_err(|_| domain_client::DomainError::denied())?;
domain_client::execute_access(request, proof).await
}
#[tauri::command]
async fn suite_domain_request(
request: domain_client::SuiteRequest,
) -> Result<Value, domain_client::DomainError> {
let _operation =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
let access =
auth::require_signed_in_session().map_err(|_| domain_client::DomainError::denied())?;
require_role_for_city_work(&access, "search-city-knowledge", None)
.map_err(|_| domain_client::DomainError::denied())?;
require_enabled_city_modules("search-city-knowledge", None)
.map_err(|_| domain_client::DomainError::denied())?;
let proof = auth::domain_session_proof().map_err(|_| domain_client::DomainError::denied())?;
domain_client::execute_suite(request, proof).await
}
#[tauri::command]
async fn meetings_domain_request(
request: domain_client::MeetingsRequest,
) -> Result<Value, domain_client::DomainError> {
let _operation =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
let profile_id = module_registry::module_selection_state()
.map_err(|_| domain_client::DomainError::denied())?
.profile_id;
if !matches!(
profile_id.as_str(),
"meetings-beta"
| "city-core"
| "zoning-beta"
| "planning-preview"
| "permitting-preview"
| "inspections-preview"
) {
return Err(domain_client::DomainError::denied());
}
let access =
auth::require_signed_in_session().map_err(|_| domain_client::DomainError::denied())?;
require_role_for_city_work(&access, "create-meeting", None)
.map_err(|_| domain_client::DomainError::denied())?;
require_enabled_city_modules("create-meeting", None)
.map_err(|_| domain_client::DomainError::denied())?;
let proof = auth::domain_session_proof().map_err(|_| domain_client::DomainError::denied())?;
domain_client::execute_meetings(request, proof).await
}
#[tauri::command]
async fn code_domain_request(
request: code_client::CodeRequest,
) -> Result<Value, domain_client::DomainError> {
let _operation =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
let profile_id = module_registry::module_selection_state()
.map_err(|_| domain_client::DomainError::denied())?
.profile_id;
if !matches!(
profile_id.as_str(),
"city-core"
| "zoning-beta"
| "planning-preview"
| "permitting-preview"
| "inspections-preview"
) {
return Err(domain_client::DomainError::denied());
}
let access =
auth::require_signed_in_session().map_err(|_| domain_client::DomainError::denied())?;
require_role_for_city_work(&access, "import-code-source", None)
.map_err(|_| domain_client::DomainError::denied())?;
require_enabled_city_modules("import-code-source", None)
.map_err(|_| domain_client::DomainError::denied())?;
let proof = auth::domain_session_proof().map_err(|_| domain_client::DomainError::denied())?;
code_client::execute(request, proof).await
}
#[tauri::command]
async fn open_domain_exports(request: domain_files::OpenExports) -> Result<Value, String> {
let access = auth::require_signed_in_session()?;
let payload = serde_json::json!({"folder":request.folder.name()});
require_role_for_city_work(&access, "open-exports-folder", Some(&payload))?;
require_enabled_city_modules("open-exports-folder", Some(&payload))?;
let message =
tauri::async_runtime::spawn_blocking(move || domain_files::open_exports(request.folder))
.await
.map_err(|_| "Could not open the local exports folder.".to_string())??;
Ok(serde_json::json!({"message":message}))
}
#[tauri::command]
async fn zoning_domain_request(
request: zoning_client::ZoningRequest,
) -> Result<Value, domain_client::DomainError> {
let _guard =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
let selection = module_registry::module_selection_state()
.map_err(|_| domain_client::DomainError::denied())?;
if !selection
.enabled_module_ids
.iter()
.any(|id| id == "townlight_zoning")
{
return Err(domain_client::DomainError::denied());
}
let session =
auth::require_signed_in_session().map_err(|_| domain_client::DomainError::denied())?;
let role = session.role.as_deref();
if role != Some("local-admin") && role != Some("city-staff") {
return Err(domain_client::DomainError::denied());
}
let proof = auth::domain_session_proof().map_err(|_| domain_client::DomainError::denied())?;
zoning_client::execute(request, proof, false).await
}
#[tauri::command]
async fn zoning_public_request(
request: zoning_client::ZoningRequest,
) -> Result<Value, domain_client::DomainError> {
let _guard =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
let selection = module_registry::module_selection_state()
.map_err(|_| domain_client::DomainError::denied())?;
if !selection
.enabled_module_ids
.iter()
.any(|id| id == "townlight_zoning")
{
return Err(domain_client::DomainError::denied());
}
zoning_client::execute(request, String::new(), true).await
}
fn planning_capability(staff: bool) -> Result<String, domain_client::DomainError> {
let selection = module_registry::module_selection_state()
.map_err(|_| domain_client::DomainError::denied())?;
if !matches!(
selection.profile_id.as_str(),
"planning-preview" | "permitting-preview" | "inspections-preview"
) || !selection
.enabled_module_ids
.iter()
.any(|id| id == "townlight_planning")
{
return Err(domain_client::DomainError::denied());
}
if !staff {
return Ok(String::new());
}
let session =
auth::require_signed_in_session().map_err(|_| domain_client::DomainError::denied())?;
if !matches!(
session.role.as_deref(),
Some("local-admin") | Some("city-staff")
) {
return Err(domain_client::DomainError::denied());
}
auth::domain_session_proof().map_err(|_| domain_client::DomainError::denied())
}
#[tauri::command]
async fn planning_domain_request(
request: planning_client::PlanningRequest,
) -> Result<Value, domain_client::DomainError> {
let _guard =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
planning_client::execute(request, planning_capability(true)?, false).await
}
fn permitting_capability() -> Result<String, domain_client::DomainError> {
let selection = module_registry::module_selection_state()
.map_err(|_| domain_client::DomainError::denied())?;
if !matches!(
selection.profile_id.as_str(),
"permitting-preview" | "inspections-preview"
) || !selection
.enabled_module_ids
.iter()
.any(|id| id == "townlight_permitting")
{
return Err(domain_client::DomainError::denied());
}
planning_capability(true)
}
#[tauri::command]
async fn permitting_domain_request(
request: permitting_client::PermittingRequest,
) -> Result<Value, domain_client::DomainError> {
let _guard =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
permitting_client::execute(request, permitting_capability()?).await
}
fn inspections_capability() -> Result<String, domain_client::DomainError> {
let selection = module_registry::module_selection_state()
.map_err(|_| domain_client::DomainError::denied())?;
if !matches!(selection.profile_id.as_str(), "inspections-preview")
|| !selection
.enabled_module_ids
.iter()
.any(|id| id == "townlight_inspections")
{
return Err(domain_client::DomainError::denied());
}
planning_capability(true)
}
#[tauri::command]
async fn inspections_domain_request(
request: inspections_client::InspectionsRequest,
) -> Result<Value, domain_client::DomainError> {
let _guard =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
inspections_client::execute(request, inspections_capability()?).await
}
#[tauri::command]
async fn permitting_pick_bundle() -> Result<Value, String> {
permitting_capability().map_err(|_| "Sign in with an authorized Permitting account.")?;
planning_pick_bundle().await
}
#[tauri::command]
async fn planning_public_request(
request: planning_client::PlanningRequest,
) -> Result<Value, domain_client::DomainError> {
let _guard =
profile_gate::operation().map_err(|_| domain_client::DomainError::maintenance())?;
planning_client::execute(request, planning_capability(false)?, true).await
}
#[tauri::command]
async fn planning_pick_bundle() -> Result<Value, String> {
let _guard = profile_gate::operation().map_err(|_| "Profile maintenance in progress.")?;
planning_capability(true).map_err(|_| "Sign in with an authorized Planning account.")?;
tauri::async_runtime::spawn_blocking(|| {
#[cfg(windows)]
{
use std::io::Read;
let Some(path) = picked_file_path_for_desktop()? else {
return Ok(Value::Null);
};
let mut file =
std::fs::File::open(path).map_err(|_| "Could not read the selected bundle.")?;
let mut bytes = Vec::new();
file.by_ref()
.take(36_000_001)
.read_to_end(&mut bytes)
.map_err(|_| "Could not read the bundle.")?;
if bytes.len() > 36_000_000 {
return Err("Bundle exceeds the local import limit.".into());
}
serde_json::from_slice(&bytes)
.map_err(|_| "Choose a valid Planning JSON source bundle.".into())
}
#[cfg(not(windows))]
{
Err("Planning file picker requires Windows.".into())
}
})
.await
.map_err(|_| "Could not complete the source picker.".to_string())?
}
fn city_work_action_authorized(
action: String,
payload: Option<Value>,
) -> Result<CityWorkActionResult, String> {
let mut payload = payload;
let access = auth::access_state()?;
let signed_in = access_is_signed_in(&access);
let public_action = workflows::city_work_action_allows_public(&action);
if action == "load-demo-town" && !access_is_local_admin(&access) {
return Err(
"Sign in with a Townlight admin account before loading demonstration data.".to_string(),
);
}
if !signed_in {
if !access.configured {
return Err(
"Finish first-run setup before public city workflows are available.".to_string(),
);
}
if !public_action {
auth::require_signed_in_session()?;
}
if action == "answer-code-question" {
let mut public_payload = payload.unwrap_or_else(|| serde_json::json!({}));
let Some(fields) = public_payload.as_object_mut() else {
return Err("Public code questions must use a JSON object payload.".to_string());
};
fields.insert("publicOnly".to_string(), Value::Bool(true));
payload = Some(public_payload);
}
} else {
require_role_for_city_work(&access, &action, payload.as_ref())?;
}
require_enabled_city_modules(&action, payload.as_ref())?;
if module_registry::module_selection_state()?.profile_id == "meetings-beta" {
// This development profile has a single Python Meetings authority.
// Unmigrated shared actions stay unavailable, never write old JSON.
return Err("Use the typed Python product workflow in this Meetings candidate; legacy actions are unavailable.".into());
}
let mut result = workflows::city_work_action(&action, payload.as_ref())?;
if action == "search-city-knowledge" {
filter_search_results_for_enabled_modules(&mut result)?;
filter_search_results_for_access(&mut result, &access);
result.message = format!(
"Local search completed across enabled modules with {} result(s).",
result.search_results.len()
);
}
if !signed_in
&& public_action
&& action != "lookup-public-records-request"
&& action != "add-public-records-message"
{
result.state = workflows::city_work_public_projection(&result.state);
}
Ok(result)
}
#[tauri::command]
async fn city_work_action(
action: String,
payload: Option<Value>,
) -> Result<CityWorkActionResult, String> {
tauri::async_runtime::spawn_blocking(move || {
catch_unwind(AssertUnwindSafe(|| {
city_work_action_authorized(action, payload)
}))
.map_err(|_| {
"City workflow action stopped unexpectedly. Restart Townlight and retry; if this repeats, use Repair from System Health."
.to_string()
})?
})
.await
.map_err(|error| format!("City workflow action could not complete: {error}"))?
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
// A second launch was attempted. Do NOT start a second process that
// would concurrently read-modify-write the system-of-record files.
// Focus the already-running instance's window instead.
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}))
.on_window_event(|_window, event| {
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
let _ = supervisor::supervisor_action("stop", None);
}
})
.invoke_handler(tauri::generate_handler![
get_app_state,
get_model_state,
model_action,
preview_first_run_state,
first_run_action,
auth_action,
supervisor_action,
choose_file_path,
choose_folder_path,
module_action,
get_city_work_state,
records_domain_request,
records_public_request,
access_domain_request,
suite_domain_request,
meetings_domain_request,
code_domain_request,
zoning_domain_request,
zoning_public_request,
planning_domain_request,
permitting_domain_request,
inspections_domain_request,
permitting_pick_bundle,
planning_public_request,
planning_pick_bundle,
open_domain_exports,
city_work_action
])
.run(tauri::generate_context!())
.expect("failed to run Townlight desktop");
}
fn main() {
run();
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
use std::path::PathBuf;
fn with_clean_first_run_state<T>(test: impl FnOnce(PathBuf) -> T) -> T {
let _guard = first_run::test_env_lock().lock().expect("test env lock");
let root = env::temp_dir().join(format!(
"townlight-desktop-app-state-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
env::set_var("TOWNLIGHT_DESKTOP_STATE_DIR", &root);
let result = test(root.clone());
env::remove_var("TOWNLIGHT_DESKTOP_STATE_DIR");
let _ = fs::remove_dir_all(root);
result
}
fn create_first_admin() {
first_run::first_run_action("choose-location", Some("locations"), None)
.expect("locations saved");
let module_payload = serde_json::json!({ "profileId": "city-core" });
first_run::first_run_action("select-modules", Some("modules"), Some(&module_payload))
.expect("modules saved");
let city_payload = serde_json::json!({
"cityName": "Brookfield",
"state": "CO",
"timeZone": "America/Denver",
"recordsContact": "records@example.gov",
"clerkContact": "clerk@example.gov"
});
first_run::first_run_action(
"create-city-profile",
Some("city-profile"),
Some(&city_payload),
)
.expect("city profile saved");
let admin_payload = serde_json::json!({
"adminName": "Alex Clerk",
"adminEmail": "alex@example.gov",
"adminPasscode": "correct horse battery staple"
});
let result =
first_run::first_run_action("create-admin", Some("first-admin"), Some(&admin_payload))
.expect("admin saved");
assert!(result.accepted);
}
fn sign_in_as_first_admin() {
let sign_in_payload = serde_json::json!({
"email": "alex@example.gov",
"passcode": "correct horse battery staple"
});
auth::auth_action("sign-in", Some(&sign_in_payload)).expect("admin signed in");
}
fn create_staff_user(name: &str, email: &str, role: &str, passcode: &str) {
let payload = serde_json::json!({
"userName": name,
"userEmail": email,
"userRole": role,
"userPasscode": passcode
});
auth::auth_action("create-user", Some(&payload)).expect("staff user saved");
}
fn sign_in_as_user(email: &str, passcode: &str) {
let payload = serde_json::json!({
"email": email,
"passcode": passcode
});
auth::auth_action("sign-in", Some(&payload)).expect("user signed in");
}
#[test]
fn records_beta_modules_are_reported_installed() {
module_registry::validate_default_registry().expect("module registry contract validates");
let modules = module_summaries().expect("module registry parses");
for module_id in ["townlight_core", "townlight_records", "townlight_notice", "townlight_access"] {
let module = modules
.iter()
.find(|candidate| candidate.id == module_id)
.expect("records-beta module exists");
assert!(
module.installed,
"{module_id} should be installed in records-beta"
);
}
for module_id in ["townlight_meetings", "townlight_code"] {
let module = modules
.iter()
.find(|candidate| candidate.id == module_id)
.expect("queued module exists");
assert!(
!module.installed,
"{module_id} should not be installed in records-beta"
);
}
}
#[test]
fn navigation_is_task_first() {
let labels: Vec<&str> = navigation().into_iter().map(|item| item.label).collect();
assert!(labels.contains(&"Meetings & Notices"));
assert!(labels.contains(&"Records Requests"));
assert!(labels.contains(&"Code & Ordinances"));
assert!(!labels.contains(&"Docker"));
assert!(!labels.contains(&"WSL"));
}
#[test]
fn navigation_matches_frontend_fallback_state() {
// The live shell renders navigation() while main.js keeps a hardcoded
// fallbackState list for the offline path. v1.0.2 shipped with `access`
// present in the fallback but absent here, so the Accessibility tab was
// unreachable on every install. Keep the two ids in lockstep.
let main_js = include_str!("../../src/main.js");
let start = main_js
.find("navigation: [")
.expect("fallbackState.navigation block");
let end = main_js[start..]
.find("].map(")
.expect("end of fallbackState.navigation");
let block = &main_js[start..start + end];
for item in navigation() {
let needle = format!("[\"{}\"", item.id);
assert!(
block.contains(&needle),
"navigation() id `{}` is missing from main.js fallbackState.navigation",
item.id
);
}
let rust_ids: Vec<&str> = navigation().into_iter().map(|item| item.id).collect();
for line in block.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("[\"") {
continue;
}
let id = trimmed
.trim_start_matches("[\"")
.split('"')
.next()
.expect("fallback nav id");
assert!(
rust_ids.contains(&id),
"main.js fallbackState.navigation id `{id}` is missing from navigation()"
);
}
}
#[test]
fn app_state_reports_runtime_health_from_manifest() {
with_clean_first_run_state(|_| {
let state = app_state().expect("app state builds");
assert!(state.health.iter().any(|item| item.id == "desktop-shell"));
assert!(state.health.iter().any(|item| item.id == "postgres"));
assert!(state.health.iter().any(|item| item.id == "model-runtime"));
});
}
#[test]
fn app_state_reports_model_readiness_contract() {
with_clean_first_run_state(|_| {
let state = app_state().expect("app state builds");
assert_eq!(state.model.display_name, "Gemma 4 12B QAT Q4_0");
assert_eq!(state.model.status, "Needs download");
assert!(state.model.artifact.checksum_required);
assert!(state
.model
.checks
.iter()
.any(|check| check.id == "checksum" && !check.ok));
});
}
#[test]
fn model_state_hides_local_path_without_admin_session() {
with_clean_first_run_state(|_| {
create_first_admin();
let public_model = get_model_state().expect("model state");
assert_eq!(
public_model.artifact.local_path,
"Sign in as Townlight admin to view the model file path."
);
sign_in_as_first_admin();
let staff_model = get_model_state().expect("staff model state");
assert!(staff_model
.artifact
.local_path
.contains("gemma-4-12b-it-qat-q4_0.gguf"));
});
}
#[test]
fn model_actions_require_local_admin_session() {
with_clean_first_run_state(|_| {
let pre_admin_result = model_action_authorized("open-model-folder".to_string());
assert!(pre_admin_result.is_err());
assert!(pre_admin_result
.err()
.expect("pre-admin model action auth error")
.contains("Create the first Townlight admin and sign in"));
create_first_admin();
let signed_out_result = model_action_authorized("open-model-folder".to_string());
assert!(signed_out_result.is_err());
assert!(signed_out_result
.err()
.expect("model action auth error")
.contains("Sign in as the Townlight admin"));
sign_in_as_first_admin();
let signed_in_result = model_action_authorized("open-model-folder".to_string())
.expect("model action allowed");
assert!(signed_in_result.accepted);
});
}
#[test]
fn supervisor_command_wrapper_completes_backup_action() {
with_clean_first_run_state(|root| {
create_first_admin();
sign_in_as_first_admin();
let result =
tauri::async_runtime::block_on(supervisor_action("backup".to_string(), None))
.expect("backup command returns through async wrapper");
assert!(result.accepted);
assert_eq!(result.status, "Backup complete");
assert!(result.message.contains("backup-manifest.json"));
assert!(fs::read_dir(root.join("Backups"))
.expect("backup root exists")
.any(|entry| entry
.expect("backup entry")
.path()
.join("backup-manifest.json")
.is_file()));
});
}
#[test]
fn module_actions_require_admin_after_first_admin_exists() {
with_clean_first_run_state(|root| {
create_first_admin();
let signed_out_result =
module_action("disable-module".to_string(), "townlight_code".to_string());
assert!(signed_out_result.is_err());
assert!(signed_out_result
.err()
.expect("module action auth error")
.contains("Sign in as the Townlight admin"));
sign_in_as_first_admin();
let disabled = module_action("disable-module".to_string(), "townlight_code".to_string())
.expect("module disable allowed");
assert!(disabled.accepted);
assert_eq!(disabled.status, "Module disabled");
assert!(disabled
.selection
.installed_module_ids
.iter()
.any(|module_id| module_id == "townlight_code"));
assert!(!disabled
.selection
.enabled_module_ids
.iter()
.any(|module_id| module_id == "townlight_code"));
assert!(disabled
.modules
.iter()
.any(|module| module.id == "townlight_code" && module.installed && !module.enabled));
let enabled = module_action("enable-module".to_string(), "townlight_code".to_string())
.expect("module enable allowed");
assert!(enabled
.selection
.enabled_module_ids
.iter()
.any(|module_id| module_id == "townlight_code"));
let current = module_action("update-module".to_string(), "townlight_code".to_string())
.expect("module update check allowed");
assert_eq!(current.status, "Module current");
assert!(current.message.contains("pinned version"));
let opened_exports =
module_action("open-module-exports".to_string(), "townlight_code".to_string())
.expect("module exports open");
assert_eq!(opened_exports.status, "Module exports opened");
assert!(opened_exports.message.contains("code exports"));
assert!(root.join("Data").join("exports").join("code").is_dir());
let removed = module_action("remove-module".to_string(), "townlight_code".to_string())
.expect("module remove allowed");
assert_eq!(removed.status, "Module removed");
assert!(removed.message.contains("verified profile backup"));
assert!(removed.message.contains("was not deleted"));
assert!(!removed
.selection
.installed_module_ids
.iter()
.any(|module_id| module_id == "townlight_code"));
let backup_count = fs::read_dir(root.join("Backups"))
.expect("backup root exists")
.filter(|entry| {
entry
.as_ref()
.map(|entry| entry.path().join("backup-manifest.json").is_file())
.unwrap_or(false)
})
.count();
assert_eq!(backup_count, 1);
let removed_exports =
module_action("open-module-exports".to_string(), "townlight_code".to_string());
assert!(removed_exports.is_err());
assert!(removed_exports
.err()
.expect("removed module export error")
.contains("is not installed in this local profile"));
let installed = module_action("install-module".to_string(), "townlight_code".to_string())
.expect("module install allowed");
assert_eq!(installed.status, "Module installed");
assert!(installed
.selection
.enabled_module_ids
.iter()
.any(|module_id| module_id == "townlight_code"));
});
}
#[test]
fn choose_file_path_requires_signed_in_staff_and_uses_native_picker_result() {
with_clean_first_run_state(|_| {
create_first_admin();
env::set_var(
"TOWNLIGHT_TEST_FILE_PICKER_PATH",
r"C:\City\Records\packet.pdf",
);
let signed_out = choose_file_path();
env::remove_var("TOWNLIGHT_TEST_FILE_PICKER_PATH");
assert!(signed_out.is_err());
sign_in_as_first_admin();
env::set_var(
"TOWNLIGHT_TEST_FILE_PICKER_PATH",
r"C:\City\Records\packet.pdf",
);
let picked = choose_file_path().expect("signed-in file picker");
env::remove_var("TOWNLIGHT_TEST_FILE_PICKER_PATH");
assert_eq!(picked.as_deref(), Some(r"C:\City\Records\packet.pdf"));
});
}
#[test]
fn choose_folder_path_allows_first_run_then_requires_local_admin() {
with_clean_first_run_state(|_| {
env::set_var(
"TOWNLIGHT_TEST_FOLDER_PICKER_PATH",
r"C:\City\TownlightData",
);
let first_run_folder = choose_folder_path().expect("pre-admin folder picker");
env::remove_var("TOWNLIGHT_TEST_FOLDER_PICKER_PATH");
assert_eq!(first_run_folder.as_deref(), Some(r"C:\City\TownlightData"));
create_first_admin();
env::set_var(
"TOWNLIGHT_TEST_FOLDER_PICKER_PATH",
r"C:\City\TownlightBackups",
);
let signed_out = choose_folder_path();
env::remove_var("TOWNLIGHT_TEST_FOLDER_PICKER_PATH");
assert!(signed_out.is_err());
sign_in_as_first_admin();
env::set_var(
"TOWNLIGHT_TEST_FOLDER_PICKER_PATH",
r"C:\City\TownlightBackups",
);
let picked = choose_folder_path().expect("admin folder picker");
env::remove_var("TOWNLIGHT_TEST_FOLDER_PICKER_PATH");
assert_eq!(picked.as_deref(), Some(r"C:\City\TownlightBackups"));
});
}
#[test]
fn staff_user_roles_gate_city_work_modules() {
with_clean_first_run_state(|_| {
create_first_admin();
sign_in_as_first_admin();
create_staff_user(
"Riley Records",
"riley@example.gov",
"records-staff",
"records passcode 123",
);
create_staff_user(
"Casey Clerk",
"casey@example.gov",
"clerk",
"clerk passcode 123",
);
create_staff_user(
"Jordan Code",
"jordan@example.gov",
"code-staff",
"code passcode 123",
);
auth::auth_action("sign-out", None).expect("admin signed out");
sign_in_as_user("riley@example.gov", "records passcode 123");
let records_payload = serde_json::json!({
"requester": "Alex Rivera",
"summary": "Emails about park contract",
"deadline": "2026-07-10"
});
assert!(require_role_for_city_work(
&auth::access_state().unwrap(),
"create-records-request",
Some(&records_payload)
)
.is_ok());
let meeting_body = serde_json::json!({
"meetingBodyName": "City Council",
"meetingBodyType": "legislative",
"meetingBodyStatutoryBasis": "City Charter Section 2.1",
"meetingBodyCadence": "Third Wednesday",
"meetingBodyDefaultNoticeDays": "3",
"meetingBodyQuorumRule": "majority of seated members"
});
let records_error = match city_work_action_authorized(
"create-meeting-body".to_string(),
Some(meeting_body.clone()),
) {
Ok(_) => panic!("records staff cannot create meeting body"),
Err(error) => error,
};
assert!(records_error.contains("not allowed"));
auth::auth_action("sign-out", None).expect("records staff signed out");
sign_in_as_user("casey@example.gov", "clerk passcode 123");
assert!(require_role_for_city_work(
&auth::access_state().unwrap(),
"create-meeting-body",
Some(&meeting_body)
)
.is_ok());
let clerk_error = match city_work_action_authorized(
"create-records-request".to_string(),
Some(records_payload),
) {
Ok(_) => panic!("clerk cannot create records request"),
Err(error) => error,
};
assert!(clerk_error.contains("not allowed"));
auth::auth_action("sign-out", None).expect("clerk signed out");
sign_in_as_user("jordan@example.gov", "code passcode 123");
let code_payload = serde_json::json!({
"title": "Noise Ordinance",
"citation": "CMC 8.12",
"body": "Quiet hours begin at 10 PM.",
"importedBy": "Deputy Clerk"
});
assert!(require_role_for_city_work(
&auth::access_state().unwrap(),
"import-code-source",
Some(&code_payload)
)
.is_ok());
let code_error = match city_work_action_authorized(
"create-records-request".to_string(),
Some(serde_json::json!({
"requester": "Blake Chen",
"summary": "Permit logs",
"deadline": "2026-07-12"
})),
) {
Ok(_) => panic!("code staff cannot create records request"),
Err(error) => error,
};
assert!(code_error.contains("not allowed"));
});
}
#[test]
fn only_local_admin_can_load_demo_town() {
with_clean_first_run_state(|_| {
create_first_admin();
sign_in_as_first_admin();
create_staff_user(
"Riley Records",
"riley@example.gov",
"records-staff",
"records passcode 123",
);
auth::auth_action("sign-out", None).expect("admin signed out");
sign_in_as_user("riley@example.gov", "records passcode 123");
let error = city_work_action_authorized("load-demo-town".to_string(), None)
.err()
.expect("records staff must not load a demo town");
assert!(error.contains("Townlight admin"));
assert!(workflows::city_work_state()
.expect("city state remains readable")
.demo_fixture
.is_none());
});
}
#[test]
fn disabled_modules_block_owned_city_work_actions() {
with_clean_first_run_state(|_| {
create_first_admin();
sign_in_as_first_admin();
module_action("disable-module".to_string(), "townlight_code".to_string())
.expect("townlight_code disabled");
let code_payload = serde_json::json!({
"title": "Backyard Chicken Rules",
"citation": "CMC 6.16.040",
"body": "Backyard chickens are allowed with a coop permit."
});
let result =
city_work_action_authorized("import-code-source".to_string(), Some(code_payload));
assert!(result.is_err());
assert!(result
.err()
.expect("disabled module error")
.contains("Townlight Code is not enabled"));
});
}
#[test]
fn townlight_access_only_profile_can_still_reach_search_city_knowledge() {
with_clean_first_run_state(|_| {
create_first_admin();
sign_in_as_first_admin();
for m in ["townlight_code", "townlight_notice", "townlight_meetings", "townlight_records"] {
module_action("disable-module".to_string(), m.to_string()).unwrap();
}
require_enabled_city_modules("search-city-knowledge", None)
.expect("search-city-knowledge should remain enabled");
let err = city_work_action_authorized(
"search-city-knowledge".into(),
Some(serde_json::json!({"query": "water"})),
)
.err()
.unwrap();
assert!(err.contains("retired"));
});
}
#[test]
fn disabled_modules_are_filtered_from_cross_module_search() {
with_clean_first_run_state(|_| {
create_first_admin();
sign_in_as_first_admin();
module_action("disable-module".to_string(), "townlight_code".to_string()).unwrap();
let mut result = CityWorkActionResult {
accepted: true,
action: "search-city-knowledge".into(),
status: "Saved".into(),
message: String::new(),
next_action: String::new(),
state: CityWorkState::default(),
search_results: vec![workflows::SearchResult {
module_id: "townlight_code".into(),
record_id: "synthetic".into(),
title: "Synthetic".into(),
snippet: String::new(),
citation: String::new(),
status: "draft".into(),
}],
next_steps: vec![],
disclaimer: String::new(),
};
filter_search_results_for_enabled_modules(&mut result).unwrap();
assert!(result.search_results.is_empty());
});
}
#[test]
fn first_run_setup_actions_require_admin_after_first_admin_exists() {
with_clean_first_run_state(|_| {
create_first_admin();
let city_payload = serde_json::json!({
"cityName": "Brookfield",
"state": "CO",
"timeZone": "America/Denver",
"recordsContact": "records@example.gov",
"clerkContact": "clerk@example.gov"
});
let signed_out_profile_result = first_run_action_authorized(
"create-city-profile".to_string(),
Some("city-profile".to_string()),
Some(city_payload),
);
assert!(signed_out_profile_result.is_err());
assert!(signed_out_profile_result
.err()
.expect("first-run profile auth error")
.contains("Sign in as the Townlight admin"));
let signed_out_model_result = first_run_action_authorized(
"download-model".to_string(),
Some("model".to_string()),
None,
);
assert!(signed_out_model_result.is_err());
assert!(signed_out_model_result
.err()
.expect("first-run model auth error")
.contains("Sign in as the Townlight admin"));
let signed_out_result = first_run_action_authorized("backup".to_string(), None, None);
let signed_out_deferral = first_run_action_authorized(
"defer-model".to_string(),
Some("model".to_string()),
None,
);
assert!(signed_out_deferral.is_err());
assert!(signed_out_result.is_err());
assert!(signed_out_result
.err()
.expect("first-run lifecycle auth error")
.contains("Sign in as the Townlight admin"));
sign_in_as_first_admin();
let signed_in_result = first_run_action_authorized("backup".to_string(), None, None)
.expect("backup allowed");
assert!(signed_in_result.accepted);
assert_eq!(signed_in_result.action, "backup");
});
}
#[test]
fn first_run_setup_actions_can_bootstrap_before_admin_exists() {
with_clean_first_run_state(|_| {
first_run_action_authorized(
"choose-location".to_string(),
Some("locations".to_string()),
None,
)
.expect("locations can bootstrap before admin exists");
let module_payload = serde_json::json!({ "profileId": "city-core" });
first_run_action_authorized(
"select-modules".to_string(),
Some("modules".to_string()),
Some(module_payload),
)
.expect("module selection can bootstrap before admin exists");
let city_payload = serde_json::json!({
"cityName": "Brookfield",
"state": "CO",
"timeZone": "America/Denver",
"recordsContact": "records@example.gov",
"clerkContact": "clerk@example.gov"
});
let city_result = first_run_action_authorized(
"create-city-profile".to_string(),
Some("city-profile".to_string()),
Some(city_payload),
)
.expect("city profile can bootstrap before admin exists");
assert!(city_result.accepted);
let admin_payload = serde_json::json!({
"adminName": "Alex Clerk",
"adminEmail": "alex@example.gov",
"adminPasscode": "correct horse battery staple"
});
let admin_result = first_run_action_authorized(
"create-admin".to_string(),
Some("first-admin".to_string()),
Some(admin_payload),
)
.expect("first admin can bootstrap local access");
assert!(admin_result.accepted);
assert!(admin_result.next_action.contains("Sign in"));
});
}
#[test]
fn app_state_reports_first_run_setup_contract() {
with_clean_first_run_state(|_| {
let state = app_state().expect("app state builds");
assert_eq!(state.first_run.status, "Needs setup");
assert_eq!(
state.first_run.current_step_id.as_deref(),
Some("locations")
);
assert!(state.first_run.local_only);
});
}
#[test]
fn app_state_reports_saved_city_profile_and_first_admin() {
with_clean_first_run_state(|_| {
create_first_admin();
sign_in_as_first_admin();
let state = app_state().expect("app state");
let profile = state.city_profile.expect("city profile");
assert_eq!(profile.city_name, "Brookfield");
assert_eq!(profile.records_contact, "records@example.gov");
assert_eq!(state.users.len(), 1);
assert_eq!(state.users[0].email, "alex@example.gov");
assert_eq!(state.users[0].role, "local-admin");
});
}
#[test]
fn app_state_reports_module_selection_profile() {
with_clean_first_run_state(|_| {
module_registry::persist_profile_selection("city-core")
.expect("module selection persists");
let state = app_state().expect("app state");
assert_eq!(state.module_selection.profile_id, "city-core");
assert_eq!(state.module_selection.installed_module_ids.len(), 6);
assert!(state
.module_profiles
.iter()
.any(|profile| profile.id == "city-core" && profile.selected));
assert!(state
.modules
.iter()
.filter(|module| module.installed)
.all(|module| !module.proof_required.is_empty()));
});
}
#[test]
fn app_state_reports_local_city_work_state() {
with_clean_first_run_state(|_| {
let state = app_state().expect("app state builds");
assert_eq!(state.city_work.meetings.len(), 0);
assert_eq!(state.city_work.records_requests.len(), 0);
assert_eq!(state.city_work.code_sources.len(), 0);
});
}
#[test]
fn unauthenticated_city_work_state_is_public_projection() {
with_clean_first_run_state(|_| {
create_first_admin();
let mut raw = serde_json::to_value(CityWorkState::default()).unwrap();
raw["records_requests"] = serde_json::json!([{"id":"old","requester":"Synthetic","summary":"Private","deadline":"2026-11-01","status":"received","requester_contact":"private@example.invalid","created_at_unix_seconds":1}]);
let path = local_paths::data_root().join("workflows/city-work.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let original = serde_json::to_vec(&raw).unwrap();
std::fs::write(&path, &original).unwrap();
let public = get_city_work_state().expect("...");
assert!(public.records_requests.is_empty());
let state = app_state().expect("...");
assert!(state.users.is_empty());
assert!(state.health.iter().all(|item| item.admin_detail.is_empty()));
sign_in_as_first_admin();
let admin = get_city_work_state().expect("...");
assert_eq!(admin.records_requests.len(), 1);
assert_eq!(
admin.records_requests[0].requester_contact,
"private@example.invalid"
);
assert_eq!(std::fs::read(&path).unwrap(), original);
});
}
#[test]
fn public_city_work_actions_require_first_run_owner() {
with_clean_first_run_state(|_| {
let public_payload = serde_json::json!({
"requester": "Riley Resident",
"requesterContact": "riley@example.org",
"summary": "Please provide the current council packet."
});
let result = city_work_action_authorized(
"submit-public-records-request".to_string(),
Some(public_payload),
);
assert!(result.is_err());
assert!(result
.err()
.expect("setup error")
.contains("Finish first-run setup"));
});
}
#[test]
fn public_city_work_actions_do_not_require_admin_session() {
with_clean_first_run_state(|_| {
create_first_admin();
for action in [
"submit-public-records-request",
"lookup-public-records-request",
] {
let err = city_work_action_authorized(action.to_string(), None)
.err()
.unwrap_or_else(|| panic!("expected retired error for {action}"));
assert!(format!("{err:?}").contains("retired"), "{action}: {err:?}");
}
let err = city_work_action_authorized("create-records-request".to_string(), None)
.err()
.unwrap_or_else(|| panic!("expected sign-in error"));
assert!(
err.contains("Sign in with a staff or Townlight admin account"),
"{err:?}"
);
});
}
}permitting_client.rs
//! Fixed native transport. No permit rules or caller-supplied URLs/paths.
use crate::domain_client::{send, DomainError};
use reqwest::Method;
use serde::Deserialize;
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum PermittingRequest {
Requirements { offset: u32 },
Requirement { id: Uuid },
ImportRequirements { payload: Value },
Cases { offset: u32 },
Case { id: Uuid },
CreateCase { payload: Value },
ReviseCase { id: Uuid, payload: Value },
History { id: Uuid },
CreatePacket { id: Uuid, payload: Value },
ExportPacket { id: Uuid, expected_sha256: String },
SearchConnections { payload: Value },
Pathway { payload: Value },
AttachRecords { request_id: Uuid, payload: Value },
}
pub fn route(request: PermittingRequest) -> (Method, String, Option<Value>) {
use PermittingRequest::*;
let (method, path, body) = match request {
Requirements { offset } => (Method::GET, format!("/requirements?offset={offset}"), None),
Requirement { id } => (Method::GET, format!("/requirements/{id}"), None),
ImportRequirements { payload } => (Method::POST, "/requirements".into(), Some(payload)),
Cases { offset } => (Method::GET, format!("/cases?offset={offset}"), None),
Case { id } => (Method::GET, format!("/cases/{id}"), None),
CreateCase { payload } => (Method::POST, "/cases".into(), Some(payload)),
ReviseCase { id, payload } => (
Method::POST,
format!("/cases/{id}/revisions"),
Some(payload),
),
History { id } => (Method::GET, format!("/cases/{id}/history"), None),
CreatePacket { id, payload } => {
(Method::POST, format!("/cases/{id}/packets"), Some(payload))
}
ExportPacket { id, .. } => (Method::GET, format!("/packets/{id}"), None),
SearchConnections { payload } => {
(Method::POST, "/connections/search".into(), Some(payload))
}
Pathway { payload } => (Method::POST, "/pathway".into(), Some(payload)),
AttachRecords {
request_id,
payload,
} => {
return (
Method::POST,
format!("/desktop/v1/requests/{request_id}/permitting-sources"),
Some(payload),
)
}
};
(method, format!("/desktop/v1/permitting{path}"), body)
}
pub async fn execute(request: PermittingRequest, proof: String) -> Result<Value, DomainError> {
let export = match &request {
PermittingRequest::ExportPacket {
id,
expected_sha256,
} => Some((*id, expected_sha256.clone())),
_ => None,
};
let (method, path, body) = route(request);
let result = send(method, path, body, &proof).await?;
if let Some((id, expected)) = export {
let content = result
.get("text")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
let digest = result
.get("sha256")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
if digest != expected {
return Err(DomainError::denied());
}
let path = crate::domain_files::write_permitting_file(
&format!("permitting-{id}-{}.json", Uuid::new_v4()),
content.as_bytes(),
digest,
)
.map_err(|_| DomainError::denied())?;
return Ok(serde_json::json!({"path": path, "sha256": digest}));
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permitting_transport_is_fixed_and_does_not_accept_a_url_or_unknown_operation() {
assert!(serde_json::from_value::<PermittingRequest>(
serde_json::json!({"operation":"approve-permit"})
)
.is_err());
assert!(serde_json::from_value::<PermittingRequest>(
serde_json::json!({"operation":"cases","offset":0,"url":"https://example.com"})
)
.is_err());
let (_, path, _) = route(PermittingRequest::Cases { offset: 10 });
assert_eq!(path, "/desktop/v1/permitting/cases?offset=10");
}
}planning_client.rs
//! Fixed native transport and confined exact-byte exports. No business rules.
use crate::domain_client::{send, DomainError};
use reqwest::Method;
use serde::Deserialize;
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum PlanningRequest {
Initiatives {
offset: u32,
},
Amendments {
offset: u32,
},
MunicipalHistory {
id: Uuid,
},
CreateInitiative {
payload: Value,
},
ReviseInitiative {
id: Uuid,
payload: Value,
},
CreateAmendment {
payload: Value,
},
ReviseAmendment {
id: Uuid,
payload: Value,
},
SearchPolicies {
payload: Value,
},
Sources {
offset: u32,
},
SourceHistory {
plan_id: String,
#[serde(default)]
offset: u32,
},
Revision {
id: Uuid,
},
Original {
id: Uuid,
document_id: String,
},
PreviewImport {
payload: Value,
},
ImportBundle {
payload: Value,
},
Analyses {
offset: u32,
},
Analysis {
id: Uuid,
},
AnalysisHistory {
id: Uuid,
#[serde(default)]
offset: u32,
},
CreateAnalysis {
payload: Value,
},
ReviseAnalysis {
id: Uuid,
payload: Value,
},
PreviewSnapshot {
id: Uuid,
payload: Value,
},
CreateSnapshot {
id: Uuid,
payload: Value,
},
Snapshots {
offset: u32,
},
Snapshot {
id: Uuid,
},
ExportSnapshot {
id: Uuid,
expected_sha256: String,
},
AccessAdvice {
id: Uuid,
},
SearchConnections {
payload: Value,
},
PlanExplore {
payload: Value,
},
}
pub fn route(
request: PlanningRequest,
public: bool,
) -> Result<(Method, String, Option<Value>), DomainError> {
use PlanningRequest::*;
if public
&& !matches!(
&request,
SearchPolicies { .. }
| PlanExplore { .. }
| Snapshots { .. }
| Snapshot { .. }
| Original { .. }
| ExportSnapshot { .. }
)
{
return Err(DomainError::denied());
}
let result = match request {
Initiatives { offset } => (
Method::GET,
format!("/progress/targets?offset={offset}"),
None,
),
Amendments { offset } => (
Method::GET,
format!("/amendments/history?offset={offset}"),
None,
),
MunicipalHistory { id } => (Method::GET, format!("/municipal-work/{id}/history"), None),
CreateInitiative { payload } => (Method::POST, "/initiatives".into(), Some(payload)),
ReviseInitiative { id, payload } => (
Method::POST,
format!("/initiatives/{id}/revisions"),
Some(payload),
),
CreateAmendment { payload } => (Method::POST, "/amendments".into(), Some(payload)),
ReviseAmendment { id, payload } => (
Method::POST,
format!("/amendments/{id}/revisions"),
Some(payload),
),
SearchPolicies { payload } => (Method::POST, "/policies/search".into(), Some(payload)),
Sources { offset } => (Method::GET, format!("/sources?offset={offset}"), None),
SourceHistory { plan_id, offset } => (
Method::GET,
format!(
"/sources/{}/history?offset={offset}",
crate::code_client::encode_segment(&plan_id)?
),
None,
),
Revision { id } => (Method::GET, format!("/revisions/{id}"), None),
Original { id, document_id } => (
Method::GET,
format!(
"/revisions/{id}/documents/{}",
crate::code_client::encode_segment(&document_id)?
),
None,
),
PreviewImport { payload } => (Method::POST, "/imports/preview".into(), Some(payload)),
ImportBundle { payload } => (Method::POST, "/imports".into(), Some(payload)),
Analyses { offset } => (Method::GET, format!("/analyses?offset={offset}"), None),
Analysis { id } => (Method::GET, format!("/analyses/{id}"), None),
AnalysisHistory { id, offset } => (
Method::GET,
format!("/analyses/{id}/history?offset={offset}"),
None,
),
CreateAnalysis { payload } => (Method::POST, "/analyses".into(), Some(payload)),
ReviseAnalysis { id, payload } => (
Method::POST,
format!("/analyses/{id}/revisions"),
Some(payload),
),
PreviewSnapshot { id, payload } => (
Method::POST,
format!("/analyses/{id}/snapshot-preview"),
Some(payload),
),
CreateSnapshot { id, payload } => (
Method::POST,
format!("/analyses/{id}/snapshots"),
Some(payload),
),
Snapshots { offset } => (Method::GET, format!("/snapshots?offset={offset}"), None),
Snapshot { id } | ExportSnapshot { id, .. } => {
(Method::GET, format!("/snapshots/{id}"), None)
}
AccessAdvice { id } => (
Method::POST,
format!("/snapshots/{id}/access-advice"),
Some(serde_json::json!({})),
),
SearchConnections { payload } => {
(Method::POST, "/connections/search".into(), Some(payload))
}
PlanExplore { payload } => (Method::POST, "/plan-explore".into(), Some(payload)),
};
let prefix = if public {
"/townlight_planning"
} else {
"/desktop/v1/planning"
};
Ok((result.0, format!("{prefix}{}", result.1), result.2))
}
pub async fn execute(
request: PlanningRequest,
proof: String,
public: bool,
) -> Result<Value, DomainError> {
let export = match &request {
PlanningRequest::ExportSnapshot {
id,
expected_sha256,
} => Some((*id, expected_sha256.clone())),
_ => None,
};
let original = matches!(&request, PlanningRequest::Original { .. });
let (method, path, body) = route(request, public)?;
let result = send(method, path, body, &proof).await?;
if let Some((id, expected)) = export {
let content = result
.get("text")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
let digest = result
.get("sha256")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
if digest != expected {
return Err(DomainError::denied());
}
let path = crate::domain_files::write_planning_file(
&format!("planning-{id}-{}.json", Uuid::new_v4()),
content.as_bytes(),
digest,
)
.map_err(|_| DomainError::denied())?;
return Ok(serde_json::json!({"path": path, "sha256": digest}));
}
if original {
use base64::Engine;
let encoded = result
.get("content_base64")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|_| DomainError::denied())?;
let filename = result
.get("filename")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
let digest = result
.get("sha256")
.and_then(Value::as_str)
.ok_or_else(DomainError::denied)?;
let path = crate::domain_files::write_planning_file(
&format!("{}-{filename}", Uuid::new_v4()),
&bytes,
digest,
)
.map_err(|_| DomainError::denied())?;
return Ok(serde_json::json!({"path": path, "sha256": digest}));
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_transport_rejects_staff_reads_and_writes() {
for operation in ["sources", "analyses", "initiatives", "amendments"] {
let request =
serde_json::from_value(serde_json::json!({"operation":operation,"offset":0}))
.unwrap();
assert!(route(request, true).is_err());
}
assert!(route(
PlanningRequest::CreateAnalysis {
payload: serde_json::json!({})
},
true
)
.is_err());
let (_, path, _) = route(PlanningRequest::Snapshots { offset: 50 }, true).unwrap();
assert_eq!(path, "/townlight_planning/snapshots?offset=50");
assert!(serde_json::from_value::<PlanningRequest>(
serde_json::json!({"operation":"snapshot","id":"../../private"})
)
.is_err());
assert!(serde_json::from_value::<PlanningRequest>(
serde_json::json!({"operation":"snapshots","offset":0,"actor":"forged"})
)
.is_err());
}
#[test]
fn municipal_routes_are_fixed_and_staff_only() {
for (operation, endpoint) in [
("create-initiative", "/initiatives"),
("create-amendment", "/amendments"),
] {
let value = serde_json::json!({"operation": operation, "payload": {"title":"fixture"}});
let request = serde_json::from_value(value.clone()).unwrap();
assert!(route(request, true).is_err());
let (method, path, body) =
route(serde_json::from_value(value).unwrap(), false).unwrap();
assert_eq!(method, Method::POST);
assert_eq!(path, format!("/desktop/v1/planning{endpoint}"));
assert_eq!(body.unwrap()["title"], "fixture");
}
for operation in ["revise-initiative", "revise-amendment", "municipal-history"] {
assert!(
serde_json::from_value::<PlanningRequest>(serde_json::json!({
"operation": operation, "id":"../../private", "payload":{}
}))
.is_err()
);
}
}
}zoning_client.rs
use crate::domain_client::{send, DomainError};
use reqwest::Method;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize)]
#[serde(tag = "operation", rename_all = "kebab-case", deny_unknown_fields)]
pub enum ZoningRequest {
Lookup { payload: Value },
Question { payload: Value },
RegisterRule { payload: Value },
ImportBundle { payload: Value },
Workspace {},
Review { id: uuid::Uuid, payload: Value },
Outline { payload: Value },
CodeContext { payload: Value },
SearchMeetings { payload: Value },
LinkMeeting { id: uuid::Uuid, payload: Value },
History { id: uuid::Uuid },
Snapshot { id: uuid::Uuid, payload: Value },
Standards { payload: Value },
}
pub async fn execute(
request: ZoningRequest,
proof: String,
public: bool,
) -> Result<Value, DomainError> {
let (method, path, body): (Method, String, Option<Value>) = match request {
ZoningRequest::Lookup { payload } => {
(Method::POST, "/parcels/lookup".into(), Some(payload))
}
ZoningRequest::Question { payload } => (Method::POST, "/questions".into(), Some(payload)),
ZoningRequest::RegisterRule { payload } => {
if public {
return Err(DomainError::denied());
}
(Method::POST, "/rules".into(), Some(payload))
}
ZoningRequest::ImportBundle { payload } => {
if public {
return Err(DomainError::denied());
}
(Method::POST, "/imports".into(), Some(payload))
}
ZoningRequest::Workspace {} => {
if public {
return Err(DomainError::denied());
}
(Method::GET, "/workspace".into(), None)
}
ZoningRequest::Review { id, payload } => {
if public {
return Err(DomainError::denied());
}
(
Method::POST,
format!("/questions/{id}/reviews"),
Some(payload),
)
}
ZoningRequest::Outline { payload } => {
if public {
return Err(DomainError::denied());
}
(Method::POST, "/outlines".into(), Some(payload))
}
ZoningRequest::CodeContext { payload } => {
if public {
return Err(DomainError::denied());
}
(Method::POST, "/code-context".into(), Some(payload))
}
ZoningRequest::SearchMeetings { payload } => {
if public {
return Err(DomainError::denied());
}
(Method::POST, "/meetings/search".into(), Some(payload))
}
ZoningRequest::LinkMeeting { id, payload } => {
if public {
return Err(DomainError::denied());
}
(
Method::POST,
format!("/questions/{id}/meetings"),
Some(payload),
)
}
ZoningRequest::History { id } => {
if public {
return Err(DomainError::denied());
}
(Method::GET, format!("/questions/{id}/history"), None)
}
ZoningRequest::Standards { payload } => {
if public {
return Err(DomainError::denied());
}
(
Method::POST,
"/standards/process-explanation".into(),
Some(payload),
)
}
ZoningRequest::Snapshot { id, payload } => {
if public {
return Err(DomainError::denied());
}
(
Method::POST,
format!("/questions/{id}/snapshots"),
Some(payload),
)
}
};
let prefix = if public {
"/townlight_zoning"
} else {
"/desktop/v1/zoning"
};
let full_path = format!("{prefix}{path}");
send(method, full_path, body, &proof).await
}