ZDDC/ARCHITECTURE.md
ZDDC 5c33c8a821 docs: ACL/security overhaul (cascade rules, OPA, caching)
Three docs aligned with the preceding three feature commits.

zddc/README.md
--------------
Major overhaul of the access-control narrative. The previous "three-
tier" example table was misleading: it claimed a project-level
allow-list "restricts" access under a parent wildcard, when actually
the cascade is additive (a non-team employee falls up to root and
matches *@company.com). Operators reading the old docs would build
deployments that looked locked-down but leaked across the company.

New sections under "Access control: the .zddc cascade":
  * Step 1: starter .zddc — leads with the public-by-default warning
    and the --insecure escape hatch
  * How a request is evaluated — bottom-up walk with code citations
  * Glob patterns — @-boundary rule
  * When the cascade helps and when it fights you — the asymmetry
    between adding strangers (easy) and excluding insiders (hard)
  * Pick your layout — decision matrix for common shapes
  * Worked example: paired open/closed projects + third-party archive
    — full layout with trace table for two representative users
  * Patterns that look secure but aren't — anti-patterns including
    same-level allow+deny shadow, leaf-allow-doesn't-restrict,
    apps:-as-UI-mount
  * Trust model and invariants — auth boundary, subtree authority,
    root-only escalation gate
  * Trust boundary — network isolation requirement, anonymous
    information disclosure on /, audit-log integrity
  * Debugging permissions — manual cascade trace
  * Directory visibility / Reserved hidden segments
  * How to verify in 5 minutes — recipe with negative anti-pattern test
  * Federal-readiness gap analysis — bulleted with NIST control refs
  * External policy decider — OPA wire format, deployment shapes,
    failure modes
  * OPA decision cache — TTL semantics, knobs
  * Reference Rego policy — --print-rego, parity test rationale
  * Caching and ETags — content-hash story, why not server-side
  * Future work

Plus env-var table updates for ZDDC_INSECURE, ZDDC_OPA_URL,
ZDDC_OPA_FAIL_OPEN, ZDDC_OPA_CACHE_TTL; CORS narrative reflects
default-empty.

ARCHITECTURE.md
---------------
New "Server security model" section between Form Renderer and CSS:
cooperating layers (auth / policy decider / cascade / tool-rooted
view / reserved prefixes / audit log), commercial-vs-federal trust
model side-by-side, why the tool-rooted view matters for third-party
containment.

AGENTS.md
---------
Two new env-var rows (ZDDC_OPA_URL, ZDDC_OPA_CACHE_TTL); ACL line
sharpened with cascade rules + cross-reference; ZDDC_CORS_ORIGIN
description updated for default-empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:46:57 -05:00

629 lines
36 KiB
Markdown

# ZDDC Architecture
This document is the single authoritative reference for how ZDDC tools are designed and built. It covers the shared single-file HTML application pattern, the build system, tool-specific architectural decisions, and contribution guidelines.
---
## Why Single-File HTML Applications
Every ZDDC tool compiles to a single self-contained `.html` file — no servers, no installers, no subscriptions.
| Principle | Rationale |
|-----------|-----------|
| **Reliability** | Opens in any modern Chromium-based browser without network access or external services |
| **Portability** | Can be emailed, archived, or deployed to air-gapped environments with no tooling |
| **Auditability** | Source, embedded data, and output travel together, satisfying ZDDC traceability requirements |
| **Longevity** | Static assets remain functional long after build environments have changed |
| **Simplicity** | A single `.html` file eliminates deployment steps and brittle dependency chains |
---
## Repository Structure
Every HTML tool follows the same directory layout:
```
tool/
README.md # Feature scope, UI design, domain rules, help content
css/ # Logically separated stylesheets (one responsibility per file)
js/ # Vanilla ES modules (one responsibility per file)
template.html # Shell markup with {{PLACEHOLDER}} markers for development
build.sh # Inlines css/ and js/ into dist/tool.html
dist/
tool.html # Generated output — never edit this manually
```
Website files (what `zddc.varasys.io` serves) live on a **separate Codeberg repo** (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/`) for hand-edited content, plus the **deploy host's `/srv/zddc/`** for the assembled live site. The system Caddy bind-mounts `/srv/zddc/`. `./deploy` rsyncs both into it.
```
~/src/zddc-website/ (clone of codeberg.org/VARASYS/ZDDC-website)
index.html # hand-edited intro page + install snippets (root URL)
reference.html # hand-edited file-naming convention spec
css/, js/, img/ # hand-edited static assets
README.md, LICENSE # repo housekeeping
# NO releases/ — release artifacts are NOT in any git history.
~/src/zddc/dist/release-output/ (gitignored, produced by ./build alpha|beta|release)
index.html # download page, regenerated by build
<tool>_v<X.Y.Z>.html # real per-version HTML (immutable)
<tool>_v<X.Y>.html → ... # symlink: latest patch within X.Y.*
<tool>_v<X>.html → ... # symlink: latest within X.*.*
<tool>_stable.html → ... # symlink: current stable HTML
<tool>_beta.html → ... # symlink to stable (or real bytes when active beta dev)
<tool>_alpha.html → ... # symlink to beta/stable (or real bytes when active alpha dev)
zddc-server_v<X.Y.Z>_<platform> # real per-version cross-compiled binary (raw bytes, no LFS)
zddc-server_v<X.Y>_<platform> → ... # symlink chain (mirrors the HTML cascade per platform)
zddc-server_v<X>_<platform> → ...
zddc-server_<channel>_<platform> → ... # channel mirror per platform
zddc-server_<X>.html # generated stub: cell link → fans out 4 platform downloads
/srv/zddc/ (deploy host; Caddy bind-mount)
index.html, reference.html, css/, js/, img/ ← rsync'd from ~/src/zddc-website/
releases/ ← rsync'd from ~/src/zddc/dist/release-output/
```
`<tool>` ∈ {archive, transmittal, classifier, mdedit, landing}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. The Docker-tag pattern: `:1.2.3` is pinned, `:1.2` floats, `:1` floats further, `:stable` floats furthest, and `:beta` / `:alpha` are mutable channel mirrors that overwrite in place.
**zddc-server binaries are reproducible from a tag, not in git**`./build alpha|beta|release` cross-compiles them into `dist/release-output/`, `./deploy` rsyncs them to `/srv/zddc/releases/`, Caddy serves from there. Older versions: `git checkout zddc-server-v0.0.8 && ./build release 0.0.8`. The `helm/zddc-server-{prod,dev}/` charts build from source via init container, but operators who want a prebuilt binary just `curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64`. The single cell link per release points at `zddc-server_<X>.html`, a small generated stub that surfaces all four platform downloads.
To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release with `./build alpha|beta|release` and then `./deploy`.
Vendor dependencies (bundled third-party libraries) live in `tool/vendor/` if present. The build script is responsible for inlining them into the output.
---
## Documentation ownership
Each topic has exactly one authoritative home; everything else links to it.
| Topic | Single home | Linked from |
|---|---|---|
| What ZDDC is + tool channel links + dual-mode (local/server) overview + install snippets | `~/src/zddc-website/index.html` (hand-edited intro for `zddc.varasys.io/`, in the `ZDDC-website` repo) | repo `README.md`, `zddc/README.md` |
| File-naming convention spec (status codes, modifiers, folder format) | `~/src/zddc-website/reference.html` | repo `README.md`, in-tool help text |
| Versions + channel builds index of every tool | `dist/release-output/index.html` (regenerated by `./build`; deployed to `/srv/zddc/releases/index.html`) | website intro nav, "Browse all versions" link |
| Customer-deployment install (`zddc-server` binary embeds current-stable tools; `.zddc apps:` cascade overrides; cache at `<root>/_app/`) | `zddc/README.md` "Apps: virtual tool HTMLs" section | website intro, `AGENTS.md` |
| zddc-server operations: env vars, ACL syntax, `.archive` URLs, container vs binary | `zddc/README.md` | `AGENTS.md`, website intro |
| Build / release / channel commands | `AGENTS.md` | repo `README.md` ("see AGENTS.md") |
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `AGENTS.md` |
| Per-tool internal design quirks | `<tool>/README.md` | (linked from website intro tool cards) |
`index.html` in the `ZDDC-website` repo (working dir `~/src/zddc-website/index.html`) is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The install section points operators at two paths: **local** (download a `.html` file from `/releases/`) and **server** (run `zddc-server`; current-stable builds of all six tools are baked into the binary at compile time via `//go:embed`). The landing tool's released bytes live at `/srv/zddc/releases/landing_v<X.Y.Z>.html` (rsync'd from `dist/release-output/`); the embedded copy serves at the deployment root by default. The public website at `zddc.varasys.io/` is the same hand-edited `index.html` — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).
When updating documentation, prefer linking over duplicating. If you find yourself rewriting the file-naming convention in a tool's README, link to `reference.html` instead.
---
## Build System
### How It Works
Each HTML tool's `build.sh`:
1. Reads CSS files in declaration order, concatenates them
2. Reads JS files in declaration order, concatenates them
3. Processes `template.html` with `awk`, replacing `{{PLACEHOLDER}}` markers with the concatenated content and stripping CDN `<script>`/`<link>` tags
4. Writes the result to `dist/tool.html`
5. If `--release <channel-or-version>` was passed, calls `promote_release` to write into `dist/release-output/` (per-version file + symlink updates for stable; channel mirror overwrite for alpha/beta).
The top-level `./build` at the repository root is the canonical lockstep entry point. It:
1. On a channel/release cut, **seeds `dist/release-output/` from `/srv/zddc/releases/`** (preserving symlinks) so the bundle is a complete intended-live snapshot, not a sparse one-channel diff. Cascades and the verifier downstream see the same world the live site has.
2. Forwards `--release [version|alpha|beta]` to every HTML tool's build, computing a coordinated next-stable target via `_coordinated_next_stable` (max of every tool's latest tag + 1) when no explicit version is given.
3. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker).
4. On a channel/release cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain (one set per platform) and tag `zddc-server-v<X.Y.Z>` alongside the six HTML-tool tags (stable cuts only).
5. Calls `write_zddc_server_stubs_all` to refresh the per-version + per-channel stub HTML pages from whatever artifacts are in `dist/release-output/`.
6. Regenerates `dist/release-output/index.html` as the action-first download page.
7. Calls `verify_channel_links` — fails the build if any channel link is dangling.
Then `./deploy --releases` rsyncs `dist/release-output/``/srv/zddc/releases/` with `--delete-after`.
### Channels
Three release channels, applied in lockstep across all seven tools (6 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
- **Stable** — versioned, immutable. `./build release [version]` writes per-version HTML for the six HTML tools and per-version binaries for zddc-server (real bytes), refreshes the symlink chain (5 symlinks per HTML tool + 5 symlinks per zddc-server platform) all → the new version, and tags `<tool>-v<X.Y.Z>` for every tool. Skips per-tool HTML rewrites when source hasn't changed since that tool's last stable tag (binaries always rebuild).
- **Beta** — `./build beta` overwrites `<tool>_beta.html` for each HTML tool and `zddc-server_beta_<platform>` for each platform with fresh bytes. Cascades alpha → beta for both HTML and binaries (one symlink per platform). No tag — channel URLs are stable URLs by design.
- **Alpha** — `./build` overwrites only the alpha mirrors, all seven tools. No tag, no other side-effects.
A plain `./build` (no arg) is a dev build: it produces `dist/<tool>.html` and `zddc/dist/zddc-server-<platform>` binaries; doesn't touch `dist/release-output/` or the live site. The download index, stub pages, and verifier only run when a channel/release is being cut.
The cascade rule (stable cut → beta + alpha mirrors reset to stable; beta cut → alpha resets to beta) means downstream channels are never stale across either HTML or binaries. "No active beta" silently shows current stable; "no active alpha" silently shows current beta or stable. Operators don't need to run a freshen step after each stable release.
The on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/alpha/beta builds (`is_red=1`) and black for stable releases. The label format is:
| Build | Label |
|--------------------|--------------------------------------------------------|
| dev (no `--release`) | `v0.0.6-alpha · 2026-04-27 14:00:00 · abc1234[-dirty]` |
| `--release alpha` | `v0.0.6-alpha · 2026-04-27 · abc1234` |
| `--release beta` | `v0.0.6-beta · 2026-04-27 · abc1234` |
| `--release [ver]` | `v0.0.5` |
`X.Y.Z` for non-stable labels is the **next-stable target** — patch+1 from the latest clean `<tool>-vX.Y.Z` tag. Dev builds use the full timestamp + `-dirty` marker so iterative work is distinguishable from a formal `--release alpha` cut (which stamps date-only and is committed-clean by definition).
### Install distribution model
Two orthogonal axes: how the bytes get there (this section), and what runtime mode the tool ends up in (next section).
**Local mode** (no server): download a tool `.html` from <https://zddc.varasys.io/releases/> and open it. Tools are self-contained — no install, no install script.
**Server mode** (`zddc-server`): the binary `//go:embed`s the current-stable build of every tool at compile time. That's the default — no fetching happens out of the box. The server virtually serves each tool only at directories where the convention says it belongs (`internal/apps/availability.go`):
| App | Available at |
|---------------|-------------------------------------------------------------------------|
| `archive` | every directory (multi-project, project, archive, vendor) |
| `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree |
| `mdedit` | any `Working` directory and its subtree |
| `transmittal` | any `Staging` directory and its subtree |
| `landing` | only at the deployment root |
Resolution order at a request to `<dir>/<app>.html` where the app is available:
1. **Override** — real `.html` file at the path → static handler.
2. **`.zddc apps:` cascade** — walk leaf→root for an `apps.<app>` entry. Spec is `stable`/`beta`/`alpha` (canonical channel), `v0.0.4`/`v0.0`/`v0` (canonical version), full URL (custom mirror), or local path. Closer-to-leaf wins.
3. **Embedded** — the build-time HTML compiled into the binary.
URL sources fetch once on first request and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. There is no background refresh, no SHA-256 verification, no admin UI. To pull a new build, delete the cache file. Concurrent misses for the same URL share one outbound fetch (hand-rolled singleflight). Failed fetches fall through to embedded with a one-time WARN log per source URL. Direct URL access to `/_app/...` is blocked at dispatch.
The `X-ZDDC-Source` response header always reports what was served: `fetch:URL`, `cache:URL`, `path:/abs`, or `embedded:<app>@<build>`.
### Runtime mode detection
Independent of how the tool got installed. `archive` auto-detects from the URL and folder shape (`?projects=` set → multi-project; scan root has an `archive/` child → project-root; otherwise → in-archive). The other tools don't care — `transmittal`, `classifier`, `mdedit` work the same regardless of where they live.
### Build Script Requirements
Every `build.sh` must:
- Begin with `#!/bin/sh` and `set -eu` (POSIX sh, not bash)
- Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`, plus the lockstep helpers `_coordinated_next_stable`, `promote_zddc_server`, `write_zddc_server_stubs_all`, `verify_channel_links`)
- Fail immediately on missing source files (`ensure_exists` pattern)
- Clean up temp files on exit (use `trap cleanup EXIT`)
- Accept `--release [<version>|alpha|beta]` — explicit version or channel name; otherwise produce a dev build
### HTML Embedding Safety
When inlining JavaScript into a `<script>` block, the HTML parser scans for the exact string `</script>` to terminate the block — backslash escaping (`<\/script>`) does **not** prevent termination. Any JS source file or vendor library that contains `</tag>` sequences inside string literals or template literals will break the inline `<script>` block.
The rule is:
> **All `</` sequences in inlined JavaScript must be escaped as `<\/` using `sed`.**
Both the app JS concatenation step and any vendor JS bundling step must run through:
```bash
sed 's#</#<\\/#g' "$input_js" > "$safe_js"
```
Then use `</script>` (not `<\/script>`) to close the `<script>` block, since the content no longer contains any `</` sequences that the parser could misread.
This is already enforced for mdedit's vendor bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
### Vendor Dependencies
Some tools bundle third-party libraries. These live in `tool/vendor/` and are committed to the repository. The build script inlines them into `dist/tool.html`.
**Current vendor files:**
| Tool | Library | File | Notes |
|------|---------|------|-------|
| mdedit | Toast UI Editor v3.2.2 | `vendor/toastui-editor-all.min.js` | Markdown editor with live preview |
| mdedit | Toast UI Editor CSS | `vendor/toastui-editor.min.css` | Editor stylesheet |
| transmittal | jszip, docx-preview, xlsx | CDN at runtime | Optional preview features; tool works without them |
**Runtime CDN loading exception**: The transmittal tool loads jszip, docx-preview, and xlsx from CDN at runtime via `loadLibrary()` forDOCX/XLSX preview functionality. These are **optional enhancements**—core transmittal functionality (JSON payload communication) works without them. This exception is documented here because:
1. The core transmittal features (creating, signing, verifying SHA-256 digests) do not depend on these libraries
2. Preview functionality gracefully degrades if libraries fail to load
3. Bundling would significantly increase file size for rarely-used features
**Rule**: Runtime CDN loading is allowed only when:
- Features are strictly optional (graceful degradation)
- Core functionality works without the external library
- Library is clearly documented as non-essential
`template.html` for tools with vendor deps loads those deps from CDN for convenient local development. The build script replaces CDN tags with the bundled vendor files in the output.
### Development vs Production
| Context | Tailwind / Vendor | How to run |
|---------|-------------------|-----------|
| Development | CDN (live, from `template.html`) | Open `template.html` directly in Chromium |
| Production | Bundled / Static CSS | Run `bash tool/build.sh`, open `dist/tool.html` |
For mdedit specifically: `template.html` loads Toast UI from CDN and uses Tailwind Play CDN. The build replaces Toast UI with the bundled vendor file and replaces the Tailwind CDN script with the static `css/tailwind-utils.css` subset.
---
## JavaScript Architecture
### Vanilla JS Only
All tools use plain JavaScript — no TypeScript, no frameworks, no bundlers. Dependencies are managed manually via vendor files.
### Module Pattern
Each JS file wraps its code in an IIFE or module-scope block and registers its API on `window.app.modules`:
```javascript
// js/mymodule.js
(function() {
function doSomething() { ... }
window.app.modules.mymodule = { doSomething };
})();
```
Two top-level globals:
- `window.app` — per-tool app state, modules, and debug surface (every tool)
- `window.zddc` — shared filename/folder/revision parsers from `shared/zddc.js` (every tool)
No other globals. Never expose implementation internals beyond what's needed for testing.
### Module Load Order
JS files are concatenated in the order declared in `build.sh`. Each file can assume earlier files' modules are available on `window.app`. Circular dependencies are not permitted — modules must be layered.
Typical ordering:
```
app.js ← Declares window.app and top-level state
utils.js ← Stateless helpers (no dependencies)
store.js ← State management (depends on app.js)
[domain].js ← Feature modules (depend on store/utils)
main.js ← Initialization (depends on all modules)
```
### State Management
Tools manage state in one of two patterns:
**1. Direct state on `window.app`** (archive, classifier, mdedit)
```javascript
window.app = { files: [], selectedFolders: new Set(), modules: {}, ... };
```
State is read directly; mutations trigger explicit re-render calls. Classifier additionally layers a small pub-sub on top via `store.js` (`store.on('files', render)`).
**2. Proxy-based reactive state** (transmittal)
```javascript
const state = createReactiveState({ mode: 'edit', published: false });
state.subscribe((prop, newVal) => { /* auto-update UI */ });
state.mode = 'view'; // Proxy notifies all subscribers automatically
```
Use reactive state when the same property drives multiple independent UI elements. Use direct state when the data flow is simple and unidirectional.
---
## Tool-Specific Architecture
### Archive Browser
**Pattern:** Direct mutation of `window.app.{directories, files, filteredFiles, selectedFiles, ...}`, helper modules namespaced under `window.app.modules.{events, table, urlState, source, ...}`. Supports two source modes (`window.app.sourceMode`): `'local'` (File System Access API) and `'http'` (zddc-server JSON browse).
**Two-level directory structure required:**
```
root-directory/
transmittal-folder/ ← "grouping folder" — must be a subdirectory
123456-EL-SPC-0001_A (IFC) - Spec.pdf
...
```
Files at the root level are ignored. The grouping folder list and transmittal folder list are populated from the first two levels of the selected directory. Files are only counted in `filteredFiles` after ZDDC filename parsing succeeds.
**Key DOM IDs:** `#addDirectoryBtn`, `#noDirectoryMessage`, `.main-container`, `#filesTableBody`, `#fileCount`, `#selectedCount`, `#selectAllGroupingCheckbox`.
---
### Document Classifier
**Pattern:** Event-driven store (`store.js`) with `notify()` / `on()` pub-sub, spreadsheet rendering on `'files'` events.
**File object shape** (as produced by `scanner.js`):
```javascript
{
trackingNumber: '123456-EL-SPC-2623',
title: 'Specification',
revision: 'A',
status: 'IFC',
extension: 'pdf', // no leading dot
originalFilename: '...', // filename without extension
name: '...', // full filename with extension
path: 'folder/filename.pdf',
size: 45000,
isDirectory: false,
manualFilename: null // set if user overrides computed name
}
```
**`computeNewFilename(file)`** (in `utils.js`) returns `file.originalFilename + '.' + file.extension` if any required field is missing.
**Main app panel** (`#mainApp`) stays hidden (class `hidden`) until a real directory is opened via `showDirectoryPicker`. State can be injected via `store.setFolderTree()` + `store.setSelectedFolders()` for testing without triggering the picker.
---
### Markdown Editor (mdedit)
**Pattern:** Global functions (`window.updateToc`), editor instances managed per file-path in a `Map`, File System Access API for direct file read/write.
**Dependencies:** Toast UI Editor v3.2.2 (bundled), Tailwind utility subset (static CSS).
**Toast UI availability check:**
```javascript
if (typeof toastui === 'undefined') {
// Graceful degradation — show error message
}
const editor = new toastui.Editor({ el: container, ... });
```
**Key DOM IDs:** `#app`, `#select-directory`, `#welcome-screen`, `#file-tree`, `#content-container`.
**File tree:** Populated after `showDirectoryPicker()` resolves. File items are rendered as DOM children of `#file-tree`. Clicking a file opens it in the editor panel.
---
### Transmittal Creator
**Pattern:** Proxy-based reactive state, two-phase hydration, ECDSA digital signatures, SHA-256 file integrity.
**Two-phase hydration:**
1. **`populateStatic()`** — called before publishing. Fills all form fields and the file table into the HTML so the output is readable without JavaScript (progressive enhancement for SharePoint, email clients, etc.).
2. **`hydrate()`** — called on page load of a published transmittal. Hides the "Not Validated" static warning, runs signature verification, and enables interactive features.
**Progressive enhancement matrix:**
| Feature | No JavaScript | With JavaScript |
|---------|--------------|-----------------|
| Content display | ✅ Full | ✅ Full |
| File table | ✅ Shown | ✅ Shown |
| Digest / signatures | ✅ Listed | ✅ Listed + cryptographically verified |
| Validation status | ⚠️ "Not Validated" badge | ✅ "Verified" / ❌ "Invalid" |
| Editing | ❌ Disabled | ✅ Enabled (if draft) |
| Column filtering | ❌ No | ✅ Yes |
**Data store:** A `<script id="transmittal-data" type="application/json">` element embedded in the published HTML holds the full transmittal payload. On load, `data.js` reads and parses it; all UI state derives from this JSON.
**Reactive state:**
```javascript
// app.state is a Proxy — assignments auto-notify subscribers
app.state.mode = 'view'; // Triggers UI updates automatically
```
Subscribe for cross-cutting concerns:
```javascript
app.state.subscribe((property, newValue) => {
if (property === 'mode') updateModeToggleLabel(newValue);
});
```
**Security model:** ECDSA P-256 signing of the SHA-256 digest. Signatures are stored in the JSON payload. Any number of signers can co-sign. Verification runs client-side in the browser's Web Crypto API — no server required.
**Key module globals:** `window.transmittalApp` exposes `app.data`, `app.state`, and `app.modules` for debugging and testing.
---
### Form Renderer (`form/`)
**Pattern:** Schema-driven renderer for the form-data system. Reads a JSON Schema 2020-12 + RJSF-style `ui:*` hints from a server-injected `<script id="form-context">` block; recursively walks the schema and mounts a tree of widgets; on submit, walks the widget tree to serialize back to JSON and POSTs to the URL the form was loaded from.
**Why schema-driven** (vs. transmittal's hardcoded HTML): the form tool is generic — one renderer serves any form spec a user (or LLM) drops into the file tree. Adding a new form requires no code change; adding a new field type to an *existing* form requires only a YAML edit.
**Widget interface** — every widget exposes:
- `el` — DOM root
- `read()` — current value (recurses into children for object / array)
- `setError(msg)` / `clearErrors()` — show / clear field-level errors
- `child(name|idx)` — for container widgets, look up nested widget by JSON-Pointer segment (used by `errors.js` to attach server-side validation messages by path)
**Module layout:**
- `js/app.js``window.formApp = { context, rootWidget, modules }`
- `js/context.js` — read injected `#form-context` JSON
- `js/util.js``h()` DOM builder, JSON-Pointer encode/parse
- `js/widgets.js` — primitives (string/number/integer/boolean/enum, format date/email, textarea)
- `js/object.js` — fieldset rendering with `ui:order` resolution
- `js/array.js` — repeating-row UX (add/remove)
- `js/render.js` — type-triage dispatcher
- `js/serialize.js` — read tree → JSON
- `js/errors.js` — distribute errors by JSON Pointer path
- `js/post.js` — POST + handle 200/201/422/403/409 responses
- `js/main.js` — boot: load context, mount root widget, wire submit
**Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic`. Existence of `<name>.form.yaml` is the trigger; without it, the URL falls through to static-file serving.
**Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive.
---
## Server security model
zddc-server is the deployable surface — it gates access to the file tree that
all the HTML tools work against. The trust story is intentionally narrow,
delegated, and small enough to audit. This section frames it; operator detail
(syntax, anti-patterns, worked layouts, verification recipe, federal-readiness
gap analysis) lives in [`zddc/README.md`](zddc/README.md) § "Access control:
the `.zddc` cascade."
### Cooperating layers
Six layers cooperate to bound what a request can reach. Each does one job;
none of them is load-bearing alone.
| Layer | Job | Implementation |
|---|---|---|
| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate |
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML, walked deepest-first first-match-wins (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` |
### Commercial vs federal trust model
The current implementation is well-shaped for a commercial-tenant model with
delegated auth. Federal-grade qualification (FedRAMP Moderate, NIST 800-53,
FIPS 140-3, DoD STIG) requires several layers to harden. Operators deciding
whether to deploy the system should know which column they're in.
| Property | Commercial trust model (current) | Federal trust model (gaps to close) |
|---|---|---|
| Identity | Email from upstream proxy header | mTLS or signed forwarding token; PIV/CAC via IdP |
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
| Access model | Email allow/deny + single root-admin role | Role-based with identity-source-driven assignment (NIST AC-3(7)) |
| Subtree authority | Leaf allow can override parent deny (delegation) | Configurable enforcement mode where parent denies are absolute (NIST AC-6) |
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
| Disclosure process | Not formalized | `SECURITY.md`, embargoed-fix workflow, CVE assignment |
The full bullet list with NIST control references is in
[`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis."
### Why the tool-rooted view matters for third-party containment
A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool
served at that URL, with that subtree as its world. There is no breadcrumb to
`/Archive/`, no "go to root" button, no listing of sibling vendor folders.
This is **UX containment** — it makes the deployment feel self-contained and
prevents accidental discovery of out-of-scope paths. It is **not** the
security mechanism. Even if a vendor hand-crafts a URL to `/Archive/` or
`/`, the cascade's default-deny rejects them before any byte is served. The
tool-rooted view exists so a vendor reading their email link doesn't have to
choose between "click something I shouldn't" and "click nothing"; the
cascade exists so that choice never matters.
This pairing — UI affordance contained, ACL enforced — is why the server
auto-serves `archive.html` at *every* directory under `ZDDC_ROOT`. If the
archive tool only worked at root, every locked-down subtree would need
either a hand-crafted entrypoint or a tool that knew how to scope itself.
Auto-serving makes "vendor's world starts at `/Archive/<their-name>/`" the
out-of-the-box behavior with no per-deployment configuration.
---
## CSS Architecture
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).
**Common conventions:**
- CSS variables for theme colors and spacing in `base.css`
- Component-scoped class names (no global utilities except where Tailwind provides them)
- `.hidden` class uses `display: none !important` for JavaScript show/hide
- Print styles in a separate `print.css`
**mdedit Tailwind subset:**
`css/tailwind-utils.css` contains only the ~80 Tailwind v3 utility classes actually used in `template.html`. If a new utility class is needed in the template, add it here. Classes follow Tailwind v3 naming and values exactly.
---
## Testing
Tests use Playwright with Chromium only (File System Access API requires it).
### Running Tests
```bash
npm test # all tools
npx playwright test archive # single tool
npx playwright test --debug # debug mode
```
### Test Structure
Each tool has a spec file in `tests/`:
```
tests/
archive.spec.js ← 2 tests: load + directory scan
classifier.spec.js ← 2 tests: load + store injection
mdedit.spec.js ← 2 tests: load + file tree render
transmittal.spec.js ← 2 tests: paste round-trip + filesystem round-trip
fixtures/
mock-fs-api.js ← Reusable File System Access API mock
transmittal-data.js
zddc-filenames.js
```
### Mock File System API
`MOCK_FS_INIT_SCRIPT` (from `tests/fixtures/mock-fs-api.js`) overrides `showDirectoryPicker`, `showOpenFilePicker`, and `showSaveFilePicker`. Inject it via `page.addInitScript` before navigating.
```javascript
// Flat directory
window.__setMockDirectory('name', [{ name: 'file.pdf', content: '...', size: 100 }]);
// Nested directory tree
window.__setMockDirectoryTree('name', {
'subfolder': { 'file.pdf': 'content' },
'root-file.md': 'content',
});
```
### Writing Tests
Follow the pattern in `tests/transmittal.spec.js`:
- Use ESM `import` syntax
- Inject `MOCK_FS_INIT_SCRIPT` in `test.beforeEach` for any test that navigates to a tool page
- Use `waitUntil: 'domcontentloaded'` or `'load'` (not `'networkidle'` — the bundled scripts may keep the network active)
- Prefer `page.waitForFunction` over `page.waitForSelector` for app-state readiness
- Assert through the store/module API for tests that don't need visible DOM
---
## Code Standards
| Rule | Rationale |
|------|-----------|
| No `</script>` or any `</tag>` in JS string literals | Breaks inline HTML embedding — escape with `'<' + '/tag>'` or use `<\/` in `sed` at build time |
| No external dependencies at runtime | Self-contained output requirement |
| No TypeScript, no bundlers | Keeps the build system auditable and simple |
| Only `window.app` and `window.zddc` are global | Keeps the global namespace clean; expose only what's needed for debugging |
| Defensive input validation | File System API handles and user-pasted data are untrusted |
| Update README.md when features ship | Documentation parity is a delivery requirement, not optional |
---
## Git Workflow
**Branching:** short-lived feature branches (`feature/<name>`, `bugfix/<name>`, `hotfix/<name>`), squash-merged to `main` and immediately deleted. Quick fixes (typos, one-liners) go direct to `main`.
**Commit messages:** Conventional Commits — `<type>(<scope>): <description>`. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`. See `AGENTS.md` for the full table and examples.
**Releases:** Tag the commit after confirming `dist/` is current. Format: `{project}-v{version}` (e.g. `archive-v1.0.0`). Semantic versioning applies. There is no CI/CD — the built `.html` file is already committed to the repo.
```bash
bash tool/build.sh # rebuild dist/
git add -f tool/dist/tool.html # stage if needed
git commit -m "chore(tool): rebuild for vX.Y.Z"
git tag tool-vX.Y.Z
git push origin main --tags
git tag -l "archive-v*" # list releases
git push origin :refs/tags/tag-name # delete a remote tag
```
---
## Adding a New Tool
1. Create `tool/` with the standard directory layout
2. Write `template.html` with `{{CSS_PLACEHOLDER}}` and `{{JS_PLACEHOLDER}}` markers
3. Write `tool/build.sh` following the pattern of an existing tool
4. Add `bash "$SCRIPT_DIR/tool/build.sh"` to the root `build.sh`
5. Add a test project entry to `playwright.config.js`
6. Create a stub `tests/tool.spec.js`
7. Force-add the dist output: `git add -f tool/dist/tool.html`
If the tool requires vendor dependencies, download them to `tool/vendor/`, add them to `.gitignore` exclusions if appropriate, and update `build.sh` to inline them (with the `</` escaping step).