> The LLM is the ingestion and reasoning layer. Your private snapshot store is the durable memory. The dashboard is the interface.
@@ -24,11 +24,14 @@ You choose the LLM. You own the data. You control the deployment. Zaati OS has n
·
Quickstart
·
- Privacy model
+ Live synthetic demo
·
- Deploy privately
+ Privacy model
+> [!IMPORTANT]
+> Zaati OS supplies contracts, prompts, validators, storage, rendering, and deployment infrastructure. It does not ship built-in Gmail, Jira, calendar, or financial connectors. Your chosen LLM or workflow must already have approved, read-only access to each source.
+
## What makes it different
- **Any LLM workflow:** ChatGPT, Claude, Gemini, a local model, n8n, cron, or custom code can publish the same contract.
@@ -38,8 +41,6 @@ You choose the LLM. You own the data. You control the deployment. Zaati OS has n
- **Useful failure states:** Freshness, provenance, confidence, missing sources, and warnings remain visible.
- **Forkable foundation:** The app, schemas, prompts, tests, CI, deployment recipes, theming, and synthetic examples ship together.
-
-
## From fork to your first dashboard
Requires Node.js 22 or newer.
@@ -77,7 +78,7 @@ The shortest useful loop is three sources, for example agenda, inbox attention,
## Create a scheduled task prompt
-Prompt Studio asks for your public Zaati OS fork, private data repository, schedule, sources, approved tools, desired content, and useful presentation blocks. It then creates a complete provider-neutral prompt with the current JSON contract, privacy boundaries, three-attempt retry protocol, and atomic multi-snapshot publication instructions.
+Prompt Studio asks three plain-language questions, then creates a human-readable permission receipt and the complete machine prompt. Advanced users can supply a reusable profile.
```bash
npm run prompt:create
@@ -86,8 +87,8 @@ npm run prompt:create

1. Answer the local wizard. Never enter credentials or real source values.
-2. Open `.zaati/generated-prompts/.scheduled-task.md`.
-3. Paste it into ChatGPT, Claude, Gemini, a local model, or your preferred workflow. Voilà.
+2. Review `.zaati/generated-prompts/.permissions.md`.
+3. Paste the scheduled-task prompt into your LLM. Voilà.
Generated profiles and prompts are ignored by Git and written with private file permissions. Existing sources produce one scheduled-task prompt. A new source produces a separate one-time setup prompt using only synthetic fixtures, while the recurring prompt remains data-only. See [Prompt Studio](docs/prompt-studio.md) and the [example profile](config/prompt-profile.example.json).
@@ -106,7 +107,7 @@ No upstream Zaati OS service participates in this flow.
## One run, many snapshots
-`schemas/snapshot-bundle.schema.json` lets one LLM run produce up to 20 registered snapshots. Zaati OS validates the entire bundle, sends concise contract errors back for up to three attempts, and writes nothing until every nested snapshot passes. Valid bundles are persisted as one rollback-safe local transaction or one Git commit.
+`schemas/snapshot-bundle.schema.json` lets one LLM run produce up to 20 registered snapshots. Zaati OS compares the returned sources with the workflow's authoritative source set, sends concise contract errors back for up to three attempts, and writes nothing until every nested snapshot passes. Git publication uses one pull request whose independent validator must pass before a human or trusted merge policy accepts it.
This is especially useful for scheduled AI products where active task capacity is limited. One daily task can refresh agenda, inbox, work, money, news, and the overview instead of consuming one task per source. Start with the [one-task tutorial](docs/tutorials/one-task-daily-bundle.md).
@@ -153,7 +154,7 @@ See [LLM contract](docs/llm-contract.md) and [`schemas/`](schemas/) for the exec
| `daily-overview.md` | Combine registered source snapshots | Adaptive dashboard |
| `weekly-review.md` | Find patterns and produce an evidence-based review | Progress, timeline, decisions |
-These are provider-neutral templates. Copy one into any tool that can read approved sources and write JSON to the private snapshot store.
+These are provider-neutral templates, not connectors. The ChatGPT scheduled-task recipe is the maintained first path. Other providers are contract-compatible but are not claimed as end-to-end certified until their documented release gate passes.
## Repository map
@@ -175,7 +176,7 @@ deployments/ Optional infrastructure recipes
A public fork is code, not a diary. Real snapshots, instance configuration, connector exports, secrets, and generated dashboard data are ignored. CI rejects committed private snapshot paths and common secret shapes. Synthetic examples are visibly marked and schema-validated.
-Optional AES-256-GCM snapshot encryption protects files at rest with a key supplied only through an ignored local key file or protected deployment secret. It is feature flagged and off by default. Encryption does not replace Access because authorized builds and browsers must eventually decrypt displayed facts.
+Optional AES-256-GCM snapshot encryption protects files at rest with a key supplied only through an ignored local key file or protected CI secret. It is feature flagged and off by default. Use it only with a trusted ingestion process that can receive the key. Never paste the key into an LLM prompt. Encryption does not replace Access because authorized builds and browsers must eventually decrypt displayed facts.
The dashboard is a static bundle. That bundle contains the snapshot facts needed for display, so it must be treated as private even if the source repository is public. The recommended deployment disables public `workers.dev` and preview URLs, then requires Cloudflare Access on a custom hostname before data deployment.
@@ -183,24 +184,25 @@ Read [Privacy and threat model](docs/privacy.md) before connecting a real source
## Commands
-| Command | Result |
-| ---------------------------- | ------------------------------------------------------------------------------ |
-| `npm run dev` | Build the data index and start Vite |
-| `npm run setup` | Complete the guided three-step local setup |
-| `npm run tutorial` | Run the retrying mock LLM bundle and open it locally |
-| `npm run workflow:run` | Connect any command-based LLM adapter |
-| `npm run snapshot:ingest` | Atomically validate and persist one multi-snapshot bundle |
-| `npm run snapshot:keygen` | Create an ignored 256-bit snapshot key |
-| `npm run instance:configure` | Create ignored local settings |
-| `npm run source:add` | Scaffold a source catalog entry and worker prompt |
-| `npm run prompt:create` | Build a private copy-ready scheduled-task prompt from a local profile |
-| `npm run data:validate` | Validate registries, snapshots, ownership, and UI blocks |
-| `npm run privacy:validate` | Reject private paths and common credential shapes |
-| `npm run format:check` | Reject formatting drift with Prettier |
-| `npm run lint` | Run type-aware ESLint, React Hooks, and React Refresh rules |
-| `npm run test:coverage` | Run tests with enforced line, branch, and function coverage |
-| `npm run check` | Run contracts, security, build, retry, encryption, performance, and WCAG tests |
-| `npm run deploy` | Validate, build, and deploy with Wrangler |
+| Command | Result |
+| ------------------------------ | ------------------------------------------------------------------------------ |
+| `npm run dev` | Build the data index and start Vite |
+| `npm run setup` | Complete the guided three-step local setup |
+| `npm run tutorial` | Run the retrying mock LLM bundle and open it locally |
+| `npm run workflow:run` | Connect any command-based LLM adapter |
+| `npm run snapshot:ingest` | Atomically validate and persist one multi-snapshot bundle |
+| `npm run snapshot:keygen` | Create an ignored 256-bit snapshot key |
+| `npm run instance:configure` | Create ignored local settings |
+| `npm run source:add` | Scaffold a source catalog entry and worker prompt |
+| `npm run prompt:create` | Build a private copy-ready scheduled-task prompt from a local profile |
+| `npm run data-repository:init` | Install the independent validator in a private snapshot repository |
+| `npm run data:validate` | Validate registries, snapshots, ownership, and UI blocks |
+| `npm run privacy:validate` | Reject private paths and common credential shapes |
+| `npm run format:check` | Reject formatting drift with Prettier |
+| `npm run lint` | Run type-aware ESLint, React Hooks, and React Refresh rules |
+| `npm run test:coverage` | Run tests with enforced line, branch, and function coverage |
+| `npm run check` | Run contracts, security, build, retry, encryption, performance, and WCAG tests |
+| `npm run deploy` | Validate, build, and deploy with Wrangler |
## Enforced quality gates
@@ -229,19 +231,19 @@ Pull requests expose each gate as a separate job and finish with one `Quality ga
Zaati OS charges no platform fee and can be deployed using free or already-owned tools, depending on provider, connector, model, storage, and hosting choices.
-The safe sequence is visualized step by step in [Onboarding](docs/onboarding.md). Deploy synthetic data first, verify the Access challenge, and only then import private snapshots.
-
## Release
Current version: **v0.1.1**
-This release establishes the portable data contract, atomic bundle ingestion, adaptive renderer, guided onboarding, provider adapters, optional encrypted storage, theme studio, privacy boundaries, Cloudflare recipe, and CI quality gates. See [CHANGELOG.md](CHANGELOG.md).
+This release establishes the portable data contract, atomic bundle ingestion, adaptive renderer, guided onboarding, provider-neutral prompts, optional encrypted storage, theme studio, privacy boundaries, Cloudflare recipe, and CI quality gates. See [CHANGELOG.md](CHANGELOG.md).
+
+Before storing real data, read [Data lifecycle and key recovery](docs/data-lifecycle.md). Fork maintainers can follow [Upgrade and fork sync](docs/upgrading.md).
## Contributing
Contributions should be composable domain packs with a source entry, schema, prompt, synthetic fixture, rendering behavior, tests, privacy notes, and removal steps. Maintainers must never need real personal data to review a contribution.
-Read [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), and [SECURITY.md](SECURITY.md).
+Read [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), [SUPPORT.md](SUPPORT.md), and [SECURITY.md](SECURITY.md).
The long-term product direction is captured in [Product vision](docs/product-vision.md) and [Roadmap](docs/roadmap.md).
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 0000000..4453008
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,14 @@
+# Support
+
+Use GitHub Discussions for setup help and design ideas. Use GitHub Issues for reproducible bugs with synthetic examples only. Report vulnerabilities privately through the process in `SECURITY.md`.
+
+Never post snapshot files, provider exports, repository tokens, decryption keys, private deployment URLs, account identifiers, or screenshots containing real personal data. Maintainers should be able to reproduce every report with the synthetic tutorial.
+
+Before opening an issue, run:
+
+```bash
+npm ci
+npm run check
+```
+
+Include the operating system, Node version, failing command, and redacted error. The accessibility check also needs Chrome or Chromium, or `CHROME_PATH` set to its executable.
diff --git a/config/sources.json b/config/sources.json
index 38797a2..923dcdf 100644
--- a/config/sources.json
+++ b/config/sources.json
@@ -10,7 +10,7 @@
"description": "A realistic day assembled from user-approved calendars, tasks, and reminders.",
"worker_id": "agenda-daily",
"prompt": "prompts/daily-agenda.md",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/agenda.schema.json",
"cadence": "daily",
"freshness_sla_hours": 30,
"dashboard_role": "primary",
@@ -19,7 +19,8 @@
"privacy": {
"expected_classification": "private",
"authorized_inputs": ["Calendar event summaries", "Task titles and due dates", "User-authored reminders"],
- "forbidden_inputs": ["Meeting credentials", "Private attendee notes", "Full document bodies"]
+ "forbidden_inputs": ["Meeting credentials", "Private attendee notes", "Full document bodies"],
+ "content_guards": ["authentication-link", "one-time-code", "encoded-blob"]
}
},
{
@@ -30,7 +31,7 @@
"description": "Only messages that need a response, decision, deadline, or follow-up.",
"worker_id": "inbox-attention-daily",
"prompt": "prompts/inbox-attention.md",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/inbox.schema.json",
"cadence": "daily",
"freshness_sla_hours": 30,
"dashboard_role": "primary",
@@ -39,7 +40,8 @@
"privacy": {
"expected_classification": "sensitive",
"authorized_inputs": ["Message metadata", "Short action-oriented summaries", "Deadlines and sender labels"],
- "forbidden_inputs": ["Complete message bodies", "Attachments", "Authentication links", "Credentials"]
+ "forbidden_inputs": ["Complete message bodies", "Attachments", "Authentication links", "Credentials"],
+ "content_guards": ["authentication-link", "one-time-code", "raw-email", "encoded-blob"]
}
},
{
@@ -50,7 +52,7 @@
"description": "Owned work, blockers, decisions, and the smallest useful next actions.",
"worker_id": "work-focus-daily",
"prompt": "prompts/work-focus.md",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/work.schema.json",
"cadence": "weekdays",
"freshness_sla_hours": 36,
"dashboard_role": "primary",
@@ -59,7 +61,8 @@
"privacy": {
"expected_classification": "sensitive",
"authorized_inputs": ["Assigned issue metadata", "Status and due dates", "User-visible pull request status"],
- "forbidden_inputs": ["Source code", "Customer data", "Private incident transcripts", "Access tokens"]
+ "forbidden_inputs": ["Source code", "Customer data", "Private incident transcripts", "Access tokens"],
+ "content_guards": ["authentication-link", "encoded-blob"]
}
},
{
@@ -70,7 +73,7 @@
"description": "User-approved totals, changes, goals, and evidence without raw statements.",
"worker_id": "money-pulse-daily",
"prompt": "prompts/money-pulse.md",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/money.schema.json",
"cadence": "daily",
"freshness_sla_hours": 36,
"dashboard_role": "primary",
@@ -79,7 +82,8 @@
"privacy": {
"expected_classification": "sensitive",
"authorized_inputs": ["User-approved account totals", "Normalized portfolio summaries", "Goals and budgets"],
- "forbidden_inputs": ["Account numbers", "Statement files", "Credentials", "Payment authorization data"]
+ "forbidden_inputs": ["Account numbers", "Statement files", "Credentials", "Payment authorization data"],
+ "content_guards": ["account-number", "authentication-link", "one-time-code", "encoded-blob"]
}
},
{
@@ -90,7 +94,7 @@
"description": "A small evidence-linked briefing based on configured interests.",
"worker_id": "news-briefing-daily",
"prompt": "prompts/news-briefing.md",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/news.schema.json",
"cadence": "daily",
"freshness_sla_hours": 24,
"dashboard_role": "primary",
@@ -99,7 +103,8 @@
"privacy": {
"expected_classification": "private",
"authorized_inputs": ["Public reporting", "Configured topics and regions"],
- "forbidden_inputs": ["Paywalled full text", "Fabricated citations", "Unnecessary browsing history"]
+ "forbidden_inputs": ["Paywalled full text", "Fabricated citations", "Unnecessary browsing history"],
+ "content_guards": ["encoded-blob"]
}
},
{
@@ -110,7 +115,7 @@
"description": "The decision surface assembled from the latest primary snapshots.",
"worker_id": "overview-daily",
"prompt": "prompts/daily-overview.md",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/overview.schema.json",
"cadence": "daily",
"freshness_sla_hours": 30,
"dashboard_role": "primary",
@@ -119,7 +124,8 @@
"privacy": {
"expected_classification": "sensitive",
"authorized_inputs": ["Registered normalized snapshots only"],
- "forbidden_inputs": ["Direct provider access", "Raw content repeated from upstream sources"]
+ "forbidden_inputs": ["Direct provider access", "Raw content repeated from upstream sources"],
+ "content_guards": ["authentication-link", "one-time-code", "raw-email", "encoded-blob"]
}
},
{
@@ -130,7 +136,7 @@
"description": "An evidence-based review of patterns, progress, risks, and next-week decisions.",
"worker_id": "review-weekly",
"prompt": "prompts/weekly-review.md",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/review.schema.json",
"cadence": "weekly",
"freshness_sla_hours": 192,
"dashboard_role": "primary",
@@ -139,7 +145,8 @@
"privacy": {
"expected_classification": "sensitive",
"authorized_inputs": ["Latest seven days of registered normalized snapshots"],
- "forbidden_inputs": ["Direct provider access", "Unsupported psychological or medical conclusions"]
+ "forbidden_inputs": ["Direct provider access", "Unsupported psychological or medical conclusions"],
+ "content_guards": ["authentication-link", "one-time-code", "raw-email", "encoded-blob"]
}
}
]
diff --git a/config/workflows.json b/config/workflows.json
index ed6c552..0e6600f 100644
--- a/config/workflows.json
+++ b/config/workflows.json
@@ -9,7 +9,7 @@
"prompt": "prompts/daily-bundle.md",
"source_ids": ["agenda:primary", "inbox:attention", "work:focus", "money:pulse", "news:briefing", "overview:daily"],
"max_attempts": 3,
- "publication": "single-commit"
+ "publication": "pull-request"
}
]
}
diff --git a/data/examples/agenda/primary/2026-08-24.json b/data/examples/agenda/primary/2026-08-24.json
index a3946e3..8953612 100644
--- a/data/examples/agenda/primary/2026-08-24.json
+++ b/data/examples/agenda/primary/2026-08-24.json
@@ -1,6 +1,6 @@
{
"schema_version": "0.1.1",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/agenda.schema.json",
"snapshot_id": "agenda:primary:2026-08-24",
"source_id": "agenda:primary",
"domain": "agenda",
@@ -17,6 +17,35 @@
"title": "A focused Monday",
"summary": "Two deep-work windows, one short sync, and enough recovery space remain.",
"attention": "low",
+ "facts": {
+ "events": [
+ {
+ "id": "a1",
+ "title": "Build the first usable slice",
+ "start": "2026-08-24T08:30:00Z",
+ "end": "2026-08-24T10:30:00Z",
+ "status": "scheduled"
+ },
+ {
+ "id": "a2",
+ "title": "Project sync",
+ "start": "2026-08-24T11:00:00Z",
+ "end": "2026-08-24T11:30:00Z",
+ "status": "scheduled"
+ },
+ {
+ "id": "a3",
+ "title": "Review and ship",
+ "start": "2026-08-24T13:00:00Z",
+ "end": "2026-08-24T14:30:00Z",
+ "status": "scheduled"
+ }
+ ],
+ "actions": [
+ { "id": "p1", "title": "Open the acceptance criteria", "due_at": "2026-08-24T08:30:00Z", "status": "open" },
+ { "id": "p2", "title": "Write the sync decision", "due_at": "2026-08-24T11:00:00Z", "status": "open" }
+ ]
+ },
"presentation": {
"layout": "focus",
"blocks": [
diff --git a/data/examples/inbox/attention/2026-08-24.json b/data/examples/inbox/attention/2026-08-24.json
index 35aadba..2f14fac 100644
--- a/data/examples/inbox/attention/2026-08-24.json
+++ b/data/examples/inbox/attention/2026-08-24.json
@@ -1,6 +1,6 @@
{
"schema_version": "0.1.1",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/inbox.schema.json",
"snapshot_id": "inbox:attention:2026-08-24",
"source_id": "inbox:attention",
"domain": "inbox",
@@ -17,6 +17,25 @@
"title": "Two messages deserve attention",
"summary": "One scheduling decision and one document review need a response. Everything else can wait.",
"attention": "medium",
+ "facts": {
+ "items": [
+ {
+ "id": "m1",
+ "sender_label": "Project collaborator",
+ "subject_summary": "Choose a planning window",
+ "reason": "decision",
+ "deadline": "2026-08-24T17:00:00Z",
+ "status": "open"
+ },
+ {
+ "id": "m2",
+ "sender_label": "Review partner",
+ "subject_summary": "Approve the revised outline",
+ "reason": "reply",
+ "status": "open"
+ }
+ ]
+ },
"presentation": {
"layout": "focus",
"blocks": [
diff --git a/data/examples/money/pulse/2026-08-24.json b/data/examples/money/pulse/2026-08-24.json
index 5cc7212..643e815 100644
--- a/data/examples/money/pulse/2026-08-24.json
+++ b/data/examples/money/pulse/2026-08-24.json
@@ -1,6 +1,6 @@
{
"schema_version": "0.1.1",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/money.schema.json",
"snapshot_id": "money:pulse:2026-08-24",
"source_id": "money:pulse",
"domain": "money",
@@ -19,6 +19,17 @@
"title": "Goals are on pace",
"summary": "The synthetic savings rate remains ahead of target. One source is stale, so today's change is not treated as final.",
"attention": "low",
+ "facts": {
+ "measures": [
+ { "id": "goal-funded", "label": "Goal funded", "kind": "percent", "value": 64, "as_of": "2026-08-23T18:00:00Z" },
+ { "id": "savings-rate", "label": "Savings rate", "kind": "percent", "value": 31, "as_of": "2026-08-23T18:00:00Z" },
+ { "id": "runway", "label": "Runway", "kind": "duration", "value": 11, "unit": "months", "as_of": "2026-08-23T18:00:00Z" }
+ ],
+ "goals": [
+ { "id": "reserve", "label": "Emergency reserve", "current": 11, "target": 12, "unit": "months" },
+ { "id": "investing", "label": "Annual investing target", "current": 7.4, "target": 12, "unit": "synthetic units" }
+ ]
+ },
"presentation": {
"layout": "dashboard",
"blocks": [
diff --git a/data/examples/news/briefing/2026-08-24.json b/data/examples/news/briefing/2026-08-24.json
index b26a730..78b9f49 100644
--- a/data/examples/news/briefing/2026-08-24.json
+++ b/data/examples/news/briefing/2026-08-24.json
@@ -1,6 +1,6 @@
{
"schema_version": "0.1.1",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/news.schema.json",
"snapshot_id": "news:briefing:2026-08-24",
"source_id": "news:briefing",
"domain": "news",
@@ -17,6 +17,34 @@
"title": "Three developments worth knowing",
"summary": "The synthetic briefing prioritizes impact and removes duplicate commentary.",
"attention": "low",
+ "facts": {
+ "stories": [
+ {
+ "id": "n1",
+ "title": "Open standards make agent workflows easier to move",
+ "summary": "Teams can change models without rebuilding every downstream view.",
+ "published_at": "2026-08-24T06:30:00Z",
+ "url": "https://example.com/ai-standards",
+ "topics": ["AI systems"]
+ },
+ {
+ "id": "n2",
+ "title": "Smaller software releases outperform big-bang delivery",
+ "summary": "Reliability improves when feedback arrives before architecture hardens.",
+ "published_at": "2026-08-24T06:20:00Z",
+ "url": "https://example.com/software-delivery",
+ "topics": ["Engineering"]
+ },
+ {
+ "id": "n3",
+ "title": "Privacy controls move closer to the data layer",
+ "summary": "Access boundaries become portable across hosting providers.",
+ "published_at": "2026-08-24T06:10:00Z",
+ "url": "https://example.com/privacy-controls",
+ "topics": ["Privacy"]
+ }
+ ]
+ },
"presentation": {
"layout": "timeline",
"blocks": [
diff --git a/data/examples/overview/daily/2026-08-24.json b/data/examples/overview/daily/2026-08-24.json
index 1d96adb..2658bfb 100644
--- a/data/examples/overview/daily/2026-08-24.json
+++ b/data/examples/overview/daily/2026-08-24.json
@@ -1,6 +1,6 @@
{
"schema_version": "0.1.1",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/overview.schema.json",
"snapshot_id": "overview:daily:2026-08-24",
"source_id": "overview:daily",
"domain": "overview",
@@ -26,6 +26,24 @@
"title": "Good morning. Your day has shape.",
"summary": "Protect the first focus block, answer two time-sensitive messages, and leave the afternoon buffer intact.",
"attention": "medium",
+ "facts": {
+ "decisions": [
+ {
+ "id": "protect-focus",
+ "title": "Protect the first focus block",
+ "reason": "The agenda and work queue both identify it as the highest-value window.",
+ "source_ids": ["agenda:primary:2026-08-24", "work:focus:2026-08-24"]
+ }
+ ],
+ "actions": [
+ {
+ "id": "reply-window",
+ "title": "Reply to the scheduling question",
+ "reason": "The inbox snapshot records a decision due today.",
+ "source_ids": ["inbox:attention:2026-08-24"]
+ }
+ ]
+ },
"presentation": {
"layout": "dashboard",
"blocks": [
diff --git a/data/examples/review/weekly/2026-08-24.json b/data/examples/review/weekly/2026-08-24.json
index 65e3d1c..228f74a 100644
--- a/data/examples/review/weekly/2026-08-24.json
+++ b/data/examples/review/weekly/2026-08-24.json
@@ -1,6 +1,6 @@
{
"schema_version": "0.1.1",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/review.schema.json",
"snapshot_id": "review:weekly:2026-08-24",
"source_id": "review:weekly",
"domain": "review",
@@ -24,6 +24,32 @@
"title": "A calmer week produced better output",
"summary": "Protected focus blocks rose while interruptions fell. The useful next move is to preserve the same morning boundary.",
"attention": "low",
+ "facts": {
+ "outcomes": [
+ {
+ "id": "complete-slice",
+ "label": "One complete slice shipped",
+ "status": "positive",
+ "evidence_snapshot_ids": ["work:focus:2026-08-24", "overview:daily:2026-08-24"]
+ }
+ ],
+ "patterns": [
+ {
+ "id": "protected-mornings",
+ "label": "Protected mornings coincided with completed work",
+ "status": "positive",
+ "evidence_snapshot_ids": ["agenda:primary:2026-08-24", "overview:daily:2026-08-24"]
+ }
+ ],
+ "next_actions": [
+ {
+ "id": "repeat-boundary",
+ "label": "Keep the first two hours meeting-free on three weekdays",
+ "status": "neutral",
+ "evidence_snapshot_ids": ["review:weekly:2026-08-24"]
+ }
+ ]
+ },
"presentation": {
"layout": "timeline",
"blocks": [
diff --git a/data/examples/work/focus/2026-08-24.json b/data/examples/work/focus/2026-08-24.json
index 387d9ab..ed0d8d8 100644
--- a/data/examples/work/focus/2026-08-24.json
+++ b/data/examples/work/focus/2026-08-24.json
@@ -1,6 +1,6 @@
{
"schema_version": "0.1.1",
- "schema_ref": "schemas/domains/generic.schema.json",
+ "schema_ref": "schemas/domains/work.schema.json",
"snapshot_id": "work:focus:2026-08-24",
"source_id": "work:focus",
"domain": "work",
@@ -17,6 +17,31 @@
"title": "Ship the smallest complete slice",
"summary": "One item is ready to build, one awaits a decision, and one is blocked outside your control.",
"attention": "medium",
+ "facts": {
+ "items": [
+ {
+ "id": "renderer",
+ "title": "Adaptive block renderer",
+ "status": "in-progress",
+ "priority": "high",
+ "next_action": "Complete the table component"
+ },
+ {
+ "id": "deployment",
+ "title": "Private deployment recipe",
+ "status": "ready",
+ "priority": "medium",
+ "next_action": "Verify the Access checklist"
+ },
+ {
+ "id": "connector",
+ "title": "Connector example",
+ "status": "blocked",
+ "priority": "medium",
+ "next_action": "Wait for source permission"
+ }
+ ]
+ },
"presentation": {
"layout": "dashboard",
"blocks": [
diff --git a/docs/adding-a-domain.md b/docs/adding-a-domain.md
index da4d632..e0f07cf 100644
--- a/docs/adding-a-domain.md
+++ b/docs/adding-a-domain.md
@@ -13,12 +13,12 @@ npm run source:add -- \
--workflow daily-core
```
-The generator adds a catalog entry and a provider-neutral prompt. It defaults to the generic domain schema and creates a deterministic owned path.
+The generator adds a catalog entry, a provider-neutral prompt, a dedicated strict facts schema, and a deterministic owned path.
## Complete the pack
1. Review purpose, authorized inputs, forbidden inputs, cadence, freshness, role, and dependencies.
-2. Use the generic domain schema unless another worker or UI needs stable named fields outside the standard presentation contract.
+2. Replace the scaffolded `facts.records` fields with stable domain names where the source has a clearer durable model. Keep presentation derived from facts.
3. Add a synthetic example at `data/examples///2026-08-24.json`.
4. Demonstrate one success state and one meaningful stale, partial, failed, or empty state.
5. Add the source ID to `config/instance.example.json` only when it belongs in the default experience.
diff --git a/docs/architecture.md b/docs/architecture.md
index a6bff0d..c8b70fd 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -6,7 +6,7 @@
flowchart TD
A[Approved provider data] --> B[User-owned LLM workflow]
B --> C[Private snapshot repository]
- C --> D[Schema and privacy validation]
+ C --> D[Independent schema and privacy gate]
D --> E[Build-time index]
E --> F[Static dashboard bundle]
F --> G[Cloudflare Access]
@@ -26,9 +26,9 @@ Zaati OS does not run a hosted ingestion service. The public project never needs
`schemas/snapshot.schema.json` carries identity, effective period, producer, evidence sources, status, freshness, quality, privacy, and a domain payload. One file represents one source for one effective date.
-### Safe presentation
+### Durable facts and safe presentation
-`schemas/ui-blocks.schema.json` is an executable view model. The producer may choose among audited blocks, but cannot send code or arbitrary components. React maps every allowed `kind` to a maintained renderer.
+Each source has a domain facts schema for durable memory. `schemas/ui-blocks.schema.json` is a separate executable view model. The producer may derive audited blocks from facts, but cannot send code or arbitrary components. React maps every allowed `kind` to a maintained renderer.
### Private memory
@@ -42,9 +42,9 @@ Snapshots use deterministic paths. Source workers write independently, so one un
Vite builds a client-only shell and a separate no-store dashboard payload. Hashed application assets can be cached without baking personal facts into the JavaScript chunk. The data response is still plaintext for an authorized browser and remains private. Authentication belongs in front of every asset, not in client-side JavaScript.
-### Bundle transaction
+### Bundle transaction and publication gate
-One LLM run may return a versioned bundle containing several snapshots. Zaati OS validates all identities, schemas, source ownership, and domain payloads before writing. Local persistence uses per-file atomic renames plus rollback backups. Git-backed workflows create one tree and one commit. A partial refresh is never considered success.
+One LLM run may return a versioned bundle containing several snapshots. Zaati OS validates the authoritative source set, identities, schemas, source ownership, privacy guards, and domain payloads before writing. Local persistence uses per-file atomic renames plus rollback backups. Git-backed workflows open one pull request. A separate workflow in the private data repository validates the candidate before merge, so the producing LLM is not its own trust authority.
### Optional encrypted storage
diff --git a/docs/compatibility.md b/docs/compatibility.md
new file mode 100644
index 0000000..6bd78e0
--- /dev/null
+++ b/docs/compatibility.md
@@ -0,0 +1,12 @@
+# Provider compatibility
+
+The JSON contract is provider-neutral. End-to-end support is a stronger claim.
+
+| Path | v0.1.1 status | Required release evidence |
+| -------------------------------------- | ------------------------------ | --------------------------------------------------------------------------- |
+| Local mock tutorial | Maintained | Retry, atomicity, accessibility, and build tests in CI |
+| ChatGPT scheduled task | Maintained first real workflow | Prompt generation, permission receipt, pull request, independent validation |
+| Command adapter | Maintained | Exact JSON on stdout and trusted local validation |
+| Claude, Gemini, other hosted workflows | Contract compatible | Community recipe until its complete path has a repeatable release test |
+
+Zaati OS does not bundle provider connectors. Availability, permissions, retention, pricing, and scheduled-task behavior belong to the chosen provider.
diff --git a/docs/data-lifecycle.md b/docs/data-lifecycle.md
new file mode 100644
index 0000000..8cee476
--- /dev/null
+++ b/docs/data-lifecycle.md
@@ -0,0 +1,31 @@
+# Data lifecycle and key recovery
+
+Decide this policy before the first real snapshot. Zaati OS cannot choose how long your personal history should exist.
+
+## Retention
+
+- Keep only the facts needed for the dashboard and trends you actually use.
+- Choose a retention period per source. Money and review facts may need different windows than inbox attention.
+- A normal Git deletion removes the current file but not its history. Hard deletion requires a reviewed history rewrite in the private repository and fresh clones afterward.
+- Never test deletion with the only copy of encrypted data.
+
+## Encryption key ownership
+
+`npm run snapshot:keygen` creates an ignored 256-bit key. Back it up in a password manager or secrets vault before writing encrypted snapshots. The key never belongs in Git, an LLM prompt, a generated permission receipt, logs, or screenshots.
+
+If the key is lost, encrypted snapshots are unrecoverable by design. Zaati OS has no recovery service or escrow copy.
+
+## Rotation
+
+1. Pause producers and deployment.
+2. Back up the private repository and old key.
+3. Decrypt and re-encrypt in a trusted local or protected CI environment using a newly generated key.
+4. Validate every new envelope before replacing any old file.
+5. Update the protected validator and deployment secrets together.
+6. Deploy, verify the dashboard, then revoke the old key.
+
+If a key may have leaked, rotate it and rewrite encrypted Git history. Re-encrypting only the current files does not remove old ciphertext from history.
+
+## Source removal
+
+Disable the source in instance configuration, remove its scheduled producer, delete current snapshot files, and decide whether history must also be rewritten. Rebuild and confirm the source is absent from the dashboard and generated payload.
diff --git a/docs/deployment/data-repository.md b/docs/deployment/data-repository.md
index 38f6f58..846a08f 100644
--- a/docs/deployment/data-repository.md
+++ b/docs/deployment/data-repository.md
@@ -24,24 +24,34 @@ The public source catalog and schemas remain in the code repository. Workers rea
Use separate, least-privilege identities:
-- The LLM workflow needs write access only to the private data repository. Prefer its existing GitHub connection instead of putting a token inside the prompt.
+- The LLM workflow needs permission to create a branch and pull request only in the private data repository. Prefer its existing GitHub connection instead of putting a token inside the prompt.
- The deployment workflow needs a fine-grained token restricted to this one private repository with Contents read permission.
- Cloudflare credentials never belong in the data repository.
Store the deployment read token as the `ZAATI_DATA_REPOSITORY_TOKEN` secret in the protected GitHub production environment.
+## Independent validation gate
+
+Install the gate from the code fork:
+
+```bash
+npm run data-repository:init -- --repository-root ../zaati-data --code-repository YOUR_USER/zaati-os --code-ref FULL_COMMIT_SHA
+```
+
+Commit the generated `zaati.data.json` and workflow, then require `Validate Zaati snapshots` in branch protection. Use an immutable 40-character commit SHA until a reviewed release tag exists.
+
## Bundle writes
-A daily bundle worker validates every selected snapshot, creates one Git tree containing every current-date path, and publishes one commit. It must never publish a valid subset when another selected snapshot fails.
+A daily producer creates one branch and pull request containing every current-date path. It must never publish a subset, write directly to the default branch, edit the validation workflow, or merge its own pull request. The independent gate compares the candidate with the authoritative expected source set and rechecks paths, ownership, schemas, privacy rules, and encryption mode.
-Single-source workers remain supported for event-driven or independent cadences. For a single-user repository, direct default-branch commits are possible, but pull requests preserve validation and audit history.
+Single-source workers remain supported for independent cadences, but they use the same pull-request gate.
Same-day reruns replace the same file and preserve `snapshot_id`. Workers fetch the latest target branch before writing and never force-push.
-Encrypted mode stores only `.json.enc` envelopes. The private repository never receives the decryption key. Keep `ZAATI_SNAPSHOT_KEY` in the protected deployment environment.
+Encrypted mode stores only `.json.enc` envelopes. Keep `ZAATI_SNAPSHOT_KEY` in the validator and deployment environments, never in the repository or generated prompt. A hosted LLM workflow should not receive this key. Use encrypted mode with a trusted local or CI ingestion process.
## Retention
Git history retains deleted values. If a source requires hard deletion, use an approved history-rewrite procedure inside the private repository and rotate any exposed credential. A normal file deletion is not a secure erasure.
-Set source-specific retention in your operational policy. Zaati OS does not upload or prune the repository on your behalf.
+Set source-specific retention in your operational policy. Follow [Data lifecycle and key recovery](../data-lifecycle.md) before deleting or rotating encrypted data.
diff --git a/docs/llm-contract.md b/docs/llm-contract.md
index 7932017..e798cba 100644
--- a/docs/llm-contract.md
+++ b/docs/llm-contract.md
@@ -17,7 +17,7 @@ A producer must:
## Multi-snapshot bundles
-One workflow may publish several registered sources through `schemas/snapshot-bundle.schema.json`. The bundle contains snapshots, never file paths. Zaati OS derives every target from the source registry, rejects duplicate source IDs, validates all nested contracts, and persists nothing until the complete set passes.
+One workflow may publish several registered sources through `schemas/snapshot-bundle.schema.json`. The bundle contains snapshots, never file paths. `expected_source_ids` is compared with the authoritative workflow configuration, so a producer cannot quietly redefine a six-source run as a successful one-source run. Zaati OS derives every target from the source registry, rejects missing, extra, duplicate, or misordered sources, validates all nested contracts, and persists nothing until the complete set passes.
Use `prompts/daily-bundle.md` for one daily LLM run. Build direct-source candidates before aggregates so an overview can depend on valid snapshots from the same run.
@@ -29,6 +29,8 @@ The orchestration layer should attempt a complete candidate at most three times.
The LLM chooses the information shape. The application keeps control of rendering, colors, accessibility, responsive behavior, links, and executable code.
+Each source-specific domain schema requires stable `data.facts`. Facts carry durable dates, amounts, statuses, references, and decisions without depending on a visual component. `data.presentation` is a derived view of those facts. A future renderer can change a table into a graph without rewriting memory.
+
| Block | Use it for | Do not use it for |
| -------------- | ------------------------------------------------- | --------------------------------- |
| `metric-group` | A few current decision measures | A wall of arbitrary counts |
@@ -44,7 +46,9 @@ The LLM chooses the information shape. The application keeps control of renderin
## Safety limits
-The schema rejects unknown properties and limits block, row, point, item, series, text, and URL sizes. Links require HTTPS. Snapshots cannot contain scripts, HTML execution, private-key blocks, or secret-shaped keys. The app never evaluates snapshot text.
+The schema rejects unknown properties and limits block, row, point, item, series, text, and URL sizes. Links require HTTPS. Snapshots cannot contain scripts, HTML execution, private-key blocks, secret-shaped values, authentication links, raw messages, or source-specific forbidden content. Validation reports field paths and rule names without repeating the suspect value. The app never evaluates snapshot text.
+
+For Git publication, the producer opens a pull request and stops. A separately installed workflow validates exact source completeness, source ownership, schemas, paths, encryption mode, and privacy rules before merge.
## Versioning
diff --git a/docs/onboarding.md b/docs/onboarding.md
index 7af0aed..3d6cd51 100644
--- a/docs/onboarding.md
+++ b/docs/onboarding.md
@@ -29,7 +29,7 @@ You need:
- an LLM or automation environment that can use your approved sources and write to GitHub
- a Cloudflare account and custom domain only when you are ready to deploy
-ChatGPT, Claude, Gemini, local models, n8n, cron, and custom commands can all use the same generated contract. Provider menus differ, but the Zaati OS side does not.
+The contract is provider-neutral, but Zaati OS does not bundle source connectors. The maintained first real workflow is ChatGPT scheduled tasks. Other capable LLMs, local models, n8n, cron, and custom commands can use the same contract when they already have the required approved tools.
## 1. Fork, clone, and personalize
@@ -50,7 +50,7 @@ Good starter choices:
| Question | Recommended first answer |
| ----------- | ------------------------------------------------------------- |
-| Source pack | `everyday` |
+| Source pack | `daily` |
| Palette | `sage` |
| Font | `system` |
| Headers | `plain` |
@@ -84,7 +84,13 @@ data/
//.json
```
-Your code fork can stay public. Your data repository must stay private. See [Private data repository](deployment/data-repository.md) for permissions, encryption, retention, and same-day reruns.
+Your code fork can stay public. Your data repository must stay private. Install its independent validation gate before connecting an LLM:
+
+```bash
+npm run data-repository:init -- --target ../zaati-data --code-repository YOUR_GITHUB_USERNAME/zaati-os --code-ref FULL_COMMIT_SHA
+```
+
+Commit the generated files and require `Validate Zaati snapshots` in branch protection. See [Private data repository](deployment/data-repository.md) for permissions, encryption, retention, and same-day reruns.
## 4. Understand the environment setup
@@ -94,14 +100,14 @@ There is no required `.env` file for the local demo. `.env.example` is a referen
Keep values in the system that needs them:
-| Value | Where it belongs | Commit it? |
-| ------------------------------------- | ----------------------------------------------------- | ---------- |
-| Dashboard name, timezone, theme | Ignored `config/instance.local.json` | No |
-| Generated LLM task prompt | Ignored `.zaati/generated-prompts/` | No |
-| GitHub and source access | Your LLM provider's connection settings | No |
-| Hostname and Worker name | GitHub `production` environment variables | No |
-| Cloudflare and data repository tokens | GitHub `production` environment secrets | No |
-| Optional snapshot key | Protected deployment secret or ignored local key file | No |
+| Value | Where it belongs | Commit it? |
+| ------------------------------------- | ------------------------------------------------------ | ---------- |
+| Dashboard name, timezone, theme | Ignored `config/instance.local.json` | No |
+| Generated LLM task prompt | Ignored `.zaati/generated-prompts/` | No |
+| GitHub and source access | Your LLM provider's connection settings | No |
+| Hostname and Worker name | GitHub `production` environment variables | No |
+| Cloudflare and data repository tokens | GitHub `production` environment secrets | No |
+| Optional snapshot key | Trusted local or protected CI ingestion and deployment | No |
Never paste tokens into a generated prompt. Prefer provider-managed connections and narrow repository permissions.
@@ -122,27 +128,21 @@ For a useful first daily task, choose:
- `work:focus`
- `overview:daily`, after its dependencies
-Prompt Studio asks what each source should contain, which tools it may use, and which presentation blocks would help. It also asks for:
-
-- your public Zaati OS code fork
-- your private data repository
-- provider and timezone
-- schedule in plain language
-- direct commit or pull request publication
+Prompt Studio asks for a provider, a starter dashboard, the two repositories, and a schedule. It chooses registered dependencies and safe view types for the normal path.
-It creates `.zaati/generated-prompts/.scheduled-task.md` with the current schemas, privacy limits, retry protocol, deterministic paths, and atomic publication rules.
+It creates a short `.permissions.md` receipt for human review plus `.scheduled-task.md` with current contract locations, privacy limits, retries, deterministic paths, and pull-request publication rules.
### Configure the provider
In ChatGPT, Claude, Gemini, n8n, or another supported environment:
-1. Connect GitHub and grant access to the private data repository.
+1. Connect GitHub and grant permission to create branches and pull requests only in the private data repository.
2. Connect only the source tools selected in Prompt Studio, for example calendar or email.
3. Create a task, automation, or reusable workflow.
4. Paste the complete generated scheduled-task prompt.
-5. Review the requested repositories, sources, paths, and schedule.
+5. Review the generated permission receipt, repositories, sources, paths, and schedule.
6. Run it manually once before enabling recurrence.
-7. Confirm that all selected snapshots arrive in one commit or pull request.
+7. Confirm that all selected snapshots arrive in one pull request and the independent check passes.
The provider must read the current default branch contracts on every run. A copied prompt alone is not permanent authority to ignore newer schemas.
@@ -154,9 +154,10 @@ Check the private repository, not the public fork. A successful run should:
- update all selected sources together
- contain no credentials, raw provider exports, or unnecessary personal content
- preserve missing values, uncertainty, provenance, and warnings
-- leave no partial commit if one snapshot fails
+- leave no mergeable pull request if one snapshot fails
+- stop without merging its own pull request
-For safer review, keep `pull-request` publication until the workflow is stable.
+Pull-request publication and the independent validator are required for scheduled Git workflows.
### Local command alternative
@@ -224,7 +225,7 @@ Create a Cloudflare Access self-hosted application for the exact hostname. Use e
npm run access:verify -- life.example.com
```
-The command must detect an unauthenticated Access challenge or denial.
+The command must detect an unauthenticated Access challenge or denial for HTML, dashboard data, and an asset path.
### D. Connect private data
@@ -235,7 +236,7 @@ Read [Private Cloudflare deployment](deployment/cloudflare.md) for token scopes,
## Go-live checklist
- [ ] The code fork contains no real snapshots or local instance file.
-- [ ] The data repository is private.
+- [ ] The data repository is private and its independent validator is required.
- [ ] The LLM has only the GitHub and source access it needs.
- [ ] One manual bundle run succeeded before scheduling.
- [ ] The dashboard shows freshness, provenance, and honest missing states.
diff --git a/docs/privacy.md b/docs/privacy.md
index 1813673..bf64a0d 100644
--- a/docs/privacy.md
+++ b/docs/privacy.md
@@ -27,7 +27,9 @@ Users separately trust their selected source providers, LLM provider or local mo
| A broad Access rule lets anyone in | Deny by default, exact emails or constrained identity groups, unauthenticated preflight |
| Credential committed accidentally | Common secret-shape scan, GitHub secret storage, scoped tokens |
| Private snapshot repository is copied or leaked | Optional AES-256-GCM authenticated encryption with a separate deployment key |
-| One snapshot in a batch is invalid | Whole-bundle validation and rollback-safe persistence before publication |
+| Producer returns only part of a batch | Authoritative expected-source comparison rejects missing, extra, or self-redefined bundles |
+| Producer self-certifies unsafe output | Independent private-repository pull-request validation before merge |
+| Sensitive text hides inside a valid field | Universal secret scanning plus source-specific content guards over every snapshot string |
## Data minimization
@@ -43,7 +45,7 @@ Client-side hiding is not authentication. Zaati OS keeps personal facts out of t
## Encryption boundary
-Encrypted snapshot storage is optional and disabled by default. It protects repository and filesystem copies from readers who do not have the separate key and detects ciphertext modification. It does not protect data from an authorized LLM workflow, the build process, the deployed dashboard payload, an authorized browser session, a compromised provider, or a leaked decryption key.
+Encrypted snapshot storage is optional and disabled by default. It protects repository and filesystem copies from readers who do not have the separate key and detects ciphertext modification. It does not protect data from an authorized LLM workflow, the build process, the deployed dashboard payload, an authorized browser session, a compromised provider, or a leaked decryption key. Do not give the key to a hosted LLM. Use this mode with trusted local or CI ingestion.
Generate the ignored key with `npm run snapshot:keygen`. In production, store its value only as the protected `ZAATI_SNAPSHOT_KEY` deployment secret. Never commit the key beside encrypted files. Back it up securely because Zaati OS cannot recover encrypted snapshots without it.
@@ -56,4 +58,5 @@ Generate the ignored key with `npm run snapshot:keygen`. In production, store it
5. Test an authorized session.
6. Test an incognito unauthenticated request.
7. Run `npm run access:verify -- your.private.hostname`.
-8. Only then connect the private data repository.
+8. Install and require the independent private-repository validation check.
+9. Only then connect the private data repository.
diff --git a/docs/prompt-studio.md b/docs/prompt-studio.md
index 464e7a6..73e4904 100644
--- a/docs/prompt-studio.md
+++ b/docs/prompt-studio.md
@@ -2,7 +2,7 @@
Prompt Studio turns a small, private local profile into a complete prompt that you can paste into ChatGPT, Claude, Gemini, a local model, or another scheduled workflow. The generated prompt includes repository locations, source intent, current Zaati OS contracts, safe presentation blocks, privacy boundaries, validation, retries, and atomic publication.
-It does not connect to an LLM or store credentials.
+It does not connect to an LLM, install source connectors, or store credentials.
## Three steps
@@ -10,9 +10,11 @@ It does not connect to an LLM or store credentials.
npm run prompt:create
```
-1. Choose one or more registered sources.
-2. Describe the information each source should contain, the approved tools it may use, and useful block types.
-3. Copy `.zaati/generated-prompts/.scheduled-task.md` into your LLM and approve the connections it requests.
+1. Choose a provider and a useful starter dashboard.
+2. Enter the public code fork and private data repository.
+3. Review `.permissions.md`, then copy `.scheduled-task.md` into your LLM and approve only the listed connections.
+
+The normal wizard chooses registered sources, dependencies, safe blocks, and sensible tool labels for you. Repository names are the only technical concepts in the default path. Use a JSON profile when you need per-source control.
The profile and prompts are ignored by Git. Their directory uses mode `0700` and each file uses mode `0600`. They may still reveal repository names and workflow intent, so treat them as private configuration.
@@ -45,7 +47,9 @@ The preferred block list is a safe menu, not a forced layout. The LLM should cho
## One task, many snapshots
-Add several source objects to the same profile. The generated prompt requires one complete `snapshot-bundle` result, validates all nested snapshots, retries the whole candidate at most three times, and publishes all dated files in one commit or pull request. It never publishes a valid subset from a failed run.
+Add several source objects to the same profile. The generated prompt includes `expected_source_ids`, requires one complete `snapshot-bundle`, retries the whole candidate at most three times, and opens one pull request. It never publishes a subset or merges its own pull request.
+
+The private repository must independently validate the candidate with `npm run data-repository:init`. This separates the LLM that reads and writes data from the policy gate that decides whether the candidate is mergeable.
Direct sources should appear before aggregate sources. Registered aggregate dependencies still apply.
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 93e40e5..07082e2 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -20,7 +20,7 @@ When the demo feels right, generate the complete prompt for your real scheduled
npm run prompt:create
```
-Choose sources, describe the result you need, name the approved tools, and select useful presentation blocks. Paste the private generated prompt into your LLM. Read [Prompt Studio](prompt-studio.md) for multi-source profiles and new-source setup.
+Choose a provider and starter dashboard, then enter your public code fork and private data repository. Review the generated permission receipt before pasting the prompt into your LLM. Read [Prompt Studio](prompt-studio.md) for advanced profiles and new-source setup.
## 2. Configure the instance
@@ -30,7 +30,13 @@ The wizard writes `config/instance.local.json`. It is ignored by Git. You can re
For experimentation, write snapshots under ignored `data/snapshots/`.
-For production, keep the public code fork clean and create a separate private data repository with the same `data/snapshots///...` shape. Follow [Private data repository](deployment/data-repository.md).
+For production, keep the public code fork clean and create a separate private data repository. Install its independent validator before connecting an LLM:
+
+```bash
+npm run data-repository:init -- --repository-root ../zaati-data --code-repository YOUR_USER/zaati-os --code-ref FULL_COMMIT_SHA
+```
+
+Require the `Validate Zaati snapshots` check on the private repository's default branch. Follow [Private data repository](deployment/data-repository.md).
## 4. Connect one multi-source workflow
@@ -42,7 +48,7 @@ A useful first set is:
Open `prompts/daily-bundle.md` and `prompts/scheduled-github-bundle.md`. Replace their repository, source, and timezone placeholders. Give them to the LLM workflow that already has approved access.
-Run the complete prompt manually before scheduling it. One run should validate and commit all selected dated files together. For a command adapter, pipe exact JSON into the local transaction:
+Run the complete prompt manually before scheduling it. One run should open one pull request containing all selected dated files. The LLM must never merge or bypass the independent check. For a trusted command adapter, pipe exact JSON into the local transaction:
```bash
your-llm-command | npm run snapshot:ingest -- --output-dir data/snapshots
diff --git a/docs/recommended-setup.md b/docs/recommended-setup.md
index 092ad48..c8cb1c4 100644
--- a/docs/recommended-setup.md
+++ b/docs/recommended-setup.md
@@ -5,8 +5,9 @@
1. Fork the public code repository.
2. Run `npm install`, `npm run setup`, and `npm run tutorial`.
3. Create one separate private data repository.
-4. Test one three-source bundle manually.
-5. Deploy synthetic data, configure Cloudflare Access, verify the challenge, then connect private data.
+4. Install and require the private repository's independent snapshot validator.
+5. Generate a prompt, review its permission receipt, and test one bundle manually.
+6. Deploy synthetic data, configure Cloudflare Access, verify HTML, data, and asset challenges, then connect private data.
## Strong defaults
@@ -14,7 +15,7 @@
| ------------------ | --------------------------------------------------------------------- |
| Scheduled workflow | One daily bundle task plus one weekly review task |
| First sources | Agenda, work focus, and daily overview |
-| Publication | One validated Git commit per bundle |
+| Publication | One independently validated pull request per bundle |
| Hosting | Cloudflare Worker static assets on a custom Access-protected hostname |
| Repository | Public code fork plus separate private snapshot repository |
| Theme | System font, comfortable density, system light or dark mode |
@@ -22,7 +23,7 @@
## Optional hardening
-- Enable snapshot encryption before the first real snapshot.
+- Enable snapshot encryption only with trusted local or CI ingestion, then back up the key before the first encrypted snapshot.
- Protect the GitHub `production` environment with reviewers.
- Enable dependency graph, dependency review, Dependabot, secret scanning, and push protection.
- Use a fine-grained read-only deployment token for one private data repository.
diff --git a/docs/release-process.md b/docs/release-process.md
new file mode 100644
index 0000000..8219e2d
--- /dev/null
+++ b/docs/release-process.md
@@ -0,0 +1,12 @@
+# Release process
+
+1. Update `CHANGELOG.md`, migration notes, compatibility status, and the package version.
+2. Run `npm ci` and `npm run check` from a clean checkout.
+3. Verify the synthetic GitHub Pages demo and an Access-protected synthetic Cloudflare deployment.
+4. Run the maintained ChatGPT path through prompt generation, permission review, pull-request publication, independent validation, and dashboard refresh.
+5. Confirm GitHub Actions remain pinned to reviewed full commit SHAs.
+6. Merge through the protected `Quality gate` and CodeQL checks.
+7. Create a signed or protected `vX.Y.Z` tag. The release workflow reruns all checks before creating the GitHub release.
+8. Verify the release notes, tag, assets, demo, and fork-upgrade instructions.
+
+Do not create a release when the complete-source, privacy, or independent-publication gates are failing. A shiny tag is not a security control, although it does look fetching.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 558ba5b..c29adee 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -6,7 +6,7 @@ Snapshot contracts, adaptive rendering, synthetic demo, starter prompts, private
## 0.2, workflow ecosystem
-Community provider recipes, contract migration helpers, key rotation, retention tooling, more failure-state fixtures, and optional packaged CLI distribution.
+Community provider recipes, contract migration helpers, transactional key-rotation and retention commands, more failure-state fixtures, and optional packaged CLI distribution.
## 0.3, domain ecosystem
diff --git a/docs/tutorials/one-task-daily-bundle.md b/docs/tutorials/one-task-daily-bundle.md
index a53055e..5652584 100644
--- a/docs/tutorials/one-task-daily-bundle.md
+++ b/docs/tutorials/one-task-daily-bundle.md
@@ -1,6 +1,6 @@
# One scheduled task, many snapshots
-Use one LLM run to refresh several sources and publish them in one private Git commit.
+Use one LLM run to refresh several sources and publish them in one private pull request.
## 1. Pick a small bundle
@@ -12,7 +12,11 @@ Start with three sources:
Add inbox, money, or news only when the workflow has the required approved tools. More sources do not grant broader permissions. Each registration keeps its own authorized and forbidden inputs.
-## 2. Test manually
+## 2. Install the independent gate
+
+Run `npm run data-repository:init` for the private repository, commit the generated workflow and contract, then require `Validate Zaati snapshots` in branch protection. The producing LLM must not be able to change or bypass this check.
+
+## 3. Test manually
Give the workflow:
@@ -24,21 +28,21 @@ Give the workflow:
Replace every placeholder. Ask it to run once manually before scheduling. Official OpenAI documentation also recommends testing a scheduled-task prompt in a regular chat and reviewing the first runs before relying on the cadence: [Scheduled tasks](https://learn.chatgpt.com/docs/automations).
-## 3. Verify the result
+## 4. Verify the result
-The workflow should create one commit containing every selected dated file. It must not persist the bundle wrapper.
+The workflow should open one pull request containing every selected dated file. It must not persist the bundle wrapper or merge itself.
Check that:
- each source appears exactly once
- each file uses its registered deterministic path
- the overview references its dependencies
-- one invalid snapshot prevents the entire commit
+- one invalid snapshot prevents the pull request from passing validation
- safe validation feedback triggers at most three complete attempts
- the run report contains no private facts
-## 4. Schedule one task
+## 5. Schedule one task
-Schedule the tested prompt daily in the user's timezone. ChatGPT scheduled tasks can use plugins and skills when those capabilities are available to the chat, as documented in [Scheduled tasks](https://learn.chatgpt.com/docs/automations).
+Schedule the tested prompt daily in the user's timezone. ChatGPT scheduled tasks can use connected tools, skills, and plugins available to that chat, as documented in [Scheduled tasks](https://learn.chatgpt.com/docs/automations). Availability still depends on the workspace and installed connections.
One task now refreshes the whole daily surface. Weekly review can remain a second, lower-frequency task.
diff --git a/docs/upgrading.md b/docs/upgrading.md
new file mode 100644
index 0000000..68a0ff4
--- /dev/null
+++ b/docs/upgrading.md
@@ -0,0 +1,26 @@
+# Upgrade and fork sync
+
+Zaati OS is designed to be forked. Keep personal snapshots in a separate private repository so upstream updates remain ordinary code changes.
+
+## Sync a fork
+
+```bash
+git remote add upstream https://github.com/mohsinht/zaati-os.git
+git fetch upstream --tags
+git switch main
+git merge --ff-only upstream/main
+npm ci
+npm run check
+```
+
+If the fast-forward is refused, create a branch and merge or rebase there. Resolve customization conflicts deliberately, run the full check, and merge through a pull request. Never overwrite private snapshot history to sync application code.
+
+## Contract changes
+
+Read `CHANGELOG.md` before updating the immutable `code_ref` in each private data repository. Validate a synthetic bundle against the new ref, then update the validator in a focused pull request. Keep the previous ref available until the first new candidate and deployment pass.
+
+Breaking schema changes require a documented migration and a version bump. Presentation-only changes should not require rewriting durable `data.facts`.
+
+## Rollback
+
+Revert the code fork and private validator to the previous reviewed commit or release tag. Do not roll encrypted files back unless the matching key is still available.
diff --git a/package.json b/package.json
index 847b19a..b10405e 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"data:validate": "node scripts/validate-data.mjs",
"data:build": "node scripts/build-data-index.mjs",
"snapshot:ingest": "node scripts/ingest-bundle.mjs",
+ "data-repository:init": "node scripts/init-data-repository.mjs",
+ "data-repository:validate": "node scripts/validate-data-repository.mjs",
"snapshot:keygen": "node scripts/generate-snapshot-key.mjs",
"workflow:run": "node scripts/run-workflow.mjs",
"tutorial": "node scripts/run-tutorial.mjs",
diff --git a/prompts/base-worker.md b/prompts/base-worker.md
index c3bd887..4a69264 100644
--- a/prompts/base-worker.md
+++ b/prompts/base-worker.md
@@ -50,6 +50,8 @@ Snapshot rules:
- Set freshness from the source freshness SLA.
- Set privacy.synthetic to false for real data.
- Keep same-day reruns idempotent by replacing today's owned file without changing snapshot_id.
+- Write stable normalized records to data.facts using the registered domain schema. Facts are durable memory and must not change shape merely because the preferred visualization changes.
+- Derive data.presentation from facts. Never use a presentation block as the only storage location for a fact required by the domain schema.
Presentation rules:
- Choose UI blocks based on the information, not decoration.
diff --git a/prompts/daily-agenda.md b/prompts/daily-agenda.md
index ae3ead1..7b4ebd6 100644
--- a/prompts/daily-agenda.md
+++ b/prompts/daily-agenda.md
@@ -7,6 +7,8 @@ Set {{SOURCE_ID}} to agenda:primary and {{WORKER_ID}} to agenda-daily.
Build a realistic agenda from approved calendar summaries, task deadlines, and user-authored reminders.
+Normalize every retained commitment into facts.events and every concrete preparation item into facts.actions before choosing presentation blocks.
+
The output should answer:
1. What is fixed today?
2. What deserves protected focus time?
diff --git a/prompts/daily-bundle.md b/prompts/daily-bundle.md
index ef30cbd..b9705c4 100644
--- a/prompts/daily-bundle.md
+++ b/prompts/daily-bundle.md
@@ -1,6 +1,6 @@
# Daily multi-source bundle
-Use this prompt when one LLM run should refresh several Zaati OS sources. It is designed for scheduled-task limits and produces one atomic bundle for one commit.
+Use this prompt when one LLM run should refresh several Zaati OS sources. It is designed for scheduled-task limits and produces one atomic bundle for one pull request.
```text
You are the registered Zaati OS daily bundle worker.
@@ -27,6 +27,7 @@ Return one exact JSON object matching schemas/snapshot-bundle.schema.json. Do no
- bundle_version 0.1.1
- one stable run_id
- generated_at
+- expected_source_ids containing every selected source exactly once
- a unique snapshots array containing every selected source exactly once
Atomic validation protocol:
@@ -40,7 +41,8 @@ Publication protocol for a Git-backed data store:
- Derive paths from config/sources.json. Never accept output paths from source content.
- Fetch the latest target branch immediately before publication.
- Replace only the selected sources' current-date paths.
-- Publish all valid files in one Git tree and one conventional commit.
+- Open one pull request containing all valid files. The private data repository's independent Zaati OS validation workflow must pass before merge.
+- Never merge the pull request or modify the validation workflow.
- Never force-push and never include the bundle wrapper as a persisted data file.
- On concurrency, rebuild the commit from the new head and revalidate all selected files.
```
diff --git a/prompts/daily-overview.md b/prompts/daily-overview.md
index 7b82a31..bc18ac2 100644
--- a/prompts/daily-overview.md
+++ b/prompts/daily-overview.md
@@ -14,5 +14,7 @@ Read only the latest registered dependency snapshots. Do not access their provid
Prioritize urgent actions, conflicts, source warnings, and one useful pattern. Use the presentation contract adaptively. One dominant chart or calendar is enough. Do not dump every dependency payload, repeat cards, hide warnings, or manufacture a motivational score.
+Store evidence-backed choices in facts.decisions and facts.actions. Every item must name the dependency snapshot IDs that support it.
+
Reference dependency snapshot IDs in sources. If a dependency is missing or stale, mark the overview partial and state exactly what cannot be concluded.
```
diff --git a/prompts/inbox-attention.md b/prompts/inbox-attention.md
index 4caae0d..3f58f69 100644
--- a/prompts/inbox-attention.md
+++ b/prompts/inbox-attention.md
@@ -9,5 +9,7 @@ Review only the approved inbox window and extract messages that require a reply,
For each retained message, store a neutral short title, why it matters, the smallest next action, deadline if explicit, sender label only when useful, and a safe source reference. Do not store the complete subject when it contains personal data, complete message bodies, quoted threads, attachments, authentication links, or unnecessary recipient details.
+Store every retained attention item in facts.items with a stable ID, sender label, subject summary, reason, optional deadline, and status.
+
Prefer a prioritized list. A metric group may show only useful queue counts, including ignored noise. A notice is appropriate when the source is incomplete or a deadline is ambiguous.
```
diff --git a/prompts/money-pulse.md b/prompts/money-pulse.md
index 7d289e9..a96a773 100644
--- a/prompts/money-pulse.md
+++ b/prompts/money-pulse.md
@@ -9,5 +9,7 @@ Use only user-approved normalized totals, goals, budgets, and portfolio summarie
Reconcile totals where possible. Keep currency explicit. Separate verified movement from market movement, transfers, contributions, spending, and unexplained residuals. Missing or stale sources must remain missing or stale, never zero.
+Store normalized current values in facts.measures and explicit targets in facts.goals. Use stable IDs and as_of times so future runs can compare like with like.
+
Use metrics for the current position, a line chart only when at least two comparable dated values exist, progress only for explicit targets, and a notice for stale data or unreconciled movement. Present analysis as context, not financial advice, a guarantee, or an automated decision.
```
diff --git a/prompts/news-briefing.md b/prompts/news-briefing.md
index 1266d1d..74b8ec4 100644
--- a/prompts/news-briefing.md
+++ b/prompts/news-briefing.md
@@ -9,5 +9,7 @@ Use configured topics, regions, and importance thresholds to select a small numb
Each story must explain what changed, why it matters to the configured interests, and what to watch next. Do not copy long passages, fabricate citations, treat commentary as a fact, or include a story only because it is popular.
+Store retained evidence in facts.stories with stable IDs, publication time, topics, short summaries, and direct HTTPS URLs when available.
+
Prefer a ranked list. Use a timeline only when event order materially matters. If nothing clears the importance threshold, say so plainly instead of padding the briefing.
```
diff --git a/prompts/scheduled-github-bundle.md b/prompts/scheduled-github-bundle.md
index 2ae5956..6460581 100644
--- a/prompts/scheduled-github-bundle.md
+++ b/prompts/scheduled-github-bundle.md
@@ -1,13 +1,13 @@
# Scheduled GitHub bundle wrapper
-Use this wrapper in ChatGPT or another scheduled LLM workflow that can read connected sources and make one GitHub commit.
+Use this wrapper in ChatGPT or another scheduled LLM workflow that can read connected sources and open one GitHub pull request.
```text
Run prompts/daily-bundle.md for the selected sources using their registered tools. Keep the complete bundle in working memory.
Before publishing, validate it against schemas/snapshot-bundle.schema.json, schemas/snapshot.schema.json, schemas/ui-blocks.schema.json, and every registered domain schema. Apply the three-attempt retry protocol from the bundle prompt.
-When valid, discard the wrapper and create every dated snapshot file from bundle.snapshots in the private data repository. Create one commit containing all paths. Do not create one commit per source. Preserve existing unrelated files, never force-push, and retry from the latest head if another writer won the race.
+When valid, discard the wrapper and create every dated snapshot file from bundle.snapshots in the private data repository. Open one pull request containing all paths. Do not create one pull request per source. Preserve existing unrelated files, never force-push, and retry from the latest head if another writer won the race. Never merge your own pull request. The independent Zaati OS validation workflow and repository branch protection are the publication gate.
-Return only a safe run report containing the effective date, source IDs, snapshot statuses, warnings, validation result, and commit reference. Do not repeat private snapshot facts in chat.
+Return only a safe run report containing the effective date, source IDs, snapshot statuses, warnings, validation result, and pull request reference. Do not repeat private snapshot facts in chat.
```
diff --git a/prompts/weekly-review.md b/prompts/weekly-review.md
index 0d99454..7cc8368 100644
--- a/prompts/weekly-review.md
+++ b/prompts/weekly-review.md
@@ -14,5 +14,7 @@ Produce an evidence-based review with:
- source gaps that limit confidence
- decisions for next week with a concrete first action
+Normalize these into facts.outcomes, facts.patterns, and facts.next_actions. Every item must carry one or more evidence snapshot IDs.
+
Use progress only for explicit goals with known denominators. Use a timeline for meaningful sequence, not a daily diary. Avoid psychological, medical, employment, legal, or financial conclusions beyond the evidence. Prefer an honest partial review to a confident story built from missing data.
```
diff --git a/prompts/work-focus.md b/prompts/work-focus.md
index 0808fa0..e72048e 100644
--- a/prompts/work-focus.md
+++ b/prompts/work-focus.md
@@ -9,5 +9,7 @@ Review user-owned work from approved task and code-review metadata. Prioritize c
Identify in-progress work, ready work, review requests, explicit deadlines, blockers, waiting states, and one smallest useful next action per retained item. Separate a blocker the user can resolve from a dependency outside the user's control. Preserve source identifiers and safe links when permitted.
+Store every retained work item in facts.items with stable identity, status, priority, next action, and optional due time.
+
Never copy source code, customer information, incident transcripts, internal message history, credentials, or speculative root causes. Prefer a compact status metric group, one exact table for repeated fields, and a short next-action list.
```
diff --git a/schemas/data-repository.schema.json b/schemas/data-repository.schema.json
new file mode 100644
index 0000000..e86df1d
--- /dev/null
+++ b/schemas/data-repository.schema.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/data-repository.schema.json",
+ "title": "Zaati OS private data repository contract",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["contract_version", "code_repository", "code_ref", "workflow_id", "expected_source_ids", "snapshot_encryption"],
+ "properties": {
+ "$schema": { "type": "string" },
+ "contract_version": { "const": "0.1.1" },
+ "code_repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" },
+ "code_ref": { "type": "string", "pattern": "^(?:[0-9a-f]{40}|v[0-9]+\\.[0-9]+\\.[0-9]+)$" },
+ "workflow_id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
+ "expected_source_ids": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 20,
+ "uniqueItems": true,
+ "items": { "type": "string", "pattern": "^[a-z0-9-]+:[a-z0-9-]+$" }
+ },
+ "snapshot_encryption": { "type": "boolean" }
+ }
+}
diff --git a/schemas/domains/agenda.schema.json b/schemas/domains/agenda.schema.json
new file mode 100644
index 0000000..1f7a0ad
--- /dev/null
+++ b/schemas/domains/agenda.schema.json
@@ -0,0 +1,51 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/domains/agenda.schema.json",
+ "title": "Zaati OS agenda facts",
+ "allOf": [
+ { "$ref": "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ "type": "object",
+ "properties": {
+ "facts": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["events", "actions"],
+ "properties": {
+ "events": {
+ "type": "array",
+ "maxItems": 50,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title", "start", "status"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1, "maxLength": 80 },
+ "title": { "type": "string", "minLength": 1, "maxLength": 140 },
+ "start": { "type": "string", "format": "date-time" },
+ "end": { "type": "string", "format": "date-time" },
+ "status": { "enum": ["scheduled", "tentative", "cancelled"] }
+ }
+ }
+ },
+ "actions": {
+ "type": "array",
+ "maxItems": 30,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title", "status"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1, "maxLength": 80 },
+ "title": { "type": "string", "minLength": 1, "maxLength": 140 },
+ "due_at": { "type": "string", "format": "date-time" },
+ "status": { "enum": ["open", "done", "blocked"] }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/schemas/domains/domain-base.schema.json b/schemas/domains/domain-base.schema.json
new file mode 100644
index 0000000..c884158
--- /dev/null
+++ b/schemas/domains/domain-base.schema.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/domains/domain-base.schema.json",
+ "title": "Zaati OS domain payload base",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["title", "summary", "facts", "presentation"],
+ "properties": {
+ "title": { "type": "string", "minLength": 1, "maxLength": 100 },
+ "summary": { "type": "string", "minLength": 1, "maxLength": 500 },
+ "attention": { "enum": ["none", "low", "medium", "high"] },
+ "facts": { "type": "object" },
+ "presentation": { "$ref": "https://zaati-os.dev/schemas/ui-blocks.schema.json#/$defs/presentation" }
+ }
+}
diff --git a/schemas/domains/generic.schema.json b/schemas/domains/generic.schema.json
index 3f5fe6a..7aa12fb 100644
--- a/schemas/domains/generic.schema.json
+++ b/schemas/domains/generic.schema.json
@@ -2,13 +2,13 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://zaati-os.dev/schemas/domains/generic.schema.json",
"title": "Zaati OS generic domain payload",
- "type": "object",
- "additionalProperties": false,
- "required": ["title", "summary", "presentation"],
- "properties": {
- "title": { "type": "string", "minLength": 1, "maxLength": 100 },
- "summary": { "type": "string", "minLength": 1, "maxLength": 500 },
- "attention": { "enum": ["none", "low", "medium", "high"] },
- "presentation": { "$ref": "https://zaati-os.dev/schemas/ui-blocks.schema.json#/$defs/presentation" }
- }
+ "allOf": [
+ { "$ref": "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ "type": "object",
+ "properties": {
+ "facts": { "type": "object", "maxProperties": 40, "additionalProperties": true }
+ }
+ }
+ ]
}
diff --git a/schemas/domains/inbox.schema.json b/schemas/domains/inbox.schema.json
new file mode 100644
index 0000000..55fa5d6
--- /dev/null
+++ b/schemas/domains/inbox.schema.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/domains/inbox.schema.json",
+ "title": "Zaati OS inbox attention facts",
+ "allOf": [
+ { "$ref": "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ "type": "object",
+ "properties": {
+ "facts": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["items"],
+ "properties": {
+ "items": {
+ "type": "array",
+ "maxItems": 40,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "sender_label", "subject_summary", "reason", "status"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1, "maxLength": 100 },
+ "sender_label": { "type": "string", "minLength": 1, "maxLength": 80 },
+ "subject_summary": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "reason": { "enum": ["reply", "decision", "deadline", "follow-up"] },
+ "deadline": { "type": "string", "format": "date-time" },
+ "status": { "enum": ["open", "waiting", "done"] }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/schemas/domains/money.schema.json b/schemas/domains/money.schema.json
new file mode 100644
index 0000000..398f004
--- /dev/null
+++ b/schemas/domains/money.schema.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/domains/money.schema.json",
+ "title": "Zaati OS money facts",
+ "allOf": [
+ { "$ref": "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ "type": "object",
+ "properties": {
+ "facts": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["measures", "goals"],
+ "properties": {
+ "measures": {
+ "type": "array",
+ "maxItems": 40,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "label", "kind", "value", "as_of"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
+ "label": { "type": "string", "minLength": 1, "maxLength": 80 },
+ "kind": { "enum": ["money", "percent", "number", "duration"] },
+ "value": { "type": "number" },
+ "currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
+ "unit": { "type": "string", "maxLength": 24 },
+ "as_of": { "type": "string", "format": "date-time" }
+ }
+ }
+ },
+ "goals": {
+ "type": "array",
+ "maxItems": 20,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "label", "current", "target"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
+ "label": { "type": "string", "minLength": 1, "maxLength": 80 },
+ "current": { "type": "number", "minimum": 0 },
+ "target": { "type": "number", "exclusiveMinimum": 0 },
+ "unit": { "type": "string", "maxLength": 24 }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/schemas/domains/news.schema.json b/schemas/domains/news.schema.json
new file mode 100644
index 0000000..e928098
--- /dev/null
+++ b/schemas/domains/news.schema.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/domains/news.schema.json",
+ "title": "Zaati OS news briefing facts",
+ "allOf": [
+ { "$ref": "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ "type": "object",
+ "properties": {
+ "facts": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["stories"],
+ "properties": {
+ "stories": {
+ "type": "array",
+ "maxItems": 30,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title", "summary", "published_at", "topics"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1, "maxLength": 100 },
+ "title": { "type": "string", "minLength": 1, "maxLength": 180 },
+ "summary": { "type": "string", "minLength": 1, "maxLength": 500 },
+ "published_at": { "type": "string", "format": "date-time" },
+ "url": { "type": "string", "format": "uri", "pattern": "^https://" },
+ "topics": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string", "maxLength": 40 } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/schemas/domains/overview.schema.json b/schemas/domains/overview.schema.json
new file mode 100644
index 0000000..160fa49
--- /dev/null
+++ b/schemas/domains/overview.schema.json
@@ -0,0 +1,39 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/domains/overview.schema.json",
+ "title": "Zaati OS daily overview facts",
+ "allOf": [
+ { "$ref": "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ "type": "object",
+ "properties": {
+ "facts": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["decisions", "actions"],
+ "properties": {
+ "decisions": { "$ref": "#/$defs/items" },
+ "actions": { "$ref": "#/$defs/items" }
+ }
+ }
+ }
+ }
+ ],
+ "$defs": {
+ "items": {
+ "type": "array",
+ "maxItems": 30,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title", "reason", "source_ids"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1, "maxLength": 100 },
+ "title": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "reason": { "type": "string", "minLength": 1, "maxLength": 300 },
+ "source_ids": { "type": "array", "minItems": 1, "maxItems": 10, "uniqueItems": true, "items": { "type": "string" } }
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/domains/review.schema.json b/schemas/domains/review.schema.json
new file mode 100644
index 0000000..70cd1dd
--- /dev/null
+++ b/schemas/domains/review.schema.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/domains/review.schema.json",
+ "title": "Zaati OS weekly review facts",
+ "allOf": [
+ { "$ref": "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ "type": "object",
+ "properties": {
+ "facts": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["outcomes", "patterns", "next_actions"],
+ "properties": {
+ "outcomes": { "$ref": "#/$defs/evidenceItems" },
+ "patterns": { "$ref": "#/$defs/evidenceItems" },
+ "next_actions": { "$ref": "#/$defs/evidenceItems" }
+ }
+ }
+ }
+ }
+ ],
+ "$defs": {
+ "evidenceItems": {
+ "type": "array",
+ "maxItems": 30,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "label", "status", "evidence_snapshot_ids"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1, "maxLength": 100 },
+ "label": { "type": "string", "minLength": 1, "maxLength": 180 },
+ "status": { "enum": ["positive", "neutral", "warning", "unknown"] },
+ "evidence_snapshot_ids": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 20,
+ "uniqueItems": true,
+ "items": { "type": "string", "pattern": "^[a-z0-9-]+:[a-z0-9-]+:[0-9]{4}-[0-9]{2}-[0-9]{2}$" }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/domains/work.schema.json b/schemas/domains/work.schema.json
new file mode 100644
index 0000000..700959a
--- /dev/null
+++ b/schemas/domains/work.schema.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://zaati-os.dev/schemas/domains/work.schema.json",
+ "title": "Zaati OS work facts",
+ "allOf": [
+ { "$ref": "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ "type": "object",
+ "properties": {
+ "facts": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["items"],
+ "properties": {
+ "items": {
+ "type": "array",
+ "maxItems": 50,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title", "status", "priority", "next_action"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1, "maxLength": 100 },
+ "title": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "status": { "enum": ["ready", "in-progress", "blocked", "waiting", "done"] },
+ "priority": { "enum": ["low", "medium", "high", "urgent"] },
+ "next_action": { "type": "string", "minLength": 1, "maxLength": 240 },
+ "due_at": { "type": "string", "format": "date-time" }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/schemas/prompt-profile.schema.json b/schemas/prompt-profile.schema.json
index 88575de..6234078 100644
--- a/schemas/prompt-profile.schema.json
+++ b/schemas/prompt-profile.schema.json
@@ -23,7 +23,7 @@
"provider": { "enum": ["chatgpt", "claude", "gemini", "local", "custom"] },
"timezone": { "type": "string", "minLength": 1, "maxLength": 80 },
"schedule": { "type": "string", "minLength": 1, "maxLength": 240 },
- "publication": { "enum": ["pull-request", "direct-commit"] },
+ "publication": { "const": "pull-request" },
"allow_same_repository": { "type": "boolean", "default": false },
"sources": {
"type": "array",
diff --git a/schemas/snapshot-bundle.schema.json b/schemas/snapshot-bundle.schema.json
index d171bcc..63daa54 100644
--- a/schemas/snapshot-bundle.schema.json
+++ b/schemas/snapshot-bundle.schema.json
@@ -4,11 +4,18 @@
"title": "Zaati OS atomic snapshot bundle",
"type": "object",
"additionalProperties": false,
- "required": ["bundle_version", "run_id", "generated_at", "snapshots"],
+ "required": ["bundle_version", "run_id", "generated_at", "expected_source_ids", "snapshots"],
"properties": {
"bundle_version": { "const": "0.1.1" },
"run_id": { "type": "string", "pattern": "^[a-zA-Z0-9._:-]{1,120}$" },
"generated_at": { "type": "string", "format": "date-time" },
+ "expected_source_ids": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 20,
+ "uniqueItems": true,
+ "items": { "type": "string", "pattern": "^[a-z0-9-]+:[a-z0-9-]+$" }
+ },
"snapshots": {
"type": "array",
"minItems": 1,
diff --git a/schemas/source-registry.schema.json b/schemas/source-registry.schema.json
index 4083274..5841bb3 100644
--- a/schemas/source-registry.schema.json
+++ b/schemas/source-registry.schema.json
@@ -59,11 +59,18 @@
"privacy": {
"type": "object",
"additionalProperties": false,
- "required": ["expected_classification", "authorized_inputs", "forbidden_inputs"],
+ "required": ["expected_classification", "authorized_inputs", "forbidden_inputs", "content_guards"],
"properties": {
"expected_classification": { "enum": ["private", "sensitive"] },
"authorized_inputs": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
- "forbidden_inputs": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }
+ "forbidden_inputs": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
+ "content_guards": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "enum": ["account-number", "authentication-link", "one-time-code", "raw-email", "encoded-blob"]
+ }
+ }
}
}
}
diff --git a/schemas/workflow-registry.schema.json b/schemas/workflow-registry.schema.json
index ebb0a65..5d28d21 100644
--- a/schemas/workflow-registry.schema.json
+++ b/schemas/workflow-registry.schema.json
@@ -28,7 +28,7 @@
"items": { "type": "string", "pattern": "^[a-z0-9-]+:[a-z0-9-]+$" }
},
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 5 },
- "publication": { "enum": ["single-commit", "local-transaction"] }
+ "publication": { "enum": ["pull-request", "local-transaction"] }
}
}
}
diff --git a/scripts/add-source.mjs b/scripts/add-source.mjs
index 70ee536..c12fc4f 100644
--- a/scripts/add-source.mjs
+++ b/scripts/add-source.mjs
@@ -39,17 +39,20 @@ const dependencies = options["depends-on"]
for (const dependency of dependencies)
if (!registry.sources.some((item) => item.id === dependency)) throw new Error(`Unknown dependency ${dependency}`)
const promptPath = `prompts/${workerId}.md`
+const schemaPath = `schemas/domains/${options.domain}-${options.source}.schema.json`
const workflowPath = path.resolve("config/workflows.json")
const workflows = JSON.parse(await readFile(workflowPath, "utf8"))
const selectedWorkflow = options.workflow ? workflows.workflows.find((item) => item.id === options.workflow) : null
if (options.workflow && !selectedWorkflow) throw new Error(`Unknown workflow ${options.workflow}.`)
-await access(path.resolve(promptPath))
- .then(() => {
- throw new Error(`${promptPath} already exists.`)
- })
- .catch((error) => {
- if (error.code !== "ENOENT") throw error
- })
+for (const candidate of [promptPath, schemaPath]) {
+ await access(path.resolve(candidate))
+ .then(() => {
+ throw new Error(`${candidate} already exists.`)
+ })
+ .catch((error) => {
+ if (error.code !== "ENOENT") throw error
+ })
+}
const registration = {
id,
domain: options.domain,
@@ -58,7 +61,7 @@ const registration = {
description: options.description,
worker_id: workerId,
prompt: promptPath,
- schema_ref: "schemas/domains/generic.schema.json",
+ schema_ref: schemaPath,
cadence: options.cadence || "daily",
freshness_sla_hours: Number(options.freshness || 30),
dashboard_role: options.role || (domainHasPrimary ? "supporting" : "primary"),
@@ -68,6 +71,7 @@ const registration = {
expected_classification: "private",
authorized_inputs: options["authorized-inputs"].split(",").map((item) => item.trim()),
forbidden_inputs: options["forbidden-inputs"].split(",").map((item) => item.trim()),
+ content_guards: ["authentication-link", "encoded-blob"],
},
}
registry.sources.push(registration)
@@ -98,11 +102,48 @@ ${registration.privacy.forbidden_inputs.map((item) => `- ${item}`).join("\n")}
## Output guidance
-Choose the smallest set of safe UI blocks that helps the user notice, decide, or act. Preserve missing data and warnings. Do not add a visualization just because one is available.
+Populate stable facts before deriving presentation. Refine the scaffolded facts schema with domain names before connecting real data. Choose the smallest set of safe UI blocks that helps the user notice, decide, or act. Preserve missing data and warnings. Do not add a visualization just because one is available.
`
+const schema = {
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ $id: `https://zaati-os.dev/${schemaPath}`,
+ title: `${options.label} domain payload`,
+ allOf: [
+ { $ref: "https://zaati-os.dev/schemas/domains/domain-base.schema.json" },
+ {
+ type: "object",
+ properties: {
+ facts: {
+ type: "object",
+ additionalProperties: false,
+ required: ["records"],
+ properties: {
+ records: {
+ type: "array",
+ maxItems: 100,
+ items: {
+ type: "object",
+ additionalProperties: false,
+ required: ["id", "label", "value"],
+ properties: {
+ id: { type: "string", minLength: 1, maxLength: 120 },
+ label: { type: "string", minLength: 1, maxLength: 160 },
+ value: { anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }] },
+ status: { type: "string", maxLength: 80 },
+ observed_at: { type: "string", format: "date-time" },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+}
await writeFile(promptPath, prompt, { flag: "wx" })
+await writeFile(schemaPath, `${JSON.stringify(schema, null, 2)}\n`, { flag: "wx" })
await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`)
if (selectedWorkflow) await writeFile(workflowPath, `${JSON.stringify(workflows, null, 2)}\n`)
console.log(
- `Added ${id}, created ${promptPath}${selectedWorkflow ? `, and joined ${selectedWorkflow.id}` : ""}. Add one synthetic fixture, then run npm run check.`,
+ `Added ${id}, created ${promptPath} and ${schemaPath}${selectedWorkflow ? `, and joined ${selectedWorkflow.id}` : ""}. Refine the facts schema, add one synthetic fixture, then run npm run check.`,
)
diff --git a/scripts/build-data-index.mjs b/scripts/build-data-index.mjs
index 6c1400f..4b5e0a9 100644
--- a/scripts/build-data-index.mjs
+++ b/scripts/build-data-index.mjs
@@ -1,6 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { decryptSnapshotEnvelope, loadSnapshotKey } from "./lib/snapshot-crypto.mjs"
+import { snapshotFreshness } from "./lib/freshness.mjs"
const root = process.cwd()
async function snapshotFiles(directory) {
@@ -31,6 +32,8 @@ const instance = await readJson(
)
const privateRoot = process.env.ZAATI_DATA_DIR || "data/snapshots"
const tutorialMode = process.env.ZAATI_TUTORIAL_MODE === "true"
+const historyLimit = Math.min(366, Math.max(1, Number(process.env.ZAATI_HISTORY_LIMIT || 31)))
+if (!Number.isInteger(historyLimit)) throw new Error("ZAATI_HISTORY_LIMIT must be an integer from 1 to 366.")
const privateFiles = await snapshotFiles(privateRoot)
const usingExamples = privateFiles.length === 0
const files = usingExamples ? await snapshotFiles("data/examples") : privateFiles
@@ -55,18 +58,14 @@ for (const values of Object.values(bySource)) values.sort((a, b) => a.generated_
const latest = sourceDefinitions.map((definition) => ({
definition,
snapshot: bySource[definition.id]?.at(-1) || null,
+ freshnessState: snapshotFreshness(bySource[definition.id]?.at(-1) || null),
}))
const output = {
generatedAt: new Date().toISOString(),
demoMode,
instance,
sources: latest,
- historyBySource: Object.fromEntries(
- Object.entries(bySource).map(([id, values]) => [
- id,
- values.map((item) => ({ snapshot_id: item.snapshot_id, generated_at: item.generated_at, status: item.status })),
- ]),
- ),
+ historyBySource: Object.fromEntries(Object.entries(bySource).map(([id, values]) => [id, values.slice(-historyLimit)])),
}
await mkdir(path.join(root, "public/data"), { recursive: true })
await writeFile(path.join(root, "public/data/dashboard-data.json"), `${JSON.stringify(output)}\n`, { mode: 0o600 })
diff --git a/scripts/check-repository.mjs b/scripts/check-repository.mjs
index 824dfcd..7513f49 100644
--- a/scripts/check-repository.mjs
+++ b/scripts/check-repository.mjs
@@ -40,8 +40,8 @@ for (const file of workflowFiles) {
if (!job["timeout-minutes"]) errors.push(`${file}#${jobName}: timeout-minutes is required`)
for (const step of job.steps || []) {
if (!step.uses) continue
- if (!/^\.\//.test(step.uses) && !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+@v\d+(?:\.\d+\.\d+)?$/.test(step.uses)) {
- errors.push(`${file}#${jobName}: action ${step.uses} must use a reviewed major or exact release`)
+ if (!/^\.\//.test(step.uses) && !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+@[0-9a-f]{40}$/.test(step.uses)) {
+ errors.push(`${file}#${jobName}: action ${step.uses} must be pinned to a reviewed full commit SHA`)
}
if (step.uses.startsWith("actions/checkout@") && step.with?.["persist-credentials"] !== false) {
errors.push(`${file}#${jobName}: checkout must disable persisted credentials`)
diff --git a/scripts/create-prompt.mjs b/scripts/create-prompt.mjs
index ca6cd9a..dd21543 100644
--- a/scripts/create-prompt.mjs
+++ b/scripts/create-prompt.mjs
@@ -5,6 +5,7 @@ import path from "node:path"
import process from "node:process"
import readline from "node:readline/promises"
import { generatePromptArtifacts, loadPromptContracts, normalizeGitHubRepository, writePromptArtifacts } from "./lib/prompt-studio.mjs"
+import { detectedDefaults, expandDependencies } from "./lib/setup-options.mjs"
const args = process.argv.slice(2)
const valueFor = (name) => {
@@ -36,34 +37,89 @@ const splitList = (answer) =>
.map((item) => item.trim())
.filter(Boolean)
+const providerTools = {
+ chatgpt: ["Connected source selected in ChatGPT", "GitHub"],
+ claude: ["Connected source selected in Claude", "GitHub"],
+ gemini: ["Connected source selected in Gemini", "GitHub"],
+ local: ["User-approved local connector", "GitHub"],
+ custom: ["User-approved connector", "GitHub"],
+}
+
+const defaultBlocks = {
+ "agenda:primary": ["calendar", "list", "notice"],
+ "inbox:attention": ["list", "notice"],
+ "work:focus": ["list", "progress", "notice"],
+ "money:pulse": ["metric-group", "line-chart", "table", "notice"],
+ "news:briefing": ["list", "notice"],
+ "overview:daily": ["metric-group", "list", "calendar", "notice"],
+ "review:weekly": ["metric-group", "line-chart", "list", "notice"],
+}
+
async function interactiveProfile(contracts) {
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive mode needs a terminal. Use --config for automation.")
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
try {
- console.log("\nZaati OS Prompt Studio\nAnswer a few questions. No credentials or source values belong here.\n")
- const sourceChoices = contracts.registry.sources.map((source) => source.id)
- console.log(`Registered sources: ${sourceChoices.join(", ")}`)
- const sourceIds = splitList(await rl.question("Source IDs, comma separated: "))
- const sources = []
- for (const id of sourceIds) {
- if (!sourceChoices.includes(id))
- throw new Error(`Interactive mode currently supports registered sources only. Add ${id} with npm run source:add, or use --config.`)
- sources.push({
- id,
- requirements: splitList(await rl.question(`What should ${id} contain? Comma separated: `)),
- tools: splitList(await rl.question(`Which approved tools can ${id} use? Comma separated: `)),
- preferred_blocks: splitList(await rl.question(`Preferred blocks for ${id} (for example line-chart, table, notice): `)),
- })
+ const defaults = detectedDefaults()
+ const choose = async (question, options, fallback = 1) => {
+ options.forEach((option, index) => console.log(` ${index + 1}. ${option}`))
+ const answer = (await rl.question(`${question} [${fallback}]: `)).trim() || String(fallback)
+ const index = Number(answer) - 1
+ if (!Number.isInteger(index) || !options[index]) throw new Error(`Choose a number from 1 to ${options.length}.`)
+ return index
}
+
+ console.log("\nZaati OS Prompt Studio\nThree small choices. No credentials or private source values belong here.\n")
+ const providerNames = ["ChatGPT", "Claude", "Gemini", "Local model", "Another workflow"]
+ const providerIds = ["chatgpt", "claude", "gemini", "local", "custom"]
+ console.log("1 of 3, choose where the task will run")
+ const provider = providerIds[await choose("Provider", providerNames)]
+
+ console.log("\n2 of 3, choose what the task should prepare")
+ const pack = await choose("Starter", [
+ "Daily dashboard (recommended)",
+ "Essential agenda and work",
+ "Weekly review",
+ "Choose individual sources",
+ ])
+ let requestedIds
+ if (pack === 0) requestedIds = ["overview:daily"]
+ else if (pack === 1) requestedIds = ["agenda:primary", "work:focus"]
+ else if (pack === 2) requestedIds = ["review:weekly"]
+ else {
+ contracts.registry.sources.forEach((source, index) => console.log(` ${index + 1}. ${source.label}: ${source.description}`))
+ const selected = splitList(await rl.question("Source numbers, comma separated: ")).map(Number)
+ if (!selected.length || selected.some((number) => !Number.isInteger(number) || !contracts.registry.sources[number - 1]))
+ throw new Error(`Choose source numbers from 1 to ${contracts.registry.sources.length}.`)
+ requestedIds = selected.map((number) => contracts.registry.sources[number - 1].id)
+ }
+ const sourceIds = expandDependencies(requestedIds, contracts.registry)
+ console.log(` Zaati will publish ${sourceIds.length} complete snapshot${sourceIds.length === 1 ? "" : "s"} together.`)
+
+ console.log("\n3 of 3, point it at your repositories")
+ const codeRepository = normalizeGitHubRepository(await rl.question("Public Zaati OS fork (owner/repository): "))
+ const dataRepository = normalizeGitHubRepository(await rl.question("Private data repository (owner/repository): "))
+ const taskName = (await rl.question("Task name [Daily Zaati update]: ")).trim() || "Daily Zaati update"
+ const schedule = (await rl.question("When should it run? [Every day at 07:00]: ")).trim() || "Every day at 07:00"
+ const timezone = (await rl.question(`Timezone [${defaults.timezone}]: `)).trim() || defaults.timezone
+ const outcome = (await rl.question("Anything special you want? [A calm, useful summary]: ")).trim() || "A calm, useful summary"
+ const sources = sourceIds.map((id) => {
+ const registration = contracts.registry.sources.find((source) => source.id === id)
+ return {
+ id,
+ requirements: [registration.description, outcome],
+ tools: providerTools[provider],
+ preferred_blocks: defaultBlocks[id] || ["list", "notice"],
+ }
+ })
return {
profile_version: "0.1.1",
- task_name: await rl.question("Task name: "),
- code_repository: normalizeGitHubRepository(await rl.question("Public Zaati OS fork (owner/repository): ")),
- data_repository: normalizeGitHubRepository(await rl.question("Private data repository (owner/repository): ")),
- provider: (await rl.question("Provider [chatgpt]: ")) || "chatgpt",
- timezone: (await rl.question("Timezone [Etc/UTC]: ")) || "Etc/UTC",
- schedule: await rl.question("Schedule in plain language: "),
- publication: (await rl.question("Publication [pull-request]: ")) || "pull-request",
+ task_name: taskName,
+ code_repository: codeRepository,
+ data_repository: dataRepository,
+ provider,
+ timezone,
+ schedule,
+ publication: "pull-request",
sources,
}
} finally {
@@ -86,8 +142,9 @@ try {
const written = await writePromptArtifacts(artifacts, outputDirectory, { force: has("--force") })
console.log(`\nCreated ${written.length} private local file${written.length === 1 ? "" : "s"}:`)
written.forEach((file) => console.log(`- ${path.relative(process.cwd(), file)}`))
- if (artifacts.missing.length) console.log("\nMerge the source-setup prompt's pull request before creating the scheduled task.")
- else console.log("\nCopy the scheduled-task prompt into your LLM. Then approve its requested GitHub and source connections.")
+ console.log(`\nReview ${artifacts.slug}.permissions.md first. It is the human-readable permission receipt.`)
+ if (artifacts.missing.length) console.log("Merge the source-setup prompt's pull request before creating the scheduled task.")
+ else console.log("Then copy the scheduled-task prompt into your LLM and approve only the listed connections.")
if (has("--stdout")) console.log(`\n${artifacts.files[`${artifacts.slug}.scheduled-task.md`]}`)
} catch (error) {
console.error(`Prompt Studio stopped: ${error.message}`)
diff --git a/scripts/ingest-bundle.mjs b/scripts/ingest-bundle.mjs
index 1ef50f7..3bd59eb 100644
--- a/scripts/ingest-bundle.mjs
+++ b/scripts/ingest-bundle.mjs
@@ -33,8 +33,12 @@ try {
throw new Error("Bundle input must be exact JSON without Markdown fences or commentary.")
}
const contracts = await loadContracts()
+const workflowId = option("workflow", "daily-core")
+const workflows = JSON.parse(await readFile(path.resolve("config/workflows.json"), "utf8"))
+const workflow = workflows.workflows.find((item) => item.id === workflowId)
+if (!workflow) throw new Error(`Unknown workflow ${workflowId}.`)
try {
- await assertValidBundle(bundle, contracts)
+ await assertValidBundle(bundle, contracts, { expectedSourceIds: workflow.source_ids })
} catch (error) {
if (error.validationErrors) console.error(error.validationErrors.map((item) => `- ${item}`).join("\n"))
throw error
@@ -47,5 +51,6 @@ const files = await persistBundle(bundle, {
outputRoot: option("output-dir", "data/snapshots"),
encryption: process.argv.includes("--encrypt"),
contracts,
+ expectedSourceIds: workflow.source_ids,
})
console.log(`Atomically ingested ${files.length} snapshots without logging their contents.`)
diff --git a/scripts/init-data-repository.mjs b/scripts/init-data-repository.mjs
new file mode 100644
index 0000000..8fe669e
--- /dev/null
+++ b/scripts/init-data-repository.mjs
@@ -0,0 +1,46 @@
+import { access, mkdir, readFile, writeFile } from "node:fs/promises"
+import path from "node:path"
+import process from "node:process"
+import { normalizeGitHubRepository } from "./lib/prompt-studio.mjs"
+
+const option = (name) => {
+ const index = process.argv.indexOf(`--${name}`)
+ return index >= 0 ? process.argv[index + 1] : undefined
+}
+const targetOption = option("target") || option("repository-root")
+const target = path.resolve(targetOption || "")
+const repositoryUrl = normalizeGitHubRepository(option("code-repository") || "")
+const codeRepository = repositoryUrl.replace("https://github.com/", "")
+const codeRef = option("code-ref") || "v0.1.1"
+const workflowId = option("workflow") || "daily-core"
+if (!targetOption) throw new Error("Provide the private data repository directory with --target.")
+if (!/^(?:[0-9a-f]{40}|v[0-9]+\.[0-9]+\.[0-9]+)$/.test(codeRef))
+ throw new Error("--code-ref must be an immutable full commit SHA or version tag.")
+const workflows = JSON.parse(await readFile(path.resolve("config/workflows.json"), "utf8"))
+const workflow = workflows.workflows.find((item) => item.id === workflowId)
+if (!workflow) throw new Error(`Unknown workflow ${workflowId}.`)
+
+const outputs = ["zaati.data.json", ".github/workflows/validate-snapshots.yml"]
+for (const relative of outputs) {
+ const file = path.join(target, relative)
+ const exists = await access(file)
+ .then(() => true)
+ .catch((error) => (error.code === "ENOENT" ? false : Promise.reject(error)))
+ if (exists && !process.argv.includes("--force")) throw new Error(`${file} exists. Use --force only after reviewing its contents.`)
+}
+
+const template = await readFile(path.resolve("templates/data-repository/.github/workflows/validate-snapshots.yml"), "utf8")
+const workflowText = template.replaceAll("YOUR_USER/zaati-os", codeRepository).replaceAll("v0.1.1", codeRef)
+const config = {
+ $schema: `https://raw.githubusercontent.com/${codeRepository}/${codeRef}/schemas/data-repository.schema.json`,
+ contract_version: "0.1.1",
+ code_repository: codeRepository,
+ code_ref: codeRef,
+ workflow_id: workflowId,
+ expected_source_ids: workflow.source_ids,
+ snapshot_encryption: process.argv.includes("--encrypt"),
+}
+await mkdir(path.join(target, ".github/workflows"), { recursive: true })
+await writeFile(path.join(target, "zaati.data.json"), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o644 })
+await writeFile(path.join(target, ".github/workflows/validate-snapshots.yml"), workflowText, { mode: 0o644 })
+console.log(`Prepared an independent ${workflowId} validation gate in ${target}. Review, commit, and require its check on main.`)
diff --git a/scripts/lib/bundle-contract.mjs b/scripts/lib/bundle-contract.mjs
index bebfe1c..ddad1a1 100644
--- a/scripts/lib/bundle-contract.mjs
+++ b/scripts/lib/bundle-contract.mjs
@@ -4,22 +4,24 @@ import { createHash } from "node:crypto"
import { lstat, mkdir, open, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path"
import { decryptSnapshotEnvelope, encryptSnapshot, loadSnapshotKey } from "./snapshot-crypto.mjs"
+import { scanSnapshot } from "./snapshot-safety.mjs"
const readJson = async (file) => JSON.parse(await readFile(file, "utf8"))
const ajvErrors = (prefix, errors = []) => errors.map((error) => `${prefix}${error.instancePath || "/"}: ${error.message}`)
export async function loadContracts(root = process.cwd()) {
+ const registry = await readJson(path.join(root, "config/sources.json"))
const files = [
"schemas/ui-blocks.schema.json",
"schemas/snapshot.schema.json",
"schemas/snapshot-bundle.schema.json",
- "schemas/domains/generic.schema.json",
+ "schemas/domains/domain-base.schema.json",
+ ...new Set(registry.sources.map((source) => source.schema_ref)),
]
- const [ui, snapshot, bundle, generic] = await Promise.all(files.map((file) => readJson(path.join(root, file))))
- const registry = await readJson(path.join(root, "config/sources.json"))
+ const [ui, snapshot, bundle, ...domains] = await Promise.all(files.map((file) => readJson(path.join(root, file))))
const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true })
addFormats(ajv)
- for (const schema of [ui, snapshot, bundle, generic]) ajv.addSchema(schema)
+ for (const schema of [ui, snapshot, bundle, ...domains]) if (!ajv.getSchema(schema.$id)) ajv.addSchema(schema)
return { ajv, registry, bundleSchema: bundle, root }
}
@@ -38,14 +40,27 @@ export function targetForSnapshot(registration, snapshot, outputRoot = "data/sna
return target
}
-export async function validateBundle(bundle, contracts) {
+export async function validateBundle(bundle, contracts, { expectedSourceIds } = {}) {
contracts ||= await loadContracts()
const errors = []
const validate = contracts.ajv.getSchema(contracts.bundleSchema.$id)
if (!validate(bundle)) errors.push(...ajvErrors("bundle", validate.errors))
if (!Array.isArray(bundle?.snapshots)) return errors
+ const declared = Array.isArray(bundle.expected_source_ids) ? bundle.expected_source_ids : []
+ const actual = bundle.snapshots.map((snapshot) => snapshot?.source_id).filter(Boolean)
+ const authoritative = expectedSourceIds || declared
+ const missing = authoritative.filter((sourceId) => !actual.includes(sourceId))
+ const extra = actual.filter((sourceId) => !authoritative.includes(sourceId))
+ if (missing.length) errors.push(`bundle: missing expected sources ${missing.join(", ")}`)
+ if (extra.length) errors.push(`bundle: contains unexpected sources ${extra.join(", ")}`)
+ if (expectedSourceIds) {
+ const undeclared = expectedSourceIds.filter((sourceId) => !declared.includes(sourceId))
+ const unapproved = declared.filter((sourceId) => !expectedSourceIds.includes(sourceId))
+ if (undeclared.length) errors.push(`bundle: expected_source_ids omits authoritative sources ${undeclared.join(", ")}`)
+ if (unapproved.length) errors.push(`bundle: expected_source_ids contains unapproved sources ${unapproved.join(", ")}`)
+ }
const seen = new Set()
- for (const snapshot of bundle.snapshots) {
+ for (const [index, snapshot] of bundle.snapshots.entries()) {
const prefix = snapshot?.source_id || "unknown-source"
if (seen.has(snapshot?.source_id)) errors.push(`${prefix}: source appears more than once in one bundle`)
seen.add(snapshot?.source_id)
@@ -58,6 +73,11 @@ export async function validateBundle(bundle, contracts) {
errors.push(`${prefix}: domain or source does not match its registration`)
if (snapshot.schema_ref !== registration.schema_ref) errors.push(`${prefix}: schema_ref must be ${registration.schema_ref}`)
if (snapshot.producer?.worker_id !== registration.worker_id) errors.push(`${prefix}: worker_id must be ${registration.worker_id}`)
+ for (const dependency of registration.depends_on) {
+ const dependencyIndex = actual.indexOf(dependency)
+ if (dependencyIndex > index) errors.push(`${prefix}: dependency ${dependency} must appear earlier in the bundle`)
+ }
+ errors.push(...scanSnapshot(snapshot, { contentGuards: registration.privacy.content_guards || [] }).map((error) => `${prefix}${error}`))
const domainPath = path.join(contracts.root, registration.schema_ref)
const domainSchema = await readJson(domainPath).catch(() => null)
if (!domainSchema) {
@@ -71,8 +91,8 @@ export async function validateBundle(bundle, contracts) {
return errors
}
-export async function assertValidBundle(bundle, contracts) {
- const errors = await validateBundle(bundle, contracts)
+export async function assertValidBundle(bundle, contracts, options) {
+ const errors = await validateBundle(bundle, contracts, options)
if (errors.length) {
const error = new Error(`Snapshot bundle rejected with ${errors.length} validation error${errors.length === 1 ? "" : "s"}.`)
error.validationErrors = errors
@@ -97,9 +117,9 @@ async function rejectSymlinkPath(root, target) {
}
}
-export async function persistBundle(bundle, { outputRoot = "data/snapshots", encryption = false, key, contracts } = {}) {
+export async function persistBundle(bundle, { outputRoot = "data/snapshots", encryption = false, key, contracts, expectedSourceIds } = {}) {
contracts ||= await loadContracts()
- await assertValidBundle(bundle, contracts)
+ await assertValidBundle(bundle, contracts, { expectedSourceIds })
const requestedRoot = path.resolve(outputRoot)
await mkdir(requestedRoot, { recursive: true, mode: 0o700 })
const resolvedRoot = await realpath(requestedRoot)
diff --git a/scripts/lib/freshness.mjs b/scripts/lib/freshness.mjs
new file mode 100644
index 0000000..084a8f7
--- /dev/null
+++ b/scripts/lib/freshness.mjs
@@ -0,0 +1,11 @@
+export function snapshotFreshness(snapshot, now = Date.now()) {
+ if (!snapshot) return "missing"
+ if (snapshot.status === "failed") return "failed"
+ const expiresAt = Date.parse(snapshot.freshness?.expires_at)
+ if (!Number.isFinite(expiresAt) || now >= expiresAt) return "stale"
+ if (snapshot.status === "partial") return "partial"
+ const generatedAt = Date.parse(snapshot.generated_at)
+ const lifetime = Number.isFinite(generatedAt) ? Math.max(expiresAt - generatedAt, 0) : 0
+ const agingWindow = Math.min(4 * 60 * 60 * 1000, Math.max(30 * 60 * 1000, lifetime * 0.25))
+ return now >= expiresAt - agingWindow ? "aging" : "fresh"
+}
diff --git a/scripts/lib/mock-provider.mjs b/scripts/lib/mock-provider.mjs
index 89a8c04..ea877f5 100644
--- a/scripts/lib/mock-provider.mjs
+++ b/scripts/lib/mock-provider.mjs
@@ -15,6 +15,7 @@ export async function createMockBundle({ root = process.cwd(), attempt = 1, fail
bundle_version: "0.1.1",
run_id: "tutorial-daily-bundle-2026-08-24",
generated_at: "2026-08-24T07:30:00Z",
+ expected_source_ids: sourceIds,
snapshots,
})
}
diff --git a/scripts/lib/prompt-studio.mjs b/scripts/lib/prompt-studio.mjs
index 446bcb5..9b54487 100644
--- a/scripts/lib/prompt-studio.mjs
+++ b/scripts/lib/prompt-studio.mjs
@@ -85,9 +85,8 @@ export function validatePromptProfile(profile, contracts) {
return normalized
}
-function selectedBlockContract(uiSchema, kinds) {
- const shared = Object.fromEntries(["span", "tone", "format", "metric"].map((name) => [name, uiSchema.$defs[name]]))
- return { shared, blocks: Object.fromEntries(kinds.map((kind) => [kind, uiSchema.$defs[kind]])) }
+function selectedBlockSummary(uiSchema, kinds) {
+ return kinds.map((kind) => ({ kind, required_fields: uiSchema.$defs[kind].required, extra_fields_rejected: true }))
}
function sourceRegistration(source, contracts) {
@@ -112,6 +111,7 @@ function sourceRegistration(source, contracts) {
expected_classification: "private",
authorized_inputs: source.registration.authorized_inputs,
forbidden_inputs: source.registration.forbidden_inputs,
+ content_guards: ["authentication-link", "encoded-blob"],
},
}
}
@@ -128,10 +128,6 @@ function buildScheduledPrompt(profile, contracts, registrations, missing) {
tools: source.tools,
preferred_blocks: source.preferred_blocks,
}))
- const publication =
- profile.publication === "pull-request"
- ? "Create or update one pull request containing the complete run. Never create one pull request per source."
- : "Create one commit on the configured data branch containing the complete run."
const registrationGate = missing.length
? `\n## Setup gate\n\nThese sources are not yet registered: ${missing.join(", ")}. Do not schedule or run this recurring task until the one-time setup prompt has been merged into the code repository. On every run, stop safely if any selected source is absent from the current registry.\n`
: ""
@@ -154,7 +150,7 @@ You are a Zaati OS v${VERSION} data producer. Read approved sources, build one c
### Repositories and permissions
- Read the current default branch of ${profile.code_repository} for the authoritative contract.
-- Write only to ${profile.data_repository}.
+- Write only to a new branch and pull request in ${profile.data_repository}. Never write directly to its protected default branch and never merge your own pull request.
- Never edit application code, schemas, prompts, configuration, workflows, CI, dependencies, or documentation during a recurring run.
- Never put credentials, raw provider exports, account identifiers, complete messages, attachments, or unnecessary personal details into snapshots, commits, pull requests, logs, or comments.
- Stop without writing if repository access, required tools, or source registration is unavailable.
@@ -192,6 +188,7 @@ Return exactly one JSON object with no prose or Markdown fences to the validatio
"bundle_version": "0.1.1",
"run_id": "${sourceSlug(profile.task_name)}:YYYY-MM-DD",
"generated_at": "RFC 3339 date-time",
+ "expected_source_ids": ${json(profile.sources.map((source) => source.id))},
"snapshots": [
{
"schema_version": "0.1.1",
@@ -214,32 +211,16 @@ Return exactly one JSON object with no prose or Markdown fences to the validatio
}
\`\`\`
-The exact bundle schema is:
-
-\`\`\`json
-${json(contracts.bundleSchema)}
-\`\`\`
-
-The exact snapshot envelope is:
-
-\`\`\`json
-${json(contracts.snapshotSchema)}
-\`\`\`
-
-The generic domain payload is:
-
-\`\`\`json
-${json(contracts.genericSchema)}
-\`\`\`
+The compact object above is orientation, not a replacement for validation. The current files in the code repository are authoritative: schemas/snapshot-bundle.schema.json, schemas/snapshot.schema.json, schemas/ui-blocks.schema.json, and each registration.schema_ref. Populate stable data.facts first, then derive data.presentation from those facts.
### Safe presentation blocks
Choose the smallest useful set from only the preferred blocks in the source intent. Use line charts for ordered trends, bars for categories, calendars for timed events, tables for exact repeated fields, timelines for meaningful sequences, and notices for caveats. Do not add a chart when a sentence or table is clearer. Never emit HTML, SVG, CSS, JavaScript, component names, templates, or executable links.
-Selected block definitions and their shared definitions:
+Selected block summary. Read schemas/ui-blocks.schema.json for the exact nested fields and limits:
\`\`\`json
-${json(selectedBlockContract(contracts.uiSchema, selectedKinds))}
+${json(selectedBlockSummary(contracts.uiSchema, selectedKinds))}
\`\`\`
### Validate, retry, publish
@@ -249,8 +230,9 @@ ${json(selectedBlockContract(contracts.uiSchema, selectedKinds))}
3. Make at most three total attempts. After the third failure, write nothing and preserve every previous snapshot.
4. Reject duplicate source IDs, unregistered sources, wrong worker ownership, unexpected properties, invalid block kinds, and any partial bundle.
5. Persist only the nested snapshots, never the bundle wrapper. Same-day reruns replace only today's owned files and preserve snapshot_id.
-6. ${publication}
-7. Include only derived target paths and a redacted success or failure summary in the run report. Never echo private facts.
+6. Open one pull request containing the complete run. The independent "Validate Zaati snapshots" check must pass. Do not bypass, disable, edit, or self-certify that check.
+7. Never merge the pull request. Branch protection and the independent validator are the publication authority.
+8. Include only derived target paths and a redacted success or failure summary in the run report. Never echo private facts.
The run succeeds only when every requested source is valid and published together. Voilà means one calm refresh, not six tiny fires.
`
@@ -269,10 +251,10 @@ For each source:
1. Add a reusable entry to config/sources.json with deterministic worker ownership and target path.
2. Add a provider-neutral worker prompt under prompts/ that states authorized and forbidden inputs.
-3. Use schemas/domains/generic.schema.json unless a new data contract is genuinely required.
+3. Add a source-specific facts schema under schemas/domains/. Presentation remains optional view intent derived from stable facts.
4. Add an obviously synthetic public fixture with privacy.synthetic true, contains_personal_data false, and classification public.
5. Add tests and concise setup, permissions, disabling, and removal documentation.
-6. If it joins a multi-source workflow, preserve whole-bundle validation and one-commit publication.
+6. If it joins a multi-source workflow, preserve whole-bundle validation and one-pull-request publication.
7. Run npm run check and report the results in the pull request.
Never add credentials, provider exports, account IDs, private repository names, personal values, identifying screenshots, or real snapshot data. Do not weaken privacy validation, ignored paths, CI, encryption, Access-first deployment, or schema safety. Stop and ask if the requested source cannot be implemented without private examples.
@@ -287,6 +269,7 @@ export function generatePromptArtifacts(profile, contracts) {
const slug = sourceSlug(validProfile.task_name)
const files = {
[`${slug}.scheduled-task.md`]: buildScheduledPrompt(validProfile, contracts, registrations, missing),
+ [`${slug}.permissions.md`]: buildPermissionManifest(validProfile, registrations, missing),
[`${slug}.profile.json`]: `${JSON.stringify(validProfile, null, 2)}\n`,
}
if (missing.length)
@@ -297,6 +280,44 @@ export function generatePromptArtifacts(profile, contracts) {
return { profile: validProfile, slug, missing, files }
}
+function buildPermissionManifest(profile, registrations, missing) {
+ const sourceRows = profile.sources
+ .map((source) => {
+ const registration = registrations.find((item) => item.id === source.id)
+ return `| ${registration.label} | ${source.tools.join(", ")} | ${registration.target_path} | ${source.preferred_blocks.join(", ")} |`
+ })
+ .join("\n")
+ return `# ${safeLine(profile.task_name)} permission manifest
+
+Review this short file before pasting the machine prompt.
+
+## Schedule
+
+- Provider: ${PROVIDER_NAMES[profile.provider]}
+- Schedule: ${safeLine(profile.schedule)}
+- Timezone: ${safeLine(profile.timezone)}
+
+## Repository access
+
+- Read contracts from: ${profile.code_repository}
+- Create branches and pull requests in: ${profile.data_repository}
+- Direct writes and self-merges: forbidden
+- Required independent check: Validate Zaati snapshots
+
+## Source permissions
+
+| Source | Approved tools | Owned path | Allowed views |
+| --- | --- | --- | --- |
+${sourceRows}
+
+## Safety boundary
+
+The task may read only registered authorized inputs. It must not retain registered forbidden inputs, credentials, raw provider exports, authentication links, complete messages, account numbers, or unrelated personal data. Zaati OS scans snapshots and validates the exact source set independently before merge.
+
+${missing.length ? `Setup is incomplete. Register these sources before scheduling: ${missing.join(", ")}.` : "Setup is ready once the private repository validation check is installed and required by branch protection."}
+`
+}
+
export async function writePromptArtifacts(artifacts, outputDirectory, { force = false } = {}) {
await mkdir(outputDirectory, { recursive: true, mode: 0o700 })
await chmod(outputDirectory, 0o700)
diff --git a/scripts/lib/setup-options.mjs b/scripts/lib/setup-options.mjs
new file mode 100644
index 0000000..aa62333
--- /dev/null
+++ b/scripts/lib/setup-options.mjs
@@ -0,0 +1,72 @@
+const currencies = {
+ AE: "AED",
+ AU: "AUD",
+ BE: "EUR",
+ CA: "CAD",
+ CH: "CHF",
+ DE: "EUR",
+ ES: "EUR",
+ FR: "EUR",
+ GB: "GBP",
+ IE: "EUR",
+ IN: "INR",
+ IT: "EUR",
+ JP: "JPY",
+ NL: "EUR",
+ NZ: "NZD",
+ PK: "PKR",
+ PT: "EUR",
+ SG: "SGD",
+ US: "USD",
+}
+
+export function detectedDefaults() {
+ const resolved = new Intl.DateTimeFormat().resolvedOptions()
+ const locale = resolved.locale || "en-US"
+ let region
+ try {
+ region = new Intl.Locale(locale).maximize().region
+ } catch {
+ region = "US"
+ }
+ return { locale, timezone: resolved.timeZone || "Etc/UTC", currency: currencies[region] || "USD" }
+}
+
+export function validTimezone(value) {
+ try {
+ new Intl.DateTimeFormat("en", { timeZone: value }).format()
+ return true
+ } catch {
+ return false
+ }
+}
+
+export function validLocale(value) {
+ try {
+ return new Intl.Locale(value).toString() === value
+ } catch {
+ return false
+ }
+}
+
+export function validCurrency(value) {
+ try {
+ new Intl.NumberFormat("en", { style: "currency", currency: value }).format(1)
+ return /^[A-Z]{3}$/.test(value)
+ } catch {
+ return false
+ }
+}
+
+export function expandDependencies(sourceIds, registry) {
+ const byId = new Map(registry.sources.map((source) => [source.id, source]))
+ const expanded = new Set()
+ const add = (sourceId) => {
+ const source = byId.get(sourceId)
+ if (!source) throw new Error(`Unknown source ${sourceId}.`)
+ source.depends_on.forEach(add)
+ expanded.add(sourceId)
+ }
+ sourceIds.forEach(add)
+ return [...expanded]
+}
diff --git a/scripts/lib/snapshot-safety.mjs b/scripts/lib/snapshot-safety.mjs
new file mode 100644
index 0000000..dd57690
--- /dev/null
+++ b/scripts/lib/snapshot-safety.mjs
@@ -0,0 +1,54 @@
+export const credentialPatterns = [
+ ["private key", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/],
+ ["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{24,}\b/],
+ ["OpenAI-style key", /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/],
+ ["AWS access key", /\bAKIA[0-9A-Z]{16}\b/],
+ ["Slack token", /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/],
+ ["Bearer credential", /\bBearer\s+[A-Za-z0-9._~+/-]{24,}=*\b/i],
+ ["JWT credential", /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/],
+ ["Google API key", /\bAIza[0-9A-Za-z_-]{30,}\b/],
+ ["live payment key", /\b(?:sk|rk)_live_[0-9A-Za-z]{16,}\b/],
+ ["embedded credential", /\b(?:api[_ -]?key|client[_ -]?secret|password|access[_ -]?token)\s*[:=]\s*["']?[A-Za-z0-9._~-]{12,}/i],
+ ["credential in URL", /https:\/\/[^\s/:]+:[^\s/@]{8,}@/i],
+]
+
+const guardChecks = {
+ "account-number": (value) => /\b\d{8,18}\b/.test(value),
+ "authentication-link": (value) =>
+ /https:\/\/[^\s]+[?&](?:access_token|auth|code|key|otp|password|reset|secret|token)=[^\s&]{4,}/i.test(value),
+ "one-time-code": (value) => /\b(?:otp|one[- ]time|verification|security)\s+(?:code\s+)?\d{4,8}\b/i.test(value),
+ "raw-email": (value) => {
+ const headers = ["from", "to", "subject", "date"].filter((name) => new RegExp(`(?:^|\\n)${name}:`, "i").test(value))
+ return headers.length >= 3 && value.length > 180
+ },
+ "encoded-blob": (value) => value.length > 1000 && /^[A-Za-z0-9+/=\s]+$/.test(value),
+}
+
+function walk(value, visit, trail = []) {
+ if (Array.isArray(value)) {
+ value.forEach((item, index) => walk(item, visit, [...trail, index]))
+ return
+ }
+ if (!value || typeof value !== "object") {
+ if (typeof value === "string") visit(value, trail)
+ return
+ }
+ for (const [key, child] of Object.entries(value)) walk(child, visit, [...trail, key])
+}
+
+export function scanSnapshot(snapshot, { contentGuards = [] } = {}) {
+ const errors = []
+ walk(snapshot, (value, trail) => {
+ const location = `#/${trail.join("/")}`
+ for (const [label, pattern] of credentialPatterns) {
+ if (pattern.test(value)) errors.push(`${location}: possible ${label} is forbidden`)
+ }
+ for (const name of contentGuards) {
+ const check = guardChecks[name]
+ if (check?.(value)) errors.push(`${location}: ${name} content guard rejected the value`)
+ }
+ })
+ return errors
+}
+
+export const supportedContentGuards = Object.keys(guardChecks)
diff --git a/scripts/run-workflow.mjs b/scripts/run-workflow.mjs
index 4aec4d4..f3959c0 100644
--- a/scripts/run-workflow.mjs
+++ b/scripts/run-workflow.mjs
@@ -84,9 +84,14 @@ export async function executeWorkflow(options = {}) {
? await createMockBundle({ root, attempt, failAttempts: settings.mockFailures, sourceIds: workflow.source_ids })
: await commandAdapter(settings.command, prompt)
const bundle = JSON.parse(raw)
- await assertValidBundle(bundle, contracts)
+ await assertValidBundle(bundle, contracts, { expectedSourceIds: workflow.source_ids })
const files = settings.outputRoot
- ? await persistBundle(bundle, { outputRoot: settings.outputRoot, encryption: settings.encrypt, contracts })
+ ? await persistBundle(bundle, {
+ outputRoot: settings.outputRoot,
+ encryption: settings.encrypt,
+ contracts,
+ expectedSourceIds: workflow.source_ids,
+ })
: []
console.log(
`Workflow succeeded on attempt ${attempt}. Validated ${bundle.snapshots.length} snapshots${files.length ? " and committed the local file transaction" : " in dry mode"}.`,
diff --git a/scripts/setup.mjs b/scripts/setup.mjs
index 69c555a..9a9b873 100644
--- a/scripts/setup.mjs
+++ b/scripts/setup.mjs
@@ -3,10 +3,14 @@ import path from "node:path"
import process from "node:process"
import { createInterface } from "node:readline/promises"
import { generateSnapshotKey } from "./lib/snapshot-crypto.mjs"
+import { detectedDefaults, expandDependencies, validCurrency, validLocale, validTimezone } from "./lib/setup-options.mjs"
const target = path.resolve("config/instance.local.json")
const force = process.argv.includes("--force")
-const defaults = JSON.parse(await readFile(path.resolve("config/instance.example.json"), "utf8"))
+const template = JSON.parse(await readFile(path.resolve("config/instance.example.json"), "utf8"))
+const detected = detectedDefaults()
+const defaults = { ...template, timezone: detected.timezone, locale: detected.locale, currency: detected.currency }
+const registry = JSON.parse(await readFile(path.resolve("config/sources.json"), "utf8"))
const exists = await readFile(target)
.then(() => true)
.catch((error) => (error.code === "ENOENT" ? false : Promise.reject(error)))
@@ -17,6 +21,20 @@ let encryption = false
if (process.stdin.isTTY && !process.argv.includes("--yes")) {
const prompt = createInterface({ input: process.stdin, output: process.stdout })
const ask = async (label, fallback) => (await prompt.question(`${label} (${fallback}): `)).trim() || fallback
+ const askValid = async (label, fallback, validate, example) => {
+ while (true) {
+ const answer = await ask(label, fallback)
+ if (validate(answer)) return answer
+ console.log(` Not valid. Try ${example}.`)
+ }
+ }
+ const choose = async (label, values, fallback) => {
+ while (true) {
+ const answer = await ask(`${label}: ${values.join(", ")}`, fallback)
+ if (values.includes(answer)) return answer
+ console.log(` Choose one of: ${values.join(", ")}.`)
+ }
+ }
const confirm = async (label, fallback = false) => {
const answer = (await prompt.question(`${label} (${fallback ? "Y/n" : "y/N"}): `)).trim().toLowerCase()
return answer ? answer === "y" || answer === "yes" : fallback
@@ -24,22 +42,25 @@ if (process.stdin.isTTY && !process.argv.includes("--yes")) {
console.log("\n1 of 3, make it yours")
const brandName = await ask("Dashboard name", defaults.brand_name)
const brandMark = await ask("Short brand mark", brandName.trim().slice(0, 1).toUpperCase() || defaults.brand_mark)
- const timezone = await ask("IANA timezone", defaults.timezone)
- const locale = await ask("Locale", defaults.locale)
- const currency = (await ask("ISO currency", defaults.currency)).toUpperCase()
+ const timezone = await askValid("IANA timezone", defaults.timezone, validTimezone, "Asia/Karachi")
+ const locale = await askValid("Locale", defaults.locale, validLocale, "en-PK")
+ const currency = await askValid("ISO currency", defaults.currency, (value) => validCurrency(value.toUpperCase()), "PKR")
+ console.log(
+ ` Resolved local time: ${new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: timezone }).format(new Date())}`,
+ )
console.log("\n2 of 3, choose a calm starting point")
- const pack = await ask("Source pack: essential, everyday, all", "everyday")
+ const pack = await choose("Source pack", ["essential", "daily", "all"], "daily")
const packs = {
- essential: ["overview:daily", "agenda:primary", "work:focus"],
- everyday: ["overview:daily", "agenda:primary", "inbox:attention", "work:focus", "money:pulse", "news:briefing"],
+ essential: ["agenda:primary", "work:focus"],
+ daily: ["overview:daily"],
all: defaults.enabled_sources,
}
console.log("\n3 of 3, choose the look and security")
- const preset = await ask("Palette: sage, ocean, plum, sand", defaults.theme.preset)
- const fontFamily = await ask("Font: system, humanist, editorial, rounded, mono", defaults.theme.font_family)
- const headingStyle = await ask("Headers: plain, compact, expressive", defaults.theme.heading_style)
+ const preset = await choose("Palette", ["sage", "ocean", "plum", "sand"], defaults.theme.preset)
+ const fontFamily = await choose("Font", ["system", "humanist", "editorial", "rounded", "mono"], defaults.theme.font_family)
+ const headingStyle = await choose("Headers", ["plain", "compact", "expressive"], defaults.theme.heading_style)
encryption = await confirm("Encrypt private snapshot files at rest", false)
prompt.close()
config = {
@@ -48,8 +69,8 @@ if (process.stdin.isTTY && !process.argv.includes("--yes")) {
brand_mark: brandMark,
timezone,
locale,
- currency,
- enabled_sources: packs[pack] || packs.everyday,
+ currency: currency.toUpperCase(),
+ enabled_sources: expandDependencies(packs[pack], registry),
theme: { ...defaults.theme, preset, font_family: fontFamily, heading_style: headingStyle },
storage: { snapshot_encryption: encryption },
}
diff --git a/scripts/validate-data-repository.mjs b/scripts/validate-data-repository.mjs
new file mode 100644
index 0000000..f969d2e
--- /dev/null
+++ b/scripts/validate-data-repository.mjs
@@ -0,0 +1,63 @@
+import { readFile } from "node:fs/promises"
+import path from "node:path"
+import process from "node:process"
+import Ajv2020 from "ajv/dist/2020.js"
+import { assertValidBundle, loadContracts, targetForSnapshot } from "./lib/bundle-contract.mjs"
+import { decryptSnapshotEnvelope, loadSnapshotKey } from "./lib/snapshot-crypto.mjs"
+
+const option = (name, fallback) => {
+ const index = process.argv.indexOf(`--${name}`)
+ return index >= 0 ? process.argv[index + 1] : fallback
+}
+const codeRoot = path.resolve(import.meta.dirname, "..")
+const repositoryRoot = path.resolve(option("repository-root", "."))
+const changedFile = path.resolve(option("changed-files", path.join(repositoryRoot, ".zaati-changed-files")))
+const configPath = path.join(repositoryRoot, "zaati.data.json")
+const config = JSON.parse(await readFile(configPath, "utf8"))
+const schema = JSON.parse(await readFile(path.join(codeRoot, "schemas/data-repository.schema.json"), "utf8"))
+const validateConfig = new Ajv2020({ allErrors: true, strict: true }).compile(schema)
+if (!validateConfig(config)) {
+ const reasons = validateConfig.errors.map((error) => `${error.instancePath || "/"}: ${error.message}`).join("; ")
+ throw new Error(`Invalid zaati.data.json: ${reasons}`)
+}
+
+const changed = (await readFile(changedFile, "utf8"))
+ .split(/\r?\n/)
+ .map((item) => item.trim())
+ .filter(Boolean)
+const snapshotPaths = changed.filter((file) => /^data\/snapshots\/.+\.json(?:\.enc)?$/.test(file))
+if (!snapshotPaths.length) throw new Error("This pull request contains no snapshot files to validate.")
+const unrelated = changed.filter((file) => !file.startsWith("data/snapshots/"))
+if (unrelated.length) throw new Error(`Snapshot publication pull requests cannot modify other paths: ${unrelated.join(", ")}`)
+if (snapshotPaths.some((relative) => config.snapshot_encryption !== relative.endsWith(".enc")))
+ throw new Error("Snapshot encryption mode does not match zaati.data.json.")
+
+const contracts = await loadContracts(codeRoot)
+const key = config.snapshot_encryption ? await loadSnapshotKey() : null
+const snapshots = []
+for (const relative of snapshotPaths) {
+ const absolute = path.resolve(repositoryRoot, relative)
+ if (!absolute.startsWith(`${repositoryRoot}${path.sep}`)) throw new Error("A changed snapshot path escaped the data repository.")
+ const payload = JSON.parse(await readFile(absolute, "utf8"))
+ const snapshot = relative.endsWith(".enc") ? decryptSnapshotEnvelope(payload, key) : payload
+ const registration = contracts.registry.sources.find((source) => source.id === snapshot.source_id)
+ if (!registration) throw new Error(`${relative}: source is not registered.`)
+ const expected = targetForSnapshot(registration, snapshot, path.join(repositoryRoot, "data/snapshots"))
+ const expectedRelative = path.relative(repositoryRoot, expected) + (config.snapshot_encryption ? ".enc" : "")
+ if (relative !== expectedRelative.split(path.sep).join("/")) throw new Error(`${relative}: path does not match registered ownership.`)
+ snapshots.push(snapshot)
+}
+
+const generatedAt = snapshots
+ .map((snapshot) => snapshot.generated_at)
+ .sort()
+ .at(-1)
+const bundle = {
+ bundle_version: "0.1.1",
+ run_id: `data-repository-pr:${process.env.GITHUB_RUN_ID || "local"}`,
+ generated_at: generatedAt,
+ expected_source_ids: config.expected_source_ids,
+ snapshots,
+}
+await assertValidBundle(bundle, contracts, { expectedSourceIds: config.expected_source_ids })
+console.log(`Validated one independent publication containing exactly ${snapshots.length} registered snapshots.`)
diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs
index dd1ac46..f9d9b39 100644
--- a/scripts/validate-data.mjs
+++ b/scripts/validate-data.mjs
@@ -4,6 +4,7 @@ import process from "node:process"
import Ajv2020 from "ajv/dist/2020.js"
import addFormats from "ajv-formats"
import { decryptSnapshotEnvelope, loadSnapshotKey } from "./lib/snapshot-crypto.mjs"
+import { scanSnapshot } from "./lib/snapshot-safety.mjs"
const root = process.cwd()
const errors = []
@@ -63,7 +64,16 @@ const schemaPaths = [
"schemas/instance.schema.json",
"schemas/workflow-registry.schema.json",
"schemas/prompt-profile.schema.json",
+ "schemas/data-repository.schema.json",
+ "schemas/domains/domain-base.schema.json",
"schemas/domains/generic.schema.json",
+ "schemas/domains/agenda.schema.json",
+ "schemas/domains/inbox.schema.json",
+ "schemas/domains/work.schema.json",
+ "schemas/domains/money.schema.json",
+ "schemas/domains/news.schema.json",
+ "schemas/domains/overview.schema.json",
+ "schemas/domains/review.schema.json",
]
const schemas = await Promise.all(schemaPaths.map(readJson))
const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true })
@@ -165,6 +175,7 @@ for (const file of snapshotFiles) {
errors.push(`${file}: unregistered source ${snapshot.source_id}`)
continue
}
+ errors.push(...scanSnapshot(snapshot, { contentGuards: registration.privacy.content_guards || [] }).map((error) => `${file}${error}`))
if (snapshot.domain !== registration.domain || snapshot.source !== registration.source)
errors.push(`${file}: domain and source do not match ${snapshot.source_id}`)
if (snapshot.producer?.worker_id !== registration.worker_id) errors.push(`${file}: producer must be ${registration.worker_id}`)
diff --git a/scripts/validate-privacy.mjs b/scripts/validate-privacy.mjs
index 8535d99..30918c6 100644
--- a/scripts/validate-privacy.mjs
+++ b/scripts/validate-privacy.mjs
@@ -3,6 +3,7 @@ import path from "node:path"
import process from "node:process"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
+import { credentialPatterns } from "./lib/snapshot-safety.mjs"
const root = process.cwd()
const ignored = new Set([".git", ".zaati", "node_modules", "dist", ".wrangler", "coverage"])
@@ -22,14 +23,6 @@ const textExtensions = new Set([
".jsonc",
"",
])
-const credentialPatterns = [
- ["private key", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/],
- ["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{24,}\b/],
- ["OpenAI-style key", /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/],
- ["AWS access key", /\bAKIA[0-9A-Z]{16}\b/],
- ["Slack token", /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/],
- ["Bearer credential", /\bBearer\s+[A-Za-z0-9._~+/-]{24,}=*\b/i],
-]
const errors = []
const execFileAsync = promisify(execFile)
async function files(directory = ".") {
diff --git a/scripts/verify-access.mjs b/scripts/verify-access.mjs
index 19b3db1..0f5c994 100644
--- a/scripts/verify-access.mjs
+++ b/scripts/verify-access.mjs
@@ -2,15 +2,23 @@ import process from "node:process"
const hostname = (process.argv[2] || process.env.ZAATI_HOSTNAME || "").trim().toLowerCase()
if (!hostname) throw new Error("Provide a hostname as the first argument or set ZAATI_HOSTNAME.")
-const response = await fetch(`https://${hostname}`, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(15000) })
-const location = response.headers.get("location") || ""
-const protectedByRedirect = [301, 302, 303, 307, 308].includes(response.status) && /cloudflareaccess\.com|cdn-cgi\/access/i.test(location)
-const protectedByDenial =
- [401, 403].includes(response.status) &&
- /cloudflare|access/i.test(`${response.headers.get("server") || ""} ${await response.text().catch(() => "")}`)
-if (!protectedByRedirect && !protectedByDenial) {
- throw new Error(
- `Access preflight failed for ${hostname}. Expected an Access redirect or denial, received HTTP ${response.status}. Do not deploy private snapshots.`,
- )
+const paths = ["/", "/data/dashboard-data.json", "/assets/zaati-access-probe.js"]
+for (const pathname of paths) {
+ const response = await fetch(`https://${hostname}${pathname}`, {
+ method: "GET",
+ redirect: "manual",
+ signal: AbortSignal.timeout(15000),
+ headers: { "user-agent": "zaati-access-preflight/0.1.1" },
+ })
+ const location = response.headers.get("location") || ""
+ const protectedByRedirect = [301, 302, 303, 307, 308].includes(response.status) && /cloudflareaccess\.com|cdn-cgi\/access/i.test(location)
+ const body = await response.text().catch(() => "")
+ const protectedByDenial =
+ [401, 403].includes(response.status) && /cloudflare|access/i.test(`${response.headers.get("server") || ""} ${body}`)
+ if (!protectedByRedirect && !protectedByDenial) {
+ throw new Error(
+ `Access preflight failed for ${hostname}${pathname}. Expected an Access redirect or denial, received HTTP ${response.status}. Do not deploy private snapshots.`,
+ )
+ }
}
-console.log(`Verified that ${hostname} challenges an unauthenticated request.`)
+console.log(`Verified that ${hostname} challenges unauthenticated HTML, data, and asset requests.`)
diff --git a/src/App.tsx b/src/App.tsx
index f3f168c..eb48bf0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,6 +4,7 @@ import {
BriefcaseBusiness,
CalendarDays,
ChevronDown,
+ Clock3,
CircleDollarSign,
Inbox,
LayoutDashboard,
@@ -25,6 +26,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
+import { snapshotFreshness, type FreshnessState } from "@/lib/freshness"
import type { DashboardData, InstanceConfig, Snapshot, SourceDefinition } from "@/types"
const Onboarding = lazy(() => import("@/components/Onboarding"))
@@ -38,6 +40,15 @@ const domainIcons = {
review: Activity,
} as const
const START_ID = "__start"
+const publicAsset = (file: string) => `${import.meta.env.BASE_URL}${file.replace(/^\//, "")}`
+const health = {
+ fresh: { label: "Fresh and complete", dot: "bg-positive" },
+ aging: { label: "Refresh expected soon", dot: "bg-info" },
+ stale: { label: "Expired snapshot", dot: "bg-warning" },
+ partial: { label: "Some evidence limited", dot: "bg-warning" },
+ failed: { label: "Source unavailable", dot: "bg-destructive" },
+ missing: { label: "No snapshot yet", dot: "bg-muted-foreground" },
+} satisfies Record
type ThemeMode = "light" | "dark"
type Density = "compact" | "comfortable"
type FontFamily = InstanceConfig["theme"]["font_family"]
@@ -55,7 +66,7 @@ export function App() {
const [error, setError] = useState(null)
useEffect(() => {
const controller = new AbortController()
- fetch("/data/dashboard-data.json", { cache: "no-store", credentials: "same-origin", signal: controller.signal })
+ fetch(publicAsset("data/dashboard-data.json"), { cache: "no-store", credentials: "same-origin", signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`Dashboard data returned ${response.status}.`)
return response.json() as Promise
@@ -89,7 +100,13 @@ function DashboardApp({ data }: { data: DashboardData }) {
const [settingsOpen, setSettingsOpen] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [sidebarCompact, setSidebarCompact] = useState(false)
+ const [now, setNow] = useState(() => Date.parse(data.generatedAt))
+ const [historySnapshotId, setHistorySnapshotId] = useState("")
const selected = data.sources.find((item) => item.definition.id === selectedId)
+ const history = data.historyBySource[selectedId] || []
+ const activeSnapshot = historySnapshotId
+ ? history.find((snapshot) => snapshot.snapshot_id === historySnapshotId) || selected?.snapshot
+ : selected?.snapshot
const selectedLabel = selectedId === START_ID ? "Start here" : selected?.definition.label || "Dashboard"
useEffect(() => {
@@ -141,8 +158,14 @@ function DashboardApp({ data }: { data: DashboardData }) {
return () => document.removeEventListener("keydown", close)
}, [])
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(Date.now()), 60_000)
+ return () => window.clearInterval(timer)
+ }, [])
+
const select = (id: string) => {
setSelectedId(id)
+ setHistorySnapshotId("")
setMobileOpen(false)
}
return (
@@ -156,6 +179,7 @@ function DashboardApp({ data }: { data: DashboardData }) {
setMobileOpen(false)}
onCompact={() => setSidebarCompact((value) => !value)}
@@ -238,7 +262,14 @@ function DashboardApp({ data }: { data: DashboardData }) {
select(overviewId)} />
) : selected ? (
-
+
) : (
)}
@@ -252,6 +283,7 @@ function Sidebar({
compact,
data,
mobileOpen,
+ now,
onClose,
onCompact,
onSelect,
@@ -260,6 +292,7 @@ function Sidebar({
compact: boolean
data: DashboardData
mobileOpen: boolean
+ now: number
onClose: () => void
onCompact: () => void
onSelect: (id: string) => void
@@ -281,7 +314,7 @@ function Sidebar({
{data.instance.brand_name === "Zaati OS" ? (
-
+
) : (
@@ -323,7 +356,7 @@ function Sidebar({
? domainIcons[definition.domain as keyof typeof domainIcons]
: Activity
const active = definition.id === selectedId
- const status = snapshot ? snapshot.status : "missing"
+ const status = snapshotFreshness(snapshot, now)
return (
)
})}
@@ -380,11 +400,17 @@ function Sidebar({
function DashboardPage({
definition,
+ history,
instance,
+ now,
+ onHistory,
snapshot,
}: {
definition: SourceDefinition
+ history: Snapshot[]
instance: InstanceConfig
+ now: number
+ onHistory: (snapshotId: string) => void
snapshot: Snapshot | null
}) {
if (!snapshot)
@@ -401,31 +427,81 @@ function DashboardPage({