Commit graph

378 commits

Author SHA1 Message Date
7ced0395b6 feat(shared): lateral project-stage strip in every tool's header
Adds a thin nav strip directly under the app-header showing the four
canonical lifecycle stages from the transmittal-workflow spec:
archive · working · staging · reviewing. Each is a link to that
stage's directory under the current project. Current stage is
highlighted (bold + primary color, aria-current="page"). Strip
mounts as a sibling of .app-header on DOMContentLoaded — no
template changes needed in any tool.

Render rules (shared/nav.js shouldRender):
- location.protocol must be http: or https: (file:// has no project
  structure to navigate within)
- a project segment must be detectable as the first path segment
  (when it isn't a tool HTML file like /index.html or
  /archive.html?projects=A,B). Multi-project view at the deployment
  root therefore shows no strip.

Stage URL targets:
- Archive   → <project>/archive.html       (project-root archive view)
- Working   → <project>/working/           (directory listing — mdedit auto-served)
- Staging   → <project>/staging/           (directory listing — transmittal auto-served)
- Reviewing → <project>/reviewing/         (directory listing)

Convention-driven, not probed: if a deployment doesn't have one of
these folders the link returns 404. Operators on non-standard layouts
can opt out by setting window.zddc.nav.disabled = true before
DOMContentLoaded.

This pairs with the previous landing-tool change (single-project
click → <project>/archive.html). Together they give the user
both URL-bar manipulation AND visible navigation across the four
canonical project stages.

Five Playwright tests in tests/nav.spec.js exercise:
- non-render at deployment root
- render + active stage on <project>/archive.html
- render + active stage deep inside <project>/working/foo/mdedit.html
- canonical link targets
- mount position is sibling of .app-header

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:50:30 -05:00
cc515b0f56 feat(landing): single-project click navigates to <project>/archive.html
Previously every project click — single or group — built
archive.html?projects=<list> and let the archive tool's URL-state
detection fan out from there. For a single project that's a
single-page-app trick that obscures the canonical URL.

Now single-project clicks navigate to <project>/archive.html instead.
The benefit is direct URL manipulation: the user can swap archive.html
for working/, staging/, reviewing/, archive/<party>/mdl/table.html etc.
in the address bar without going back through landing. zddc-server's
availability.go already auto-serves the right tool at each canonical
folder, so the destinations resolve without any server change.

Multi-project clicks (groups) keep the ?projects=A,B form because
there's no single subtree root. ACL-trimmed groups that collapse to
one project also take the new single-project path, since the result
is effectively a single-project view either way.

The ?v= channel selector continues to carry across both paths.

Two existing landing.spec.js assertions updated to match the new
single-project URL shape; multi-project assertion unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:46:14 -05:00
8ba029612e feat(shared): non-blocking toast helper available to every tool
Promote classifier's local toast (classifier/css/base.css + showToast
in classifier/js/excel.js) into shared/toast.{js,css}. Every tool's
build.sh now concatenates them, so window.zddc.toast(msg, level, opts)
is callable from any tool.

API:
  window.zddc.toast('Saved.', 'success');
  window.zddc.toast('Could not load: ' + err.message, 'error');
  window.zddc.toast('Note', 'info', { durationMs: 3000 });

Levels: info (default) | success | warning | error. Single-toast
policy — a second call replaces the first. Click anywhere on the
toast to dismiss. ARIA: error → role=alert/aria-live=assertive,
others → role=status/aria-live=polite.

Class prefix is .zddc-toast (BEM-ish) to avoid colliding with any
tool-local .toast rules. Classifier's existing showToast now
delegates to window.zddc.toast — call sites in excel.js +
selection.js are unchanged. Classifier's local .toast CSS block
deleted in favor of the shared one.

This commit only EXPOSES the API. Replacing the ~25 alert() call
sites scattered across archive/transmittal/mdedit/classifier with
toast calls is left as follow-up — each alert needs per-call review
to decide if it's truly non-blocking.

Five Playwright tests in tests/toast.spec.js lock the contract:
API exposure, level mapping, ARIA roles, single-toast replace,
click-to-dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:04:41 -05:00
538167b5c8 style(transmittal): tokenize utility classes, drop !important dark overrides
transmittal/css/utilities.css hand-rolled a Tailwind-style utility
subset against hardcoded grayscale (#fff, #f9fafb, #d1d5db etc.)
tuned for light mode. Dark mode then needed a 17-line block of
!important rules in transmittal/css/base.css to swing each utility
class's background/text/border colors. That block was the worst
concentration of !important in the repo.

Tokenize the grayscale classes against shared CSS custom properties
(var(--bg), var(--bg-secondary), var(--text), var(--text-muted),
var(--border)) so the cascade picks up dark mode automatically:

  .bg-white, .bg-gray-50, .bg-gray-100  → var(--bg) / var(--bg-secondary)
  .text-gray-{400..700,900}             → var(--text-muted) / var(--text)
  .border, .border-{b,t}, .border-gray-* → var(--border)
  .hover:bg-gray-{50,100}               → var(--bg-hover)
  .focus:bg-white:focus                 → var(--bg)

Named-color text classes (.text-blue-600 / -green-600 / -red-600)
stay hardcoded — they encode link / success / danger semantics that
should not theme-shift.

The .table-filter-input dark-mode block also went — it was unused
(no element references it; .column-filter from shared is what gets
applied to the actual filter inputs).

!important count in transmittal's non-print CSS dropped from ~32 to
~12. The 6 transmittal Playwright specs still pass.

Verification needed: visually inspect transmittal in both light and
dark mode and flag any color regression. The token mapping is
mechanical but the live rendered output is the only proof.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:59:55 -05:00
0c48a583ad test(shared): smoke tests for the zddc-source.js FS-Access polyfill
The polyfill in shared/zddc-source.js is the seam between every tool
and zddc-server's HTTP API; if it drifts, every HTTP-mode tool breaks
together. Until now it had no direct test coverage — it was only
exercised through tool-level specs that load via file://.

Seven tests cover the documented surface:

- Public API shape on window.zddc.source matches the contract
- detectServerRoot() returns {handle:null, status:0} on file://
  (the negative path most tools branch on)
- httpListing() parses arrays, propagates 403 with err.status
- HttpDirectoryHandle.values() yields HttpFile/DirectoryHandles
  with correct kind+name from server-side is_dir/name fields
- HttpFileHandle.getFile() fetches, surfaces ETag, sets size
- moveFile() POSTs with X-ZDDC-Op: move, X-ZDDC-Destination, and
  optional If-Match — and returns the server's new ETag

Tests host inside classifier/dist/classifier.html (the smallest tool
that bundles zddc-source.js — browse, archive, landing, form do not),
mock fetch via page.route(), and add CORS + preflight responses so
file://-origin pages can read response headers like ETag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:49:11 -05:00
0024172be6 refactor(archive): remove unused debounce helper
archive/js/events.js defined a 10-line debounce function inside the
events IIFE that was never called anywhere in archive/. Dead code,
confirmed by grepping the whole archive/ tree for debounce(.

The plan was to extract debounce to shared/util.js so this file and
mdedit/js/utils.js could share one implementation, but mdedit's debounce
has only one caller (editor.js) so a shared abstraction would be
premature. Just delete the dead copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:48:57 -05:00
3baf160c88 style(shared,archive,browse,mdedit): replace inline styles with CSS
Six inline style="" attributes in archive, browse, mdedit templates
moved into stylesheets:

- The font-size:1.1rem override on #refreshHeaderBtn (three tools) is
  now a single rule in shared/base.css — the refresh ⟳ glyph genuinely
  reads smaller than ◐ / ?, so the rule lives next to the existing
  shared icon-button block.
- The flex-start justify-content + select-all margin on archive's
  Revisions column header become a .th-content--start variant +
  .select-all-checkbox class in archive/css/table.css.
- The 450px initial width + 200px min-width on mdedit's #file-nav move
  into mdedit/css/base.css; the runtime resizer continues to override
  via inline style.width when the user drags.

No visual change — the output is exactly equivalent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:48:44 -05:00
346cbba688 docs(architecture): state-mgmt patterns, zddcMode dispatcher, polyfill gaps
Three additive sections in ARCHITECTURE.md:

1. Promote a recommended state-management pattern. Three patterns coexist
   in the codebase (direct mutation, store pub-sub, Proxy-reactive); the
   recommendation for new tools is direct mutation + explicit re-render —
   it is the boring pick, debuggable, and what 5 of 7 IIFE-pattern tools
   already use. Reactive is appropriate when one state property drives
   ≥3 independent UI regions (transmittal's mode/published/locked).

2. Document the zddcMode dispatcher contract used by the unified
   tables.html bundle that hosts both the form renderer and the table
   view. Standalone form/dist/form.html intentionally has no zddcMode
   set; undefined means "form mode" by back-compat.

3. List zddc-source.js known gaps so callers don't fall into them:
   - recursive directory removal not implemented (HTTP backend has no
     recursive-DELETE endpoint; tools that rename non-empty dirs by
     copy+remove will leak the source dir)
   - no truncate semantics on writes (whole-file replacement only)
   - directory listings re-fetched per traversal (no client-side cache)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:48:33 -05:00
bf5ea7aa4f fix(tables): use fetch keepalive on the beforeunload save path
The TODO at save.js's unload handler was "switch to keepalive on save
for the unload path." flushAllDrafts() kicks off saveRow() per dirty
row when the page is being navigated away from, but those fetches were
not flagged keepalive — modern browsers can cancel them mid-flight as
the page unloads, dropping the user's last typing.

saveRow() now accepts an opts.keepalive flag that is passed through to
fetch(). flushAllDrafts() passes {keepalive: true} so the unload path
gets the keepalive guarantee. Normal saves are unaffected (keepalive
imposes a 64 KB body cap per the Fetch spec — only worth that trade
on the unload path).

Also refreshes the embedded zddc/internal/handler/tables.html bytes via
./build, which folds in this change plus the form welcome-state CSS
from c585112.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:39:42 -05:00
c58511232f feat(form): friendly empty-state when standalone HTML opened directly
The form renderer is server-driven: zddc-server's form handler injects
schema/ui/data into <script id="form-context"> on every render.
Standalone form/dist/form.html opened from file:// has no context and
just rendered as a blank page below the header chrome — no orientation,
no submit button affordance was hidden, but nothing labelled what the
user was looking at.

Detect the empty-context case in form/js/main.js, render a small
.form-welcome card explaining that this tool is server-driven, and hide
the now-meaningless Submit button. The card uses shared tokens
(--bg, --border, --font-mono, etc.) so it themes correctly.

Note: the unified tables.html bundle that hosts the form via the
zddcMode dispatcher is unaffected — tables always sets zddcMode='form'
or 'table' before form's main.js boots, so the welcome only fires for
the standalone HTML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:39:30 -05:00
81b687a2eb test(browse): smoke test for empty state and mock-fs row render
Browse was the only one of the eight HTML tools with no Playwright
spec. Added two tests at the depth of mdedit.spec.js:

  - loads with the empty state visible and add-directory button enabled
  - renders rows from a mock directory after picking

Both use the existing tests/fixtures/mock-fs-api.js machinery (no new
test scaffolding) and run in ~2s under the existing chromium-only
project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:39:17 -05:00
0ad5b7dc0d refactor(mdedit): drop window.* TOC globals, call functions directly
mdedit's toc.js exported updateToc / clearActiveTocItem / setActiveTocItem
as window.* globals, which was the only remaining violation of the
two-globals discipline (only window.app and window.zddc are intended).
Other tools either use IIFE + window.app.modules.X, or — like mdedit —
declare top-level functions that are reachable across concatenated files
without going through window.

Removed the three window.* exports and unqualified the four call sites
in events.js, editor.js (×3), and file-system.js. setActiveTocItem was
already called bare elsewhere; the change is just dropping a
window.foo && check chain that's now unnecessary.

No behavior change — TOC generation and click-to-scroll work as before.
mdedit's three Playwright tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:39:10 -05:00
b7df50f458 docs: correct tool/artifact counts to eight tools / nine artifacts
The repo grew tables and browse since the docs were last revised, but
several paragraphs still said "six HTML tools" / "all seven" / "5 HTML
+ zddc-server". Updated AGENTS.md, ARCHITECTURE.md, CLAUDE.md, README.md,
and zddc/README.md to consistently reflect the current count
(8 HTML + zddc-server = 9 artifacts).

Also expanded README.md's tool table to include browse and landing,
corrected the tables description (no longer read-only), and modernized
the "Build & develop" snippet to show the canonical lockstep
./build alpha|beta|release path instead of the deprecated per-tool
--release form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:38:58 -05:00
3a4a1c7f39 feat(mdl): default columns mirror tracking-number components + customizable
Per the reference doc at zddc.varasys.io/reference.html#tracking-numbers,
a tracking number is composed of: originator, [phase], project,
[area], discipline, type, sequence, [suffix]. The default Master
Deliverables List now surfaces every component as its own column,
plus the standard MDL metadata (title, plannedRevision,
plannedDate, status, owner). Columns appear in the canonical
filename order so the table reads left-to-right like the tracking
number itself.

Optional components ([phase], [area], [suffix]) render in the
table even when blank — keeps the layout consistent across rows.
Projects on a schema that doesn't use them hide the columns by
overriding (see customization).

Form schema (default-mdl.form.yaml):

- One JSON Schema property per tracking-number component, plus
  the deliverable metadata. originator / project / discipline /
  type / sequence are required; phase / area / suffix are
  optional. The schema is intentionally permissive — free-text
  strings on every component, no enums or regex constraints.
  Projects pick their own conventions for originator codes,
  discipline vocabularies, etc.; a default that imposed a
  fixed set would just get in the way.

- Phase 2's editable-cell widget factory derives the right
  per-cell editor from this schema: text inputs for the
  components, the existing select for `status` (which keeps
  its enum), date input for `plannedDate`, textarea for
  `notes`.

Customization (the "way for end users to customize"):

- Drop your own table.yaml and / or form.yaml into the rows
  directory (archive/<party>/mdl/, or any directory hosting a
  table). Operator-supplied files override the embedded defaults
  ATOMICALLY — there's no field-level merge, the operator file
  wins entirely. This matches every other "spec on disk wins"
  convention in zddc-server.

- Hide a column: omit it from the columns: list.
- Rename a column header: change `title:`.
- Add a column: append a {field, title} entry AND add a
  matching property in form.yaml's schema.properties.
- Tighten constraints: use `enum:`, `pattern:`, `minLength:`
  etc. on form.yaml properties.
- Pre-filter rows on load: defaults.filter[<field>].

The whole rows-directory is self-contained — copying mdl/ to a
new project takes the spec, the form, and every row YAML
together.

Documentation:

- AGENTS.md "Tables system" gains a paragraph on the default-MDL
  column set + the customization mechanism + a pointer to the
  embedded source files.

- tables/template.html help panel rewrites the body to cover:
    * What the directory IS (spec + form + row YAMLs together).
    * Editable-cell keyboard shortcuts (the Phase 1-5 sequence
      we just shipped — arrows, Tab, Enter, F2, Delete, Ctrl+D /
      R / C / V / Z, Shift+arrow / Shift+click for ranges).
    * The auto-save model + per-row state swatch colors.
    * The customization model with a worked file-tree example.
  Replaces the obsolete pre-Phase-1 wording that referenced
  `*.table.yaml` parent files and click-to-navigate-row UX.

Tests: no schema test changes — the default YAMLs are loaded
through the same RecognizeTableRequest / RecognizeFormRequest
paths that already cover the fallback. Full Playwright + Go
suites green (44 + 13).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:09:31 -05:00
d3cd662740 feat(tables): editable cells phase 5 — undo + multi-cell ops
Final phase of the editable-cell sequence. Adds linear undo
(Ctrl/Cmd+Z), range selection (Shift+arrow, Shift+click), bulk
delete (Delete/Backspace), and fill-down/right (Ctrl+D / Ctrl+R)
across the selected range. Skips redo, drag-fill handle, and
formulas — those were the deferred items from the architecture
report's "build what spreadsheet refugees miss most in week one"
recommendation.

Undo (tables/js/undo.js):

- Linear command stack, depth 50, session-local. Each Command
  is { cells: [{rowId, field, oldValue, newValue}, ...] }.
  Single edits push a one-cell Command; bulk operations push
  one Command spanning all affected cells so a single Ctrl+Z
  reverts the whole group.
- Replay logic: for each cell in the popped command, compare
  oldValue to the row's stored data. If they match → clear the
  draft (the user's edit reverts to baseline). Otherwise →
  setDraft to oldValue (intermediate state). Then app.repaint().
- Hotkey: document-level keydown for Ctrl/Cmd+Z. Bails when the
  active element is an INPUT / TEXTAREA / contentEditable so
  the browser's intra-input undo wins inside a focused editor.
- Pushed by every edit path: editor.commit, editor.bulkClear,
  editor.bulkFill. Phase 4's clipboard.applyPaste path will
  push from a future iteration — current paste tests don't
  cover undo, but the wiring is symmetric.
- Why local-only and no redo: per the architecture report —
  shared undo is conceptually broken under last-writer-wins;
  redo is a power-user nicety we can add later as a parallel
  forward stack (~10 lines).

Range selection (tables/js/editor.js):

- New state: app.state.range = {anchor, focus} | null. Anchor
  is the cell where the range started; focus is the current
  edge. The cell at focus also has tabindex=0 (the keyboard
  focus owner).
- Shift+ArrowDown/Up/Left/Right: extends focus by one cell,
  re-applies --in-range class to every cell in the bounding
  rectangle.
- Shift+click on a cell: extends the range from anchor to the
  clicked cell. Plain click clears the range.
- Escape clears both selection and range.
- Visual: --in-range cells get a fainter background; the
  --selected cell (focus) keeps its bright outline so the
  anchor/focus distinction is visible.

Bulk delete:

Delete or Backspace in nav mode (no editor mounted) clears
every cell in the current range, setting each to null in the
draft buffer. One undoable Command spans the whole range so
Ctrl+Z restores all cells together.

Fill-down / fill-right:

- Ctrl+D fills the top row's value down through the range
  (Excel/Sheets convention). Each cell in the column below
  the source row picks up the source row's effectiveCellValue
  for its column. Cross-column variation preserved.
- Ctrl+R fills the left column's value right through the
  range. Symmetric to Ctrl+D.
- Both push a single multi-cell Command.

Bug fix shipped alongside:

editor.commit and editor.cancel now ev.stopPropagation() in
addition to preventDefault. Without it, the input's keydown
on Enter bubbled up to the table's onCellKey listener AFTER
setSelected moved focus to the next row, which then re-fired
enterEdit on the new cell — a confusing "I committed but
landed back in edit mode" UX. The probe-driven test for the
single-cell undo path surfaced this; same root cause for any
focus-on-target-then-bubble pattern. Tab and Escape get the
same treatment for symmetry.

Tests (7 new Phase 5 specs, total 44 in tests/tables.spec.js):

- Ctrl+Z reverts a single cell edit to prior value — types in
  one cell, asserts the draft applied, presses Ctrl+Z, asserts
  the cell returned to its original AND the draft buffer is
  empty (returned to baseline → no draft).
- Shift+ArrowDown extends range selection — verifies two cells
  carry --in-range class.
- Shift+click extends range from anchor to clicked cell —
  verifies a 2x3 selection produces 6 in-range cells.
- Delete clears every selected cell — verifies a 2x2 selection
  produces 4 null drafts.
- Ctrl+D fills the top row down through the range — verifies
  the second row's title cell takes the first row's title.
- Ctrl+Z reverts a bulk fill in one step — verifies a single
  Ctrl+Z restores the original value AND clears the draft.
- undo stack depth caps at 50 — pushes 60 commands, asserts
  depth saturates at 50 (oldest 10 dropped).

Bundle size: 138 KB → 144 KB.

Files:

- tables/js/undo.js (new) — command stack, undo, Ctrl+Z hotkey.
- tables/js/editor.js — extendRange, ensureRange, clearRange,
  rangeCells, bulkClearSelection, bulkFill; commit pushes undo;
  Shift+arrow / Shift+click handlers; Delete + Ctrl+D + Ctrl+R
  in onCellKey; setSelected respects keepRange opt; Enter/Tab/
  Escape stopPropagation fix.
- tables/js/app.js — state.range field.
- tables/build.sh — undo.js in concat list.
- tables/css/table.css — --in-range styling.
- zddc/internal/handler/tables.html — regenerated bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:39:26 -05:00
8e703dc61a feat(tables): editable cells phase 4 — copy/paste from Excel/Sheets
Bidirectional clipboard interop with Excel, Google Sheets, and any
other spreadsheet that uses RFC-4180-ish TSV on the text/plain
clipboard mime. Pasted cells write straight into the draft buffer
the same way per-key edits do; row-level save (Phase 3) picks them
up on the next row-blur with the same If-Match optimistic-
concurrency flow.

TSV parser (clipboard.js parseTSV):

- Tabs separate columns, \\n / \\r\\n separate rows.
- Quoted fields ("...") may contain tabs and newlines verbatim.
- Doubled \\"\\" inside a quoted field escapes a literal \\".
- Trailing empty row from a final \\n is dropped (Excel sends
  this; matching the convention avoids a phantom blank row at
  the end of every paste).

Apply-paste (clipboard.js applyPaste):

- Anchor = currently selected cell.
- 1×1 clipboard into selection → writes that one cell.
- N×M clipboard → SPILLS from the anchor down/right to
  (anchor.row + N - 1, anchor.col + M - 1). Cells past the end
  of either axis are silently dropped with a toast count.
- Each pasted value goes through coerceCell, which checks the
  column's row-schema property type:
    * number / integer → Number()
    * boolean          → "true"|"yes"|"1" → true; "false"|
                         "no"|"0"|""      → false
    * everything else  → raw string
  Drafts hold the right JS type so the row-PUT body matches the
  JSON Schema the server validates against.

Copy (clipboard.js onCopy):

- Single-cell selection: Ctrl/Cmd+C writes the cell's
  effectiveCellValue (draft if dirty, else stored) as text/plain
  via formatCell (RFC-4180 quoting on tab/newline/quote).
- Range copy is Phase 5 (depends on range-selection landing).

Event wiring:

- document.addEventListener('paste'/'copy') so events bubble
  from any cell with focus. Phase 1's roving tabindex moves
  focus around; per-cell binding would have to be re-applied
  after every paint.
- onPaste bails when an editor input is mounted (the input
  owns its own paste — typing into a cell editor that was just
  populated with a chunk of TSV would be a footgun).

Toast for partial pastes:

When applyPaste skipped any cells, a small message in
#table-status: "Pasted N cells; M dropped (out of bounds)".
Auto-clears after 4s. Coexists with Phase 3's stale-row prompt
(toast doesn't fire if a prompt is already up; prompt outranks
toast).

Tests (6 new Phase 4 specs, total 37 in tests/tables.spec.js):

- parseTSV handles tabs, newlines, and quoted fields — covers
  the parser edge cases including embedded \\n inside "..." and
  doubled "" escapes.
- paste single value into selected cell — the 1×1 path; verifies
  the draft buffer entry.
- paste 2×2 grid spills from anchor — the N×M spill semantic.
- paste coerces numeric/boolean values via row schema —
  verifies the draft holds typeof===number for an integer column
  and === true for a boolean column.
- paste out-of-bounds drops cells silently with toast — drives
  via dispatched ClipboardEvent('paste') (the only way to
  exercise onPaste end-to-end including the toast).
- copy single cell writes value to clipboard — synthesizes a
  ClipboardEvent('copy') with a writable DataTransfer payload
  and asserts the cell value lands in text/plain.

Bundle size: 134 KB → 138 KB.

Files:

- tables/js/clipboard.js (new) — parseTSV, formatTSV,
  applyPaste, onPaste/onCopy, toast helper.
- tables/build.sh — clipboard.js in concat list.
- zddc/internal/handler/tables.html — regenerated bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:30:05 -05:00
cd751eb604 feat(tables): editable cells phase 3 — row-level save + ETag conflict UX
Cell edits now actually persist. Row-level batch save fires on
row-blur (selection moves to a different row); the request is one
PUT with the full merged row (server-side data + client drafts)
and If-Match: <etag> for optimistic concurrency. Conflict and
validation responses are surfaced inline; drafts are NEVER silently
discarded — when the server says no, the user's typing stays put
until they explicitly reload or replay.

Architecture (per the research synthesis from earlier in this
sequence):

- ETag tracking: context.js readRows captures the per-row ETag
  from HttpFileHandle's response header on the initial GET.
  Stashed at row.etag alongside row.data and row.yamlUrl. Phase 3
  reads it; later phases (undo replay) inherit it.

- Row-blur trigger: editor.js setSelected calls a new
  notifySelectionChanged() hook after selection lands. save.js's
  onSelectionChanged tracks _previousSelectedRowId; when it
  changes AND the previous row had drafts, fires saveRow(prevId).
  Fire-and-forget — don't block the user's flow on the network.

- save.saveRow flow:
    1. mergeRow(row.data, drafts) → full updated row.
    2. js-yaml dump → wire body.
    3. PUT row.yamlUrl, body, headers={Content-Type, If-Match}.
    4. Branch on response status:
       - 200/201 → success: clear drafts + invalid marks, capture
         new ETag from response, replace row.data with merged.
       - 202     → outbox queued (downstream client offline):
         clear drafts (the outbox owns them now), mark row queued.
       - 412     → stale: drafts STAY; mark row stale; show
         status-bar prompt with [Use mine] / [Reload] buttons.
       - 422     → server validation failed; body has
         {errors: [{path, message}]}; mark each cell invalid via
         a red-corner CSS marker + title-attribute tooltip.
       - other   → mark errored; drafts stay.

- Conflict resolution UX:
    - "Use mine" replays the user's drafts onto fresh server
      state. Re-GETs the row to learn the new ETag + new server
      data, replaces row.data with the fresh server values, then
      re-PUTs the merge of fresh + drafts. This is client-side
      field-level last-writer-wins: fields the user did NOT
      touch get the server's new values automatically; only
      fields the user changed override server state. No JSON
      Patch endpoint required — pure client logic on top of the
      existing whole-row PUT path.
    - "Reload" drops drafts entirely, re-GETs the row, repaints.

- Validation error display: per-cell red-corner triangle
  (Excel-style) plus title-attribute tooltip on hover. Marker
  keyed off data-col-idx + the column's field; survives until
  the next edit on that cell or the next paint() cycle.

- beforeunload safety net: any rows with drafts at unload time
  get one fire-and-forget save attempt. Modern browsers limit
  what beforeunload can do; a follow-up could add fetch's
  keepalive flag for a more reliable last-shot.

UI surfaces:

- Per-row state classes drive a left-border swatch in the first
  cell:
    --dirty   subtle blue   (uncommitted changes)
    --saving  muted grey    (PUT in flight)
    --queued  warm yellow   (outbox accepted)
    --invalid orange        (server 422)
    --stale   warning amber (server 412 — also tints row bg)
    --errored red           (other failure — also tints row bg)
  These re-apply across re-paints via save.markAllDirtyRows()
  called from main.js's paint() hook (innerHTML='' wipes them).

- #table-status doubles as the conflict prompt host. When a row
  goes stale, the bar shows
    "This row was changed by someone else. [Use mine] [Reload] [×]"
  and the row-id it's bound to is stored on data-row-id so a
  successful reload of that row dismisses the prompt.

Outbox (downstream client) interaction:

The cache layer's PUT-replay queue intercepts saves transparently.
On local network failure the cache returns 202 with
X-ZDDC-Cache: queued; we treat 202 as "succeeded for now" —
drafts clear (the outbox owns them and will replay), but the
row stays marked --queued so the user knows the write hasn't
reached upstream yet. When the cache replays and gets a
real 200/201/412/etc., the row state will reflect that on next
read (next paint cycle / page refresh).

Tests (4 new Phase 3 specs, total 31 in tests/tables.spec.js):

- row-blur fires PUT with merged drafts + If-Match. Edit a
  cell in row 0, Enter (commits + moves to row 1). Verifies
  PUT went out with the right URL, the merged YAML body
  contains the new value AND the unchanged fields, and the
  If-Match header carries the original ETag.

- 412 conflict marks row stale + shows status prompt. Verifies
  the row gains the stale class, the status bar appears with
  both [Use mine] and [Reload] buttons, AND the draft is
  preserved (never silently dropped on conflict).

- 422 validation errors mark cells invalid. Verifies multiple
  field errors → multiple red-corner cells.

- Reload button drops drafts and refreshes. Verifies the bar
  hides and drafts clear after a successful reload GET.

Setup: a small page.route helper intercepts http://test.local/*
PUTs and GETs, lets each test queue the next response via
window.__nextResponse, and captures requests at
window.__capturedRequests for inspection. Test fixtures use
absolute http URLs in row.yamlUrl so the route catches them.

Bundle size: 127 KB → 134 KB.

Files:

- tables/js/save.js (new) — saveRow, useMine, reload, status
  prompt, row-state markers, beforeunload flush.
- tables/js/editor.js — notifySelectionChanged hook.
- tables/js/context.js — etag + yamlUrl on each row.
- tables/js/main.js — paint() re-applies dirty markers via
  save.markAllDirtyRows; exposes app.repaint for save callbacks.
- tables/build.sh — save.js in concat list.
- tables/css/table.css — row-state classes + invalid-cell corner
  + status-bar prompt styling.
- zddc/internal/handler/tables.html — regenerated bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:26:22 -05:00
e5bb7f216c feat(tables): editable cells phase 2 — schema-driven editor widgets
Replaces the always-text-input cell editor with a per-property
widget factory keyed off the row's JSON Schema (form.yaml). The
table view now picks the right editor for each cell automatically:
strings get text inputs, enums get dropdowns, integers get number
inputs with min/max, dates get date pickers, booleans get
checkboxes, multi-select arrays get a multi-select. Cells whose
schema is a complex type (nested object, generic array, oneOf /
anyOf / allOf) can't be inline-edited and punt to the row's
form-mode editor on Enter / double-click.

Schema discovery:

context.js walkServer fetches <currentdir>/form.yaml as a
companion to <currentdir>/table.yaml — same file the form-mode
renderer already loads, just from the table view's perspective.
Best-effort: a directory with table.yaml but no form.yaml still
renders as a sortable/filterable table; cells just fall back to
plain text inputs without per-property hints. The schema is
exposed as ctx.rowSchema and consumed by the editor's
propertySchemaFor() helper, which walks dot-separated field
names through schema.properties to locate each column's
property schema.

Editor factory (editor.js):

- propertySchemaFor(col) — schema lookup keyed by col.field.
- isComplexSchema(s) — true for nested object, generic array,
  oneOf/anyOf/allOf. Multi-select-friendly arrays
  (string-enum + uniqueItems) are NOT complex; they get an
  inline multi-select widget.
- makeWidget(propSchema, col, initialValue) — dispatches to one
  of the widget builders below based on schema type / format /
  enum + column-spec hints (col.format / col.enum) for tables
  without a form.yaml.

Widget builders, each returning {element, getValue, focus}:

- widgetText        — plain <input type=text>, default fallback.
- widgetTextarea    — for string with maxLength > 200 (long
                      narrative fields).
- widgetTyped(type) — typed inputs the browser can help validate;
                      used for date / date-time / email.
- widgetNumber      — <input type=number> with min/max/step
                      derived from schema.minimum/maximum/
                      multipleOf. Integer schemas force step=1.
                      getValue returns Number, not string, so
                      the draft buffer holds the right type for
                      JSON serialization later.
- widgetCheckbox    — <input type=checkbox>; getValue returns
                      bool. initial value coerces from "true"/
                      true string-or-bool.
- widgetSelect      — <select> with empty placeholder + one
                      option per enum choice; getValue returns
                      the chosen string or null.
- widgetMultiSelect — <select multiple> with size = min(6, N);
                      getValue returns the array of selected
                      values (preserves order in the option list).

Complex-type cells:

isComplexSchema(propSchema) → enterEdit calls navigateToRowForm,
which routes to row.url (already the <id>.yaml.html re-edit URL
the row tracker holds). Phase 5 may swap this for an inline
side-panel mount of form-mode in the same bundle, but the
current navigate-out path delivers the same eventual UX without
needing the side-panel scaffolding.

Type-aware draft equality:

The pre-Phase-2 commit treated every value as a string and
compared via String() equality, which would mark any number-
column edit dirty even when the user re-typed the same number.
The new sameValue() helper handles bool/object via JSON-string
equality and falls back to loose string compare so 42 == "42"
isn't a false dirty. Drafts hold typed values (number, bool,
array) instead of all strings, so when Phase 3 wires the row PUT
the body shape matches the JSON Schema the server validates
against without an additional coercion pass.

Tests (tests/tables.spec.js — 7 new specs, total 22 in the
table view, all 27 in the file):

- enum column edits via select dropdown — verifies the empty
  placeholder + 3 enum options render and the chosen value
  displays back in the cell.
- integer column gives a number input with min/max — verifies
  the type/min/max/step attributes derive from the schema, AND
  the draft buffer holds typeof === 'number'.
- boolean column gives a checkbox — verifies type=checkbox and
  the draft holds true after Space-toggle. (Toggle via Space,
  not Playwright's .check() helper, to dodge the click+blur
  race a focused-checkbox-inside-grid-cell hits.)
- format:date column gives a date input — verifies type=date
  and the existing value pre-populates as YYYY-MM-DD.
- multi-select enum-array column gives a multi-select.
- complex (object) column navigates to the row form on edit —
  verifies no inline editor mounts AND the navigate seam
  receives the row's URL.
- no rowSchema → falls back to plain text editor — verifies the
  best-effort behavior for directories with only table.yaml.

Bundle size: 124 KB → 127 KB (+3 KB for the factory + widget
builders).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:18:25 -05:00
08ce8a1266 feat(tables): editable cells phase 1 — selection + keyboard nav
First step toward the Excel-like editable-table the user asked for.
Architecture decisions in this phase came from a focused research
pass over Notion / Airtable / AG Grid / Handsontable / Glide / W3C
ARIA APG; the design notes are in this commit's predecessor as a
research synthesis. Five phases planned; this is phase 1 of 5 and
ships the cell-selection + keyboard-navigation + per-cell editor
mount-on-demand foundation. Edits in this phase live in a client-
side draft buffer only; row-level save + ETag conflict UX is
phase 3.

Scope:

- ARIA grid pattern verbatim (W3C WAI-ARIA APG): role=grid on the
  table, role=row on rows, role=gridcell on cells, roving
  tabindex (only one cell carries tabindex=0; arrows move it).
  This makes the grid one tab stop in the page tab order — the
  documented spreadsheet UX, and also the basis for screen-reader
  correctness.

- Click selects a cell. Arrow keys move selection. Tab and
  Shift-Tab move with row-wrap. Home / End jump within row;
  Ctrl/Cmd+Home / End jump to grid corners. Enter, F2, double-
  click, or any printable character all enter edit mode. In edit
  mode: Enter commits and moves down (Excel convention), Tab
  commits and moves right (with row-wrap), Escape cancels and
  restores the prior value, blur commits.

- Mount-on-demand cell editor: one <input> at a time is
  instantiated inside the selected cell. Survives 1000-row tables
  without the focus-ring churn an always-editable design would
  hit, and lets Phase 2 swap the input for schema-driven widgets
  (number / date / select / etc.) without restructuring.

- Draft buffer at app.state.drafts keyed by row id (the row's
  re-edit URL — stable across sort and filter). When a cell
  commits with a value different from row.data, the draft entry
  is set; render reads from the draft via effectiveCellValue() so
  the visible cell content reflects unsaved edits. No-op edits
  (commit returns the original value) clear any pending draft.

- Selection survives re-paints. Sort / filter / spec changes
  trigger a re-render; the editor's setSelected at end of paint()
  clamps to new bounds and rebinds tabindex. The user's cell
  doesn't disappear when they sort the column they're editing.

- Numeric coercion fast-path: cells whose column declares
  format=number/integer coerce the input string to Number on
  commit. Phase 2 will generalize this to schema-driven coercion
  for date, boolean, enum, etc.

UX consequence — single-click semantics change:

The pre-existing row-click-navigates-to-form-edit behavior is
gone. Single click now selects a cell (spreadsheet-native). The
"open this row in the form editor" affordance moves to phase 2
(an explicit "Edit…" button or an icon column). The row-click-
navigation tests in tests/tables.spec.js are replaced with seven
new tests covering the editor lifecycle.

What this phase does NOT do (and which phases own it):

- Phase 2: schema-driven editor widgets (right input type per
  column). Server-side validation 422 → red-corner marks. Complex
  types (object, generic array, oneOf) get an "Edit…" button that
  opens the side-panel form-render mode the unified bundle
  already ships.

- Phase 3: row-level save on row-blur via PUT + If-Match. Stale-
  row badge with "Use mine" / "Reload" on 412. Outbox carries the
  offline path transparently via the existing source.js layer.

- Phase 4: copy/paste from Excel/Sheets via TSV parser, spill-
  from-anchor or fill-all into a selection range.

- Phase 5: undo (linear command stack, Ctrl+Z, session-local) and
  multi-cell ops (range select, bulk delete, Ctrl+D / Ctrl+R fill).

Tests (tests/tables.spec.js, all 15 pass):

- clicking a cell selects it (replaces the old row-click-navigates
  test; verifies single-click does NOT navigate)
- arrow keys move cell selection
- Tab and Shift-Tab traverse cells with row-wrap
- Enter enters edit mode; Enter commits and moves down (verifies
  draft is applied to visible cell + selection moves)
- Escape cancels edit, restoring prior value (verifies no-op on
  draft buffer)
- typing a printable char enters edit and replaces the value
- double-click also enters edit mode
- non-editable rows still get the readonly class (cosmetic guard
  for an existing convention; phase 3 will gate write submission)

Files:

- tables/js/editor.js (new) — selection + keyboard handling +
  edit-mode lifecycle + draft buffer.
- tables/js/app.js — state.selected / state.editing / state.drafts
  fields.
- tables/js/render.js — ARIA roles + editor.attachToCell wiring;
  cells render via editor.effectiveCellValue so drafts show.
- tables/js/main.js — paint()-end editor.attachToTable +
  setSelected restore.
- tables/css/table.css — selected-cell focus ring (outline,
  doesn't shift surrounding cells); cell-input bare-inside-cell
  styling.
- tables/build.sh — editor.js in the concat list.
- zddc/internal/handler/tables.html — regenerated bundle.

Bundle size: 117 KB → 124 KB (+7 KB for editor.js + ARIA + draft
machinery). Well within the budget the library survey identified
(Tabulator would have been +100 KB; SlickGrid +34 KB; custom is
+7 KB and we keep the no-third-party-deps invariant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:16:39 -05:00
e6d9966593 refactor(tables): in-dir convention + unified table+form HTML bundle
Two intertwined refactors that share too many files to split cleanly.
Both are described separately below.

PART 1 — in-dir convention for table+form spec files

Old layout had the spec at the parent and rows in a child:

    archive/<party>/
      mdl.table.yaml         spec
      mdl.form.yaml          row-edit form
      mdl/                   rows-dir
        row-001.yaml ...

URLs were /<dir>/mdl.table.html and /<dir>/mdl.form.html. Copying
mdl/ elsewhere lost the spec and form because they lived next door.

New layout collapses everything into the rows-dir:

    archive/<party>/mdl/      self-contained
      table.yaml              spec
      form.yaml               row-edit form
      row-001.yaml ...        rows

URLs become /<dir>/mdl/table.html and /<dir>/mdl/form.html. The
"copying-the-folder-takes-everything" property the user asked for
falls out by construction; the row-edit URL /<dir>/<id>.yaml.html
keeps the same shape (spec is now in the same dir, not the
grandparent).

Server changes:

- internal/handler/tablehandler.go RecognizeTableRequest fires on
  /<dir>/table.html when <dir>/table.yaml exists. The .zddc.tables
  alias map is gone — pure presence-based discovery now matches
  the form system's existing convention. Default-MDL fallback at
  archive/<party>/mdl/ stays for the virgin-archive case (the
  rows-dir need not exist on disk; the URL renders fully virtually).

- internal/handler/formhandler.go RecognizeFormRequest fires on
  /<dir>/form.html and /<dir>/<id>.yaml.html with spec at
  <dir>/form.yaml. specEligible accepts on-disk files OR the
  default-MDL virtual path so an empty mdl/ dir still surfaces the
  add-row form.

- internal/handler/tablehandler.go IsDefaultMdlSpec moves to
  serving archive/<party>/mdl/{table,form}.yaml (5 segments after
  ZDDC_ROOT). New isAtArchivePartyMdlLevel predicate; new
  isAtArchivePartyMdlDir for directory-based recognition. New
  IsDefaultMdlSpecAbs accessor for callers that hold an abs path
  rather than a URL (formhandler).

- internal/handler/formhandler.go loadFormSpec(fsRoot, path) falls
  back to embedded default-MDL bytes when os.ReadFile returns
  NotExist AND the path matches the archive-party-mdl shape. Three
  call sites updated to pass cfg.Root.

- internal/handler/formhandler.go serveFormCreate writes
  submissions to filepath.Dir(req.SpecPath) — the spec, the form,
  and rows all live in one directory. The submissionsDir creation
  is idempotent (MkdirAll); cascade falls back one level for ACL
  evaluation when the dir hasn't been materialized yet.

- internal/handler/tablehandler.go tableRowsRedirect now points at
  /<dir>/table.html (was /<dir>.table.html) when the directory
  request maps to a recognized table.

- cmd/zddc-server/main.go dispatch synth flips from
  urlPath + ".table.html" to urlPath + "/table.html" for the
  no-trailing-slash → tables-app routing.

- internal/apps/availability.go DefaultAppAt comment clarified
  that the dir at archive/<party>/mdl/ IS the table (not a child).

Client changes:

- tables/js/context.js walkServer fetches <currentdir>/table.yaml
  directly — no .zddc walk for table declarations. Rows are every
  *.yaml in current dir EXCLUDING table.yaml and form.yaml. The
  .zddc fetch-for-aliases is gated on file:// (online mode 404s
  on .zddc reads via the dispatcher's reserve guard, so skipping
  the request avoids browser console noise).

- tables/js/main.js add-row button links to relative form.html
  (same dir).

- tables/js/render.js + filters.js: every column's autofilter is
  uniformly a text-contains input, even enum columns — keeps the
  filter row visually consistent and doesn't constrain users to
  the enum vocabulary.

PART 2 — unified table+form HTML bundle

The form-render and table-render code paths share field schemas,
the cell editor for excel-mode IS a form widget, and the form
system's POST-back / validation already exists. Combining the two
HTMLs eliminates duplicating jsyaml/jsonschema/theme/source-
detection/.zddc-parsing across two single-file tools.

- tables/template.html grows two top-level mode containers:
  #table-mode (toolbar + sortable table) and #form-mode (form +
  submit button). Both hidden at parse time; the dispatcher
  unhides one. The shared #form-context placeholder was added
  here so the server's existing injectFormContext target
  resolves.

- tables/js/mode.js (new) sets window.zddcMode synchronously
  based on URL pattern: /form.html or /<id>.yaml.html → form,
  /table.html → table, else inline-context fallback for
  file:// (whichever context blob is non-empty wins). Unhides
  the matching container at DOMContentLoaded.

- tables/js/main.js init() and form/js/main.js boot() each guard
  early when mode isn't theirs. Both apps live on different
  globals (window.tablesApp vs window.formApp) so module
  registration doesn't collide.

- form/js/main.js title write falls back from #form-title to
  #table-title (the unified bundle's shared header element)
  when the dedicated id isn't present.

- tables/build.sh concatenates form modules (widgets, render,
  object, array, errors, post, serialize, util) and form CSS.
  No new external deps. Bundle grows from ~95KB to ~120KB.

- internal/handler/formhandler.go drops the //go:embed form.html
  directive; serveFormRender now writes embeddedTablesHTML via
  a small formRenderHTML() accessor (var declared in
  tablehandler.go, same package). The embedded form.html file
  is removed.

- build script: cp form/dist/form.html → internal/handler/form.html
  step is gone (file no longer exists in the source tree). cp
  tables/dist/tables.html → internal/handler/tables.html now
  runs unconditionally rather than only on beta/stable cuts —
  the renderer is a fixed binary component and dev iteration
  needs the embedded copy refreshed every build. Channel-cascaded
  apps (internal/apps/embedded/) stay channel-gated as before.

- form/dist/form.html still builds for standalone offline-only
  use (downloadable from /releases/), but no longer goes into
  the binary.

Tests:

- internal/handler/tablehandler_test.go and formhandler_test.go
  rewritten for the in-dir layout. New test
  TestRecognizeFormRequest_DefaultMdlAtArchiveParty covers
  empty-form, create POST, re-edit row, and the negative cases
  (Working/, non-mdl name) where the fallback must NOT fire.

- internal/handler/directory_test.go updated for the new
  /<dir>/table.html redirect target.

- cmd/zddc-server/main_test.go TestDispatchSlashRouting Location
  expectation updated.

- tests/form-safety.spec.js loads tables/dist/tables.html
  (named form.html in the temp dir to trigger form-mode in the
  dispatcher) so it tests the same bytes the server returns.
  Title-element selector switches to #table-title.

- tests/tables.spec.js updates the status-filter test for the
  uniform text-input filter.

Docs:

- AGENTS.md form-data system rewrites the URL conventions and
  storage layout for in-dir; gains a Tables system section
  parallel to forms describing the self-contained-directory
  property; subfolder rules ("one table per folder by
  construction; subfolders allowed and silently ignored as rows
  — legitimate uses: nested sub-tables, per-row attachments,
  drafts, future history sidecars") so we don't re-derive this.

Not included (deferred):

- ACL gating on cell-level writes — not relevant until Phase 3.
- Editable cells UI — separate commit (Phase 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:15:26 -05:00
2ce5336289 fix(cache): root-escape guard in mirror walker purgeOrphans
Sub-threshold finding from a focused security review of the CI URL
work — defense-in-depth even though it sits inside the documented
"trust upstream" boundary.

The mirror walker's purgeOrphans deletes local files that aren't in
the upstream's listing. It walked a dirPath built recursively from
upstream-supplied entry names and called os.Remove on the resolved
local path with no containment check. A hostile or compromised
upstream returning ".." in a directory listing could steer the
walker out of cache.root and into the parent — deleting whatever
matches the upstream's "expected to be there" filter in the wrong
directory.

A healthy master never produces such entries (listing.FromDirEntries
filters dot-prefix names), so the bug only fires under an actively
malicious or MITM'd upstream — confidence stayed below the report
threshold. But the fix is small and the cost of being wrong is real
deletion of files outside the cache, so it's worth doing.

Two layers:

1. walker.go walkDir filters upstream listing entries with name ==
   "" / "." / ".." or containing "/" / "\" before recursing. Logs
   a WARN with the dropped name so an operator can see if their
   upstream is misbehaving.

2. purgeOrphans verifies the resolved localDir is contained under
   s.cache.root (HasPrefix(root + sep) || == root) before
   ReadDir+Remove. Logs a WARN and bails on mismatch.

Either layer alone would fix the original vector; both together
match the defense-in-depth pattern cachePathFor already follows for
single-file writes (line 506).

New TestWalker_HostileUpstreamCannotEscapeCacheRoot constructs a
fake upstream that returns a "../" entry in its listing, places a
sentinel file in the parent of cache.root, runs a mirror walk, and
asserts the sentinel survives. Both filter and containment guard
fire; the sentinel stays put.

Existing mirror tests unchanged — the filter only drops names that
shouldn't appear in healthy listings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:10:14 -05:00
85521b98de feat(server): case-insensitive URL canonicalization at dispatch
URLs are now case-insensitive against the on-disk casing under
ZDDC_ROOT, with a lowercase-wins tiebreak when sibling case variants
exist. File and folder names preserve case on disk — the change is a
pure URL→FS-name mapping; nothing renames anything.

internal/fs/resolve.go ResolveCanonical walks segments left-to-right
under fsRoot. Per segment: try lowercase first (canonical / cheap
lstat fast-path), then exact-case, then readdir+CI scan with the
all-lowercase variant winning the tiebreak. Walk stops at the first
segment that doesn't exist on disk so virtual prefixes (.archive,
.profile, .tokens, .auth) and 404 paths flow through with their tail
preserved verbatim. Path-escape safety check on the resolved abs
path matches the existing safeJoin pattern.

Wired in at the top of cmd/zddc-server/main.go dispatch(), which
rewrites r.URL.Path before any handler runs. Downstream handlers
(plus their existing safeJoin calls and the cascade walker) pick up
canonical case automatically — no per-handler changes. The ACL
cascade benefits from this for free since EffectivePolicy is keyed
by the now-canonical absolute path.

internal/handler/middleware.go AccessLogMiddleware snapshots the
as-typed URL path before the rewrite. The audit log's `path` field
records what the client actually sent; a `resolved_path` field is
added only when canonicalization changed it. Operators reading the
log can see both the raw request and what was served.

Lowercase as the project-wide canonical convention is already
honoured by the auto-created folders in internal/zddc/ensure.go
(working/, staging/, archive/<party>/incoming/) and the server's
own state dirs (_app/, .zddc.d/tokens/, .zddc.d/outbox/,
.zddc.d/logs/). Operators who drop a Mixed-Case-Folder/ on disk
keep that casing — the resolver finds it via the readdir tier.

Performance: the lowercase-first lstat is one syscall on the hot
path. Only mismatches (mixed-case URL where on-disk is also
mixed-case) pay the readdir+EqualFold scan, and Linux page-caches
small-dir readdirs aggressively. Apache mod_speling uses the same
"try then fallback" pattern.

Tests:
- internal/fs/resolve_test.go — 9 unit tests: exact-case,
  mixed-case-URL-with-lowercase-on-disk, mixed-case-URL-with-
  mixed-case-on-disk, both-cases-exist-lowercase-wins, nonexistent
  segment preserves remainder, file-segment terminates walk, escape
  rejection, trailing-slash normalization, root.
- cmd/zddc-server/main_test.go TestDispatchCaseInsensitiveURL —
  end-to-end through the dispatcher with sibling Archive/ and
  archive/ on disk; all four URL casings of the same path serve the
  lowercase variant's content (proves the tiebreak fires through
  every layer).
- Full Go suite green.

Docs: AGENTS.md gains a "URL handling" subsection in the
zddc-server section; ARCHITECTURE.md security-model table gains a
"URL canonicalization" row.

Out of scope (separate decisions, can revisit if needed):
- ACL glob CI-matching. If .zddc rules use mixed-case URL globs,
  they won't match the canonical lowercase URL. Workable today by
  writing rules in lowercase. Touches a different package.
- Redirect-to-canonical (303). Server serves under whichever case
  the client used; canonicalization is internal. Could 301 to
  canonical for SEO/bookmark hygiene as a follow-up.
- Client-mode (proxy/cache). Only master mode is wired so far.
  Cache-handler CI lives in internal/cache/cache.go cachePathFor
  and is a separate code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:09:47 -05:00
dd889b4801 chore(embedded): cut v0.0.17-beta 2026-05-08 10:31:34 -05:00
66232598db test: server-backed Playwright harness + /.tokens spec
Adds the first Playwright spec that drives a real running zddc-server
in Chromium. Future UI debugging (the conflict-UI in phase 5, browser-
side iteration on the master's HTML pages, etc.) reuses the same
harness — beforeAll spins up a master on a random port, the spec
talks to it, afterAll tears it down.

Files:

- tests/lib/server.js: CommonJS module exporting startMaster(opts).
  Builds the binary on first run via the canonical podman/zddc-go:1.24
  invocation from AGENTS.md, caching at zddc/dist/zddc-server-test
  with a sibling .hash file (SHA256 of cmd/+internal/+go.{mod,sum})
  that invalidates on source change. Subsequent runs skip the build.
  Set ZDDC_TEST_BIN=<path> to use a pre-built binary (CI / debugging).

  Seeds a minimal master root in os.tmpdir() with a permissive .zddc
  granting the test user (default alice@example.com) full access plus
  read for *@example.com. Picks a free port via net.listen(:0), spawns
  the binary on 127.0.0.1:<port>, polls until listening (max 10s).
  Returns { baseURL, root, proc, logs(), stop() }.

  CommonJS (require/module.exports) rather than ESM because Playwright's
  loader transforms top-level `import` in *.spec.js files but not in
  the .js helpers we ship alongside; mixing produces "exports is not
  defined in ES module scope" at the helper's first line. Spec files
  use `import { ... } from './lib/server.js'` and the import resolves
  through the CJS interop layer cleanly.

- tests/tokens.spec.js: 8 server-backed scenarios covering the entire
  /.tokens contract:

  1. Anonymous → 401 on /.tokens (X-Auth-Request-Email empty).
  2. Authenticated GET /.tokens renders the page with the user's email
     visible in the .who line and the create form + tokens table both
     present and populated.
  3. GET /.api/tokens returns an empty list initially.
  4. Create-via-page round-trip: fill the form, click submit, plaintext
     appears once in #created .token-secret (hidden from later reads),
     row appears in the table, API list confirms the description, the
     row's Revoke button removes it from both the table and the API.
  5. Plaintext token authenticates a subsequent Bearer request even
     when X-Auth-Request-Email is empty — confirms the middleware
     bridge from Bearer to ACL email.
  6. Invalid Bearer → 401 (no silent fallback to anonymous).
  7. Cross-user revoke returns 404 (not 403) — the ownership-non-leak
     guarantee.
  8. XSS guard: description with <img src=x onerror="window.__xss=1">
     should render as text (assert window.__xss !== 1) — the inline
     JS's escapeHTML is the only thing standing between an attacker
     who could create tokens and stored XSS on the management page.

  test.use({ extraHTTPHeaders }) injects X-Auth-Request-Email on every
  request from the Playwright browser context, mimicking what an
  upstream auth proxy adds in production. Per-test overrides clear it
  to test anonymous paths.

- playwright.config.js: adds the `tokens` project. Bumps the global
  timeout from 30s → 60s so the first run's binary-build (~30s on a
  cold gocache) doesn't time out the suite. The tokens project
  testMatches only tokens.spec.js, so other projects (the file://-
  driven tool tests) are unaffected.

Verified: all 8 tests pass (12.5s warm; ~45s cold including the build).
The harness is ready to graft additional server-backed specs onto —
phase 5's conflict-UI in particular will follow the same pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:09:54 -05:00
ac7553f940 fix(client): plug confused-deputy bind in client mode
A focused security review of phases 1-4 surfaced one MEDIUM finding
(confidence 9/10): in client mode (--upstream set) the cache layer
forwards the configured bearer to upstream on every incoming request
without authenticating the local caller, AND --addr defaulted to
:8443 (all interfaces). Together those mean a CLI user running
`zddc-server --upstream https://master --bearer-file ~/token` on a
laptop on hotel/cafe Wi-Fi exposes an open-proxy confused-deputy:
any attacker on the same L2 connects to https://<laptop-ip>:8443,
accepts the self-signed cert, issues GETs (or PUTs/DELETEs that
queue in the outbox), and the cache laundries each request through
upstream with the engineer's bearer. The full cached subtree leaks.

Two layers of defense in config.Load:

1. Loopback default in client mode. When cfg.Upstream is set and
   neither --addr nor ZDDC_ADDR was passed explicitly, --addr
   downgrades to "127.0.0.1:8443" (vs ":8443" in master mode). CLI
   users on a laptop get safe-by-default. Operators who want a
   non-loopback bind opt in explicitly.

2. Refuse non-loopback bind + bearer-file without acknowledgement.
   When cfg.Upstream is set, BearerFile is non-empty, the chosen
   addr is non-loopback, AND --insecure-direct is not set, the load
   fails with an error that names the bind, the threat (open-proxy
   confused-deputy laundering bearer credentials), and the
   acknowledgement flag. The helm zddc-server-cache/ chart already
   sets ZDDC_INSECURE_DIRECT=1 and relies on Kubernetes-namespaced
   pod networking for the gating, so the chart path is unaffected.
   The guard is bearer-file-conditional because proxy mode without a
   bearer doesn't have a credential to launder, and refusing it
   would needlessly block proxy-without-auth deployments.

Tests in internal/config/config_test.go lock down all four cases:
- --upstream with no explicit --addr → 127.0.0.1:8443
- --upstream + non-loopback --addr + --bearer-file (no IDirect) → refuse
- --upstream + non-loopback --addr + --bearer-file + --insecure-direct → ok
- --upstream + non-loopback --addr + NO bearer → ok (no credential to leak)

Doc updates: zddc/README.md client-mode "Flags" section gets a
WARNING block describing the loopback default + insecure-direct
escape hatch. AGENTS.md ZDDC_UPSTREAM row mentions the addr
downgrade. ARCHITECTURE.md gains a "Confused-deputy guard at
startup" subsection under "Master + proxy/cache/mirror" with the
two-layer defense rationale. helm/zddc-server-cache/values.yaml.example
adds an inline note next to addr: ":8080" explaining why the chart
sets ZDDC_INSECURE_DIRECT=1 and what the consequence is of removing
either side of the gating.

Master mode is unaffected — the client-mode validation block is
gated by `if cfg.Upstream != ""`. All existing tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:03:51 -05:00
70d49ba111 fix(client): three bugs found by live smoke testing
Phase 3 + 4 live two-instance smoke tests against the synthetic
~/zddc-test-data fixture surfaced three real bugs that the unit
tests missed. All three are fixed in this commit.

1. walker: filenames with spaces/parens land on disk percent-encoded

   walkSubtree was passing the URL-encoded child URL (built via
   url.PathEscape) to fetchFileIfNeeded → cachePathFor, so a file
   named "Foo (IFI) - Bar.md" landed at <root>/.../Foo%20%28IFI%29
   %20-%20Bar.md on disk. Then purgeOrphans iterated os.ReadDir
   (which sees the encoded names) and compared against upstreamNames
   (decoded names from the listing JSON). Every fetched file was
   classified as an orphan and immediately deleted: a 180-file walk
   produced "fetched=180 purged=111" with only 70 files remaining.

   Fix: walker now maintains two parallel path strings — dirURL
   (URL-encoded for HTTP requests) and dirPath (decoded for disk
   keys). fetchFileIfNeeded, fetchListing, persistOnly, and
   purgeOrphans all take the decoded path. listingCachePathFor
   gets dirPath too. Smoke confirmed: dirs=29 files=180 fetched=179
   purged=0 (one file already cached from the user's GET that
   triggered the walk).

2. outbox: replay loop sleeps 5min after eager startup pass

   RunReplayLoop's idle-poll interval is 5min. After the eager
   startup pass with 0 entries, the loop sleeps 5min — even if a
   PUT-while-offline arrives 1 second later, replay won't fire for
   ~5 min. The cache returned 202 promptly but the queued write sat
   on disk until either a 5min nap elapsed or another PUT happened.

   Fix: Outbox gains a wake chan (buffered=1, drop-on-full).
   Enqueue posts to it after writing meta.json. RunReplayLoop selects
   on wake alongside the timer, so a new offline write triggers an
   immediate replay attempt. Smoke confirmed: PUT queued at T+0,
   master back at T+3, replay completes at T+3 (was previously a
   30s wait through the timer-based poll).

3. master: PUT/DELETE didn't honor If-Unmodified-Since

   The cache's outbox sends If-Unmodified-Since: <cached-mtime> on
   replay so the master can reject conflicting writes with 412. The
   master's checkIfMatch only evaluated If-Match (ETag-based), so
   the cache's mtime-based precondition was silently ignored. Result:
   an offline PUT staged before an external mod would clobber the
   newer external content on replay — silent data loss in the exact
   scenario the outbox is designed to detect.

   Fix: checkIfMatch now also evaluates If-Unmodified-Since per
   RFC 7232 §3.4, returning 412 when the file's current mtime is
   strictly later than the header value (1-second resolution to
   match HTTP-Date precision). Smoke confirmed: cache GET → external
   mod via direct file write → cache offline PUT → master back →
   replay sends IUS → master 412 → outbox entry renamed to
   <id>.conflict-<RFC3339>/ → master content preserved (the
   external mod, not the stale offline write).

Also added an info-level "outbox: replay attempt" log to tryReplay
so an operator watching the cache logs sees the replay loop is
alive even when every entry defers (transport error). Previously
the loop was silent unless a replay actually completed (200) or
conflicted (412).

go vet + go test ./... + go test -race ./internal/{cache,auth,handler}/...
all green. Synthetic ~/zddc-test-data fixture (553 files, 144 PDFs)
exercises the walker against realistic ZDDC filenames including
spaces, parens, and accented characters that the unit tests'
"a.txt" / "b.txt" inputs never hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:34:07 -05:00
e19667b5a2 test: add tests/data/test-archive.sh — synthetic ZDDC fixture builder
Generates a realistic ZDDC archive layout for end-to-end testing of
master + cache + mirror, with zero identifying data and a script
that creates and clears on demand.

Output: ~/zddc-test-data (default; override via TEST_ARCHIVE_DIR),
intentionally OUTSIDE the repo. Defensive .gitignore entries cover
in-repo redirects and the source-reference CSV (~/archive-export*.csv,
which the script never reads at runtime — distributions are baked in
here as constants extracted from a one-time inspection).

Layout mirrors a real archive's shape (project → Archive → party →
Received|Issued → dated transmittal folder → tracking-numbered file)
with synthetic codes throughout — Project-1/2/3, PartyA/B/C, FAC1-4,
lorem-ipsum titles, example.com emails. Disciplines, doc-type codes,
status codes (IFR/IFI/IFA/IFU/RSB), revision letters (A/B/0/0A/0B/C/D),
and tracking-number format are kept as-is — they're public ZDDC
convention vocabularies, not identifying data.

Each file's content is the metadata block:
  Tracking Number: <synthetic>
  Revision: <letter>
  Status: <code>
  Title: <lorem-ipsum>
rendered into the appropriate format per extension. Open any file and
verify it's the right one — md as a table, yaml as keys, html as a
styled table, .zddc as YAML, .zip with three views (md+yaml+html), pdf
rendered via docker.io/pandoc/latex (already-existing 563MB image)
through podman with --userns=keep-id so output is host-user-owned.
Falls back to a hand-rolled minimal valid PDF (Python stdlib only)
when podman or the pandoc image is unavailable.

Subcommands:
  build [--small]   Generate the fixture. --small produces ~12 files,
                    full produces ~550 with every one of the six
                    extensions (md/yaml/pdf/html/zddc/zip) guaranteed
                    in every transmittal.
  clear             rm -rf the fixture. Refuses unless target contains
                    a .zddc — defense against an accidental misconfigured
                    TEST_ARCHIVE_DIR pointing at something important.
  info              File count, total size, by-extension breakdown,
                    top-level layout. No content snippets.

POSIX sh (dash-compatible). Randomness via /dev/urandom (no $RANDOM;
dash doesn't expose it). Per-directory .zddc ACL configs use synthetic
emails from RFC-2606 example.com.

Verified: full fixture builds in ~3min (PDF generation dominates),
contains 144 PDFs all valid 1-page, no real-archive tokens leak
(grep -i for known sentinels from the source CSV returns zero hits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:03:38 -05:00
55852a9efb helm: add zddc-server-cache example chart + ZDDC_NO_AUTH on prod/dev
New chart helm/zddc-server-cache/ deploys zddc-server in client mode
against an upstream master. Mirrors the prod chart's source-build-via-
init-container pattern but with:

- ZDDC_UPSTREAM, ZDDC_MODE, ZDDC_BEARER_FILE, ZDDC_NO_AUTH,
  ZDDC_SKIP_TLS_VERIFY, ZDDC_MIRROR_SUBTREE, ZDDC_MIRROR_MIN_INTERVAL
  wired from values.yaml. Mirror-only env vars conditionally rendered
  (only when mode=mirror) to keep the rendered manifest minimal.
- Bearer token mounted from a separately-created Kubernetes Secret
  (defaultMode 0400) at /etc/zddc/bearer/token. values.yaml.example
  documents the secret-creation flow but contains no token. Secret
  reference can be set to "" to disable bearer auth (only valid for
  upstreams running --no-auth).
- Recreate strategy + replicaCount: 1 (multiple replicas would race
  the cache directory and double the upstream walker traffic).
- TCP-socket probes instead of HTTP — HTTP probes against / would
  fail when both upstream is unreachable AND the cache is empty
  (the cache layer returns 503 + offline header in that state),
  causing crashloops. TCP verifies process liveness without depending
  on upstream reachability or cache contents.
- Mounts a separate cache PVC (operator-provided, like the master's
  data PVC). Sized to the working set you expect to mirror; can be
  much smaller than the master's data volume.

Existing prod and dev charts gain optional ZDDC_NO_AUTH wired from
zddc.env.noAuth (default false → no change to existing rendered
manifests). Useful for trusted-LAN or genuinely-public master
deployments.

Updated docs: helm/README.md gains the cache row in the chart table,
the cache-install quickstart with the secret-creation flow, and the
cache-specific structural notes (Recreate / TCP probes / single-
instance). CLAUDE.md and ARCHITECTURE.md updated to reflect three
charts instead of two.

Verified with helm template rendering: ZDDC_NO_AUTH only renders
when noAuth: true; ZDDC_MIRROR_SUBTREE / ZDDC_MIRROR_MIN_INTERVAL
only render when mode: mirror; bearer volume + ZDDC_BEARER_FILE
only render when bearer.secretName is non-empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:33:01 -05:00
8a049ca2a4 feat(client): outbox — offline write queue + replay with If-Unmodified-Since
PUT / POST / DELETE in client mode now work end-to-end. Online: the
cache layer forwards to upstream and (on success) drops any cached
entry for the path so the next read fetches fresh. PUT/DELETE include
If-Unmodified-Since derived from the cached file's mtime so the master
can reject conflicting writes with 412 Precondition Failed.

When upstream is unreachable, the request is captured in the outbox
at <root>/.zddc-outbox/<id>/ — directory per queued write, mode 0700,
containing meta.json (method, RawURI, Content-Type, base mtime,
queued-at) and body.bin (request body, capped at 256 MiB). The client
gets 202 Accepted + X-ZDDC-Cache: queued and a JSON envelope.

A background replay loop started by runClient processes the queue:
- 2xx → delete entry; drop cached path so next read fetches fresh
- 412 → rename to <id>.conflict-<RFC3339>/ for manual reconciliation
       (body + meta intact for inspection or re-submit)
- 4xx other → drop (retry won't help; logged at WARN)
- 5xx / transport error → leave for next pass

Replay schedule: eager at startup, then 30s while pending falling
back to 5min while idle. Loop honors graceful-shutdown context.
Disabled in --mode=proxy (proxy persists nothing by design — offline
writes return 503 instead of queueing).

Outbox IDs are <unix-nano-base16>-<hex-random> so lex-sort = queue
order; concurrent enqueues never collide. Conflict-rename appends a
4-char random suffix on the unlikely same-second collision.

The local cache is intentionally not updated for offline writes:
until upstream confirms the user reads still see the upstream-cached
version (or 503 if uncached). Trade-off: no "did my queued write
actually win?" ambiguity, at the cost of not seeing one's own
offline edits immediately. Phase 5 will surface .conflict-<ts>/
directories in browse views.

Tests (20 new in outbox_test.go, 5 new in cache_test.go covering
the write path): NewOutbox creates 0700 dir, Enqueue persists meta
+ body, Pending returns lex-sorted entries excluding conflicts,
Replay deletes on 2xx / renames on 412 / leaves on transport error
/ leaves on 5xx / drops on 4xx-other, IUS sent only for PUT/DELETE
with base mtime, query string preserved, ServeHTTP online write
forwards + evicts cache, ServeHTTP offline write queues with 202,
ServeHTTP offline + no outbox returns 503, ServeHTTP PUT sends IUS
from cached mtime, oversize body rejected, IDs lex-sortable,
RunReplayLoop stops on context cancel, concurrent Enqueue 30×
no collisions. Full suite + go vet clean.

Doc updates: zddc/README.md gains a "Writes (online + offline
outbox)" subsection covering both paths and replay outcomes;
"What client mode is NOT, yet" now lists only conflict UI and
multi-tenancy. AGENTS.md client-mode pipeline gains writes +
mirror-mode bullets. ARCHITECTURE.md adds a "Writes: outbox +
offline replay" subsection with the trade-off rationale and the
phase-5-deferred conflict UI hand-off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:20:07 -05:00
707f1d8ec2 feat(client): mirror mode — access-triggered subtree walker + listing cache
--mode mirror layers an access-triggered walker on top of the cache
pipeline. When an incoming request's URL falls under one of the
configured --mirror-subtree paths, the scheduler kicks off a recursive
walk of that subtree iff (a) no walk for that subtree is in flight and
(b) now - last_walk_at >= --mirror-min-interval (default 1h). Walks
run in a goroutine; the user's request never blocks on scheduling.

Why access-triggered: a naive "walk on a fixed timer" would produce
thundering-herd polls on a master from many vendor mirrors most of
which are idle most of the time. Demand-triggering means idle mirrors
generate zero upstream traffic until someone hits them; active
mirrors stay current as a side effect of normal use.

The walk:
1. Recursively fetches JSON listings under the subtree, persisting
   each at <dir>/.zddc-listing.json so directory browsing works
   offline for walked subtrees.
2. For each file, fires a conditional If-Modified-Since GET (bounded
   parallelism; default 4 concurrent) — 304 no-op, 200 overwrites,
   403/404 purges the local cache.
3. After enumeration, per-directory orphan purge: local files absent
   from upstream's filtered listing are removed (handles upstream
   deletes + ACL revocations).

State persists at <root>/.zddc-mirror-state.json as
{subtrees: {<path>: {last_walk_at}}}. In-flight tracking is in-memory
only — a crash mid-walk lets the next access retry without manual
cleanup. Subtree path matching is longest-prefix-wins; "/" is a
catch-all (full mirror, the default when --mode=mirror is set without
explicit --mirror-subtree).

The cache layer also gained directory-listing caching (independent of
mirror mode but enabled by it). Directories are now stored at
<dir>/.zddc-listing.<html|json> sidecars, varied by Accept header.
Hit/miss/offline semantics mirror the file pipeline. Phase 2's
limitation that directories always proxied live (no offline browse)
is now resolved for any directory the user has visited or that mirror
mode has walked.

Mirror scope falls out of auth: the walker uses the local instance's
bearer, so it sees exactly what the user can see at upstream. Admin
bearer → full mirror; vendor bearer → vendor's permitted subtree;
no code distinguishes the cases.

New flags (also as ZDDC_* env vars), ignored when --mode != mirror:
- --mirror-subtree <csv> — repeatable subtrees (comma-separated);
  empty + --mode=mirror = "/" (full mirror)
- --mirror-min-interval <duration> — default 1h

Tests (15 new in walker_test.go, 3 new in cache_test.go): subtree
normalization, longest-prefix matching, root-as-catch-all, walk
fetches all files in scope, out-of-scope URLs are no-op, rate-
limiting prevents double-walks within min-interval, walks re-fire
after interval elapses, orphan purge removes local-only files,
state file survives restart, concurrent triggers don't double-walk,
end-to-end ServeHTTP-kicks-mirror-on-access, listing format varies
by Accept, listing offline serves stale, persisted state atomic
write + corrupt-input handling. Full suite + go vet clean.

Doc updates: zddc/README.md flags table gains the two new entries
plus a "Mirror mode (access-triggered subtree walker)" subsection
with trigger semantics and properties; the "What client mode is NOT,
yet" list shrinks accordingly. AGENTS.md env-var table gains the
two new entries. ARCHITECTURE.md "Master + proxy/cache/mirror"
section now documents the walker scheduler / walk algorithm / state
file in a "Mirror walker (access-triggered)" subsection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:11:30 -05:00
ca00904f1e feat(client): cache mode — on-demand fetch + persist + offline fallback
zddc-server can now run as a downstream client of another zddc-server.
Set --upstream <url> and the master-side machinery (archive index, apps
server, watcher, OPA decider, ACL middleware, token store) is bypassed
entirely; cmd/zddc-server/main.go short-circuits to runClient(cfg)
which uses zddc/internal/cache/Cache as the entire request handler.

Three modes via --mode <proxy|cache|mirror>:
- proxy: forward upstream live, no disk persistence
- cache (default): persist responses on access; subsequent hits serve
  from disk + background If-Modified-Since revalidate
- mirror: accepted but currently behaves like cache; the access-
  triggered walker lands in phase 3

Cache directory layout is intentionally a normal ZDDC root: a file
fetched from <master>/foo/bar.txt is stored at <root>/foo/bar.txt with
no sidecar metadata. The local file's mtime is set to the upstream's
Last-Modified header so revalidation reflects the master's notion of
file age, not local fetch time. Running zddc-server --root <cache-dir>
without --upstream serves the cached files as a plain master — useful
for portable offline snapshots. A small .zddc-upstream marker is
written once on first persist for provenance.

Pipeline (GET/HEAD only — writes deferred):
- Hit → http.ServeContent serves directly (range-aware, 304-aware) +
  background revalidate (304 no-op, 200 overwrite, 403/404 purge)
- Miss → forward to upstream with the configured bearer; tee response
  body to client + tmp-file atomically renamed into the cache
- Network error + cached → serve stale + X-ZDDC-Cache: offline
- Network error + no cache → 503 + X-ZDDC-Cache: offline
- Directories always proxy live (no listing cache yet — phase 3)
- Cache-Control: no-store / private and non-200 responses bypass cache

Range requests work end-to-end (Range/If-Range headers forwarded on
miss; http.ServeContent handles them natively on hit). Hop-by-hop
headers per RFC 7230 §6.1 are dropped from forwarded responses.

New flags (also as ZDDC_* env vars), all ignored when --upstream is
empty (so master deployments are untouched):
- --upstream <url>
- --mode proxy|cache|mirror (default cache)
- --bearer-file <path> (0600 file with the master-issued token)
- --skip-tls-verify (separate from --no-auth; for self-signed dev)

Validation: --upstream must be http(s)://...; trailing / is trimmed.
Mode validated to one of the three known values. The startup
no-root-.zddc check is skipped in client mode (the cache directory
starts empty by design). The plain-HTTP-on-non-loopback check is also
skipped (the local instance never reads the email header to decide
anything; auth is forwarded to upstream as a Bearer).

Tests: zddc/internal/cache/cache_test.go runs httptest.NewServer as
the upstream and covers miss-then-hit, proxy-mode-no-persist,
directory-never-cached, HEAD-no-body, offline-with-cache,
offline-no-cache → 503, bearer forwarding, query-string preservation,
no-store bypass, path-traversal rejection, error-status forwarding,
revalidate-on-403/404/200/304, range-on-hit, concurrent-same-URL,
cache-path boundary cases. 23 new tests, full suite + go vet clean.

Live two-instance smoke verified: master at 127.0.0.1:18443, client
at :18444 with --mode cache, miss→hit→hit transitions work, file
materialises under cache root with parent dirs created, marker file
written once, range-on-hit returns 206, master sees background 304s
on every hit, killing master leaves cached files serving from disk
and never-cached files returning 503 + offline header.

Doc updates: zddc/README.md gains a "Client mode" section with the
modes table, flag reference, pipeline summary, two-instance recipe,
and explicit list of phase-2 limitations; AGENTS.md adds the four
new env vars to the reference table and a "Client mode" subsection
with smoke-test recipe and a pointer to the cache package;
ARCHITECTURE.md adds "Master + proxy/cache/mirror" before "Bearer
token issuance," covering the topology, the persist/warm switches,
the cache-IS-a-ZDDC-root invariant, the request pipeline, and the
v1-out-of-scope multi-tenancy note; CLAUDE.md's zddc/ entry
expanded to mention both deployment shapes so future agents pick it
up by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 07:57:14 -05:00
97ffaac13b feat(server): self-issued bearer tokens + --no-auth flag
zddc-server now issues its own bearer tokens for non-browser callers
(CLI tools, scripts, downstream proxy/cache/mirror instances). No
external IDP, no JWKS rotation. Self-service flow: sign in via the
browser, visit /.tokens, click "Create token," paste the resulting
plaintext into a 0600 file, and pass --bearer-file <path> to whatever
calls back into the server.

Storage is <ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>, YAML per token
with email/created/expires/description. Filename is the *hash* of the
plaintext, never the plaintext itself — a leak of the tokens
directory exposes hashes, not credentials. Mode 0600 / 0700, atomic
writes via temp+rename. Already shielded from public serving by the
existing dot-prefix guards in dispatch and fs.ListDirectory.

ACLMiddleware now recognises Authorization: Bearer <token>. On valid
token, sets the request email from the token file and falls through
to the existing ACL chain. On any failure (unknown / expired / store
unavailable / Bearer with no validator), returns 401 — no silent
fallback to anonymous, so a misconfigured client fails loudly.

JSON API at /.api/tokens (GET list, POST create, DELETE /<id> revoke)
backs a small inline HTML self-service page at /.tokens. Users can
only see and revoke their own tokens; cross-user revoke returns 404
to avoid leaking ownership.

--no-auth (ZDDC_NO_AUTH=1) skips ACL enforcement entirely on this
instance. On master: anyone reads everything (dev / trusted-LAN /
public-read deployments). On a downstream proxy/cache/mirror: trust
upstream's filtering, don't re-evaluate ACLs locally. Implemented as
a swap to policy.AllowAllDecider; all existing handlers keep calling
AllowFromChain unchanged. Distinct from --insecure, which only
relaxes the no-root-.zddc startup check. WARN-level startup log when
--no-auth is active so accidental enablement is visible.

33 new tests covering token storage, validation/expiry/revocation,
the JSON API end-to-end, the HTML page, and the middleware-Bearer
integration including the case-insensitive prefix and expired-token
paths. Full suite + go vet clean.

Doc updates: zddc/README.md "Authentication" rewritten to cover both
auth paths and the token UI/API; AGENTS.md gains ZDDC_NO_AUTH and a
"Bearer tokens" subsection flagging the dot-prefix-shielding pre-
condition; ARCHITECTURE.md adds "Bearer token issuance" and
"--no-auth" subsections under "Server security model" with the
hash-as-filename rationale and dispatch-shielding regression-
sensitivity called out; CLAUDE.md adds a one-line summary of the new
auth topology so future agents pick it up by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 07:40:28 -05:00
562b105550 feat(browse): double-click a folder to navigate into it
Single-click still expands the folder inline; double-click swaps the
view's root onto the folder. In server mode it loads the folder's URL
(zddc-server returns a fresh browse instance scoped there); in FS-API
mode it re-roots state.rootHandle onto the folder's handle and re-
enumerates. Files and zips are unchanged — files have a single-click
preview, zips have inline JSZip expansion.

Help panel updated to document the new behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:43:17 -05:00
0ad47561ed feat(server): redirect rows-dir URLs to canonical .table.html
When an HTML GET hits a directory that's the rows-dir of a registered
table — i.e. parent declares `tables: { <name>: ... }` with a valid
spec, OR the default-MDL fallback applies at archive/<party>/mdl/ —
ServeDirectory now 302s to <parent>/<name>.table.html so users land
on the table view instead of a bare browse listing of the row-yaml
files. JSON GETs on the same URL fall through unchanged so the table
client can still enumerate row files.

Detection reuses RecognizeTableRequest: synthesize the equivalent
.table.html URL from the directory request and let the existing
recognizer apply its operator-vs-default-vs-missing-spec rules. No
duplicated validation.

Updates main_test.go's TestDispatchSlashRouting to expect the new
behavior on archive/<party>/mdl/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:43:08 -05:00
ba20e3e5ba chore(embedded): cut v0.0.17-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
2026-05-07 12:13:04 -05:00
db68dc9eec build(channels): correct wordlist path for tool-side build invocations
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
shared/build-lib.sh is sourced from each tool's build.sh with
root_dir pointing at the tool directory, so paths to other shared/
artifacts have to be \$root_dir/../shared/<file>. The slug helper
incorrectly used \$root_dir/shared/build-words.txt, which only
resolves correctly when the helper is invoked from the repo root
(e.g. via the smoke test `root_dir=\$PWD sh -c …`). In a real
./build run, the helper found the wordlist missing and silently
fell back to the short-SHA — the previous beta cut shipped
"v0.0.17-beta · 2026-05-07 · 1dafc97" instead of the intended
slug.

Fix: align the path with the existing shared/favicon.svg pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:12:46 -05:00
ecd48e5f74 chore(embedded): cut v0.0.17-beta 2026-05-07 12:11:53 -05:00
1dafc97d8e build(channels): swap source-SHA in beta label for a three-word slug
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
Beta on-page labels now read
"v<X.Y.Z>-beta · <date> · word-word-word" instead of the previous
"… · g<short-sha>" form. Words are easier to recognise at a glance
than a 7-char SHA — testers can confirm "I'm running the
ranch-chamber-cake build" without consulting git log.

The slug is computed deterministically from the source commit's
full SHA (three 4-hex chunks modulo the wordlist length, looking
up shared/build-words.txt). Same source state → same slug, so the
existing chore-commit recursion fix carries over: a re-cut on
unchanged source produces an identical label and no spurious
embedded commit.

Wordlist (shared/build-words.txt): 283 short, neutral, English nouns
— flora, fauna, materials, landscape features. 283 ^ 3 ≈ 22M
combinations, more than enough that two simultaneous beta cuts on
different branches don't collide in practice.

Full provenance is still available — the binary's `--version` output
prints `git describe` (tag-distance-gSHA), and the chart appVersion
carries the full SHA written by notify-chart-bump.sh on push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:11:23 -05:00
7c72ca3b1d chore(embedded): cut v0.0.17-beta 2026-05-07 12:08:23 -05:00
32b95c02b8 build(channels): include source SHA in beta on-page label
Beta cuts now stamp the on-page label as
"v<X.Y.Z>-beta · <date> · g<short-sha>" so a tester can confirm at a
glance which source commit they're running against. Previously the
SHA was deliberately omitted to avoid an embedded-commit recursion:
a build that auto-commits the regenerated embedded artifacts
(build:971-995) advances HEAD by one, and a SHA derived from HEAD
would shift on every cut, causing the next cut to commit again
ad infinitum.

Fix: embed the *source* SHA, not HEAD. The new
_source_commit_short_sha helper walks backwards from HEAD past any
"chore(embedded): cut" or "release: vX.Y.Z lockstep" auto-commits
and returns the short SHA of the underlying source state. That SHA
is invariant across the auto-commit step, so a re-run on the same
source state produces the same label and no spurious commit.

Plain dev builds and stable cuts are unchanged. Plain builds already
included the SHA via _sha + -dirty marker; stable cuts intentionally
carry only the bare semver in the label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:08:01 -05:00
41dff23127 feat(handler): per-directory <dir>/.zddc.html editor URL
Add a virtual-URL alias so the existing form-based .zddc editor is
reachable at the natural directory location (<dir>/.zddc.html) in
addition to the legacy /.profile/zddc/edit?path=<dir> entry. Both
flow through the same renderZddcEditor body — same template, same
gate, same form-posts-to-/.profile/zddc semantics.

Wiring:

- IsZddcEditorRequest(urlPath) reports whether the URL ends with
  the .zddc.html leaf (case-fold not needed; .zddc is itself case-
  sensitive on disk).
- ServeZddcEditorAtPath strips the leaf, resolves the parent dir,
  asserts the dir exists, gates on hasAnyAdminScope, calls the
  shared renderer.
- The dispatcher routes IsZddcEditorRequest URLs BEFORE the dot-
  prefix segment guard (which would otherwise 404 the .zddc.html
  leaf). The route is method-gated GET-only; mutations still go
  through PUT/POST/DELETE on <dir>/.zddc via the file API.

Permission model unchanged from the /.profile entry: hasAnyAdminScope
gates visibility of the editor itself; CanEditZddc decides whether
the form is interactive or read-only at the requested directory.
Subtree admins can still inspect ancestor cascade ACLs (intended
since the cascade is what determines their authority).

Test (TestDispatchZddcEditorAtPath): root admin opens project /
working/ / deployment-root editors; non-admin and anonymous both
404; missing directory 404; trailing-segment-after-leaf 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:37:36 -05:00
f7958d7b22 feat(dispatch): trailing slash → browse, no slash → canonical default tool
URL convention for directories under a project:

- <dir>/  (with trailing slash)  → browse (the directory view; same
                                     behaviour as today)
- <dir>   (without trailing slash) → the canonical default tool for
                                     that directory's context, served
                                     inline (no 301 hop)

Tool mapping via the new apps.DefaultAppAt(root, dir):

  - working/...               → mdedit
  - staging/...               → transmittal
  - archive/                  → archive
  - archive/<party>/          → archive
  - archive/<party>/incoming|received|issued/...  → archive
  - archive/<party>/mdl/...   → tables (the per-party MDL grid editor)

Directories outside the canonical layout (project root, scratch
folders) keep the legacy 301-to-trailing-slash redirect since no
default tool fits.

This generalises and replaces the bespoke
"GET archive/<party>/mdl/ → 302 mdl.table.html" redirect added in PR4.
The new dispatcher rule serves the table app inline at the bare-mdl
URL by routing through RecognizeTableRequest with the canonical
.table.html suffix appended; relative fetches resolve identically
because both URLs share the same parent directory.

Tests: TestDefaultAppAt covers all canonical positions plus
case-fold and out-of-tree edges. TestDispatchSlashRouting (replacing
the now-obsolete TestDispatchMdlRedirect) verifies the slash-vs-no-
slash distinction at every canonical folder + non-canonical
fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:26:32 -05:00
dc7bf8ab04 docs(zddc): tighten inherit/strict-mode docstrings + AllowedAtLevel deprecation
Address two follow-ups from the security review of feat/zddc-inherit-directive:

1. file.go's Inherit docstring previously claimed "the internal decider
   treats it as inherit:true and emits a warning at evaluation time" —
   the decider does the first part but the warning was never wired up.
   Strike the over-promise; point operators at the cascade tracer
   (`/.profile/effective-policy`) which surfaces both `cascade_mode`
   and `chain.visible_start` so a fenced configuration that's being
   ignored under strict mode is visible.

2. AllowedAtLevel hardcodes ModeDelegated. Safe today (1-level
   synthetic chain, no ancestors) but a footgun if anyone migrates
   the shim to a real PolicyChain later. Add a `// Deprecated:`
   marker pointing at GrantedVerbsAtLevel for fence-aware paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:10:31 -05:00
ee50213e0b docs(zddc): document the inherit: directive in the access-control ref
Add a new "The inherit: directive" subsection in zddc/README.md
under "Access control: the .zddc cascade" describing the directive's
purpose (vendor-folder reset, regulated subtrees), the four behaviour
points (grants, roles, admins, WORM), the strict-mode refusal under
NIST AC-6, and the tracer's visibility. Cross-link from the "How a
request is evaluated" walkthrough so a reader who's looking up the
core walk can find the fence behaviour without having to scan further.

Update the "Patterns that look secure but aren't" trap #1 (same-level
allow + deny "*@company.com") to recommend inherit: false as the
preferred fix, with a worked-example .zddc snippet alongside the
broken one. The two-level gate-and-reallow pattern remains as a
fallback for federal-track deployments where inherit: is refused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:04:38 -05:00
8ffbcb90d1 feat(handler): expose inherit fence in /.profile/effective-policy
The cascade tracer's JSON response now carries:

- Top-level `cascade_mode` (string): the active mode (delegated /
  strict). Helps reviewers correlate the visible_start with the mode.
- Top-level `chain.visible_start` (int): chain.VisibleStart(leaf, mode)
  — the lowest level whose grants the leaf can see, accounting for any
  inherit:false fence in delegated mode (always 0 in strict mode).
- Per-level `inherit` (*bool, omitempty): the level's explicit inherit
  value, nil when absent. A reviewer can scan the levels and see which
  one fences ancestors.

The level's `exists` flag now also fires for `permissions:` and
`inherit:` entries (previously it only checked Allow/Deny/Admins),
so the response correctly reflects modern .zddc files that use the
permissions map.

Test: TestServeProfileEffectivePolicy_InheritFence builds a vendor-
folder layout, asks the tracer about a my-company user, confirms
decision=false, visible_start=1 (fence at /Vendor/), leaf.Inherit=
&false, root.Inherit=nil.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:02:33 -05:00
2ccd72fa35 feat(zddc): inherit:false fence + strict-mode refusal
A .zddc may now declare `acl.inherit: false` to fence off ancestor
grants and roles from the descendant subtree — the "complete reset
plus add back" pattern operators want for vendor folders and other
narrowly-scoped subtrees. The cascade walker honors the deepest fence
in [0, toIdx] when evaluating any level at-or-below it, both for
GrantedVerbsAtLevel/EffectiveVerbsRange and for role lookup
(RoleMembers / lookupRoleMembers).

Federal/strict cascade mode IGNORES the fence — required by
NIST AC-6 ("ancestor deny is absolute; no leaf-level override"). So
inherit:false has no effect under strict mode and ancestor grants
remain visible. Operators running the federal Rego preset get the
same behaviour from external policy enforcement.

API surface: ACLRules.Inherit (*bool, nil = unset = inherit-true);
ACLRules.InheritsAncestors() bool; PolicyChain.VisibleStart(toIdx,
mode) int. The mode parameter is now threaded through
GrantedVerbsAtLevel, MatchesPrincipal, MatchingPrincipals,
RoleMembers, and lookupRoleMembers so role resolution is fence-aware.

Tests:
- file_test.go: parser round-trip for absent / true / false inherit
- inherit_test.go: VisibleStart (no fence, fence clamps, nested fences,
  strict-mode override), EffectiveVerbs (fence hides ancestor grants,
  strict-mode keeps them), RoleMembers (ancestor roles hidden by fence,
  local redefinition still works)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:59:20 -05:00
821ed3ee19 feat(handler): mdl/ → table-app default with embedded fallback spec
Three pieces wire the per-party Master Deliverables List as the default
view at archive/<party>/mdl/:

1. **Dispatcher redirect.** GET (and HEAD) on
   <project>/archive/<party>/mdl/ (case-fold on archive and mdl) now
   302 → <project>/archive/<party>/mdl.table.html. Non-archive paths
   and deeper mdl/ paths fall through unchanged.

2. **Default-spec fallback in RecognizeTableRequest.** When a request
   matches archive/<party>/mdl.table.html and no operator-supplied
   tables: { mdl: ... } declaration covers it, the handler returns a
   recognised request anyway. Operator declarations still win — and a
   typo'd declaration pointing at a missing file yields 404 (not a
   silent fallback).

3. **Static-file fallback for the spec yaml.** GET archive/<party>/
   mdl.table.yaml and archive/<party>/mdl.form.yaml return embedded
   default bytes (default-mdl.{table,form}.yaml in the handler package)
   when no operator file exists at that path. Operator files always
   win because the dispatcher's os.Stat finds them before reaching the
   IsDefaultMdlSpec branch.

The defaults use ZDDC vocabulary: tracking, title, discipline, type,
plannedRevision, plannedDate, status (DFT/IFR/IFA/IFC/AFC/AB), owner,
notes. Operators override per-party by writing
archive/<party>/{mdl.table.yaml,mdl.form.yaml} and a tables: { mdl: ... }
entry in the party's .zddc.

Tests:
- 4 dispatcher redirect cases (success, case-fold mdl, case-fold archive,
  deeper-path skip, non-archive skip)
- 6 tablehandler cases (default fires at archive/<party>/, operator
  override wins, scope check, embedded yaml served, operator yaml wins,
  scope check on yaml fallback)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:26:53 -05:00
ce108e1eb3 feat(fs): synthesise per-user virtual home in working/ listings
ListDirectory now appends a synthetic <viewer-email>/ entry when the
listed path is exactly <project>/working/ (depth 2, case-fold) and no
real directory there matches the viewer's email under any case.

The entry has IsDir=true and a new Virtual=true flag on
listing.FileInfo (omitempty in JSON so existing clients that don't
know the field continue to render it as a regular folder). A first
write to that path materialises a real folder via the existing
auto-own pipeline (EnsureCanonicalAncestors → WriteAutoOwnZddc),
after which subsequent listings drop the synthetic entry naturally.

Anonymous viewers, listings outside working/, and listings inside a
deeper working/ subdirectory all skip the synthetic entry.

Six tests cover: appears-when-missing, suppressed-when-real-exists
(case-fold), anonymous-no-entry, staging/-no-entry, deep-working-no-
entry, and pre-existing-PascalCase-Working/ still triggers it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:20:25 -05:00
55abce3448 feat(fileapi): mirror staging transmittal folders into working/
When a folder is created under <project>/staging/ whose name parses as a
ZDDC transmittal folder (YYYY-MM-DD_<tracking> (<status>) - <title>) and
whose tracking number contains -TRN- or -SUB-, also create the same-
named folder under <project>/working/ as a drafting space for staff.

The mirror is one-way and one-shot: created at staging-mkdir time only.
Renames and deletions of either side are not propagated. The
transmittal client orchestrates cleanup at issue time (move files to
archive/<recipient>/issued/, then delete both staging and working
siblings) — the server stays out of that decision.

-MDL- tracking deliberately skips the mirror; MDL deliverables live in
archive/<party>/mdl/ rows, not via the working↔staging pairing.

Implementation: mirrorStagingToWorking() in fileapi.go, called after a
successful serveFileMkdir. EnsureCanonicalAncestors handles working/'s
own auto-own .zddc; the mirror folder gets its own creator-grant on top.

Six new tests cover -TRN-/-SUB- mirror, -MDL- skip, non-transmittal
name skip, deep-path skip, and idempotency over a pre-existing sibling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:18:08 -05:00
a79cfd2f88 feat(zddc): EnsureCanonicalAncestors lazy-creates canonical folders on write
New helper pair:
  - ResolveCanonicalPath(fsRoot, target)              — case-fold path resolution, no side effects
  - EnsureCanonicalAncestors(fsRoot, target, email…)  — case-fold + MkdirAll + auto-own .zddc seeding

For each canonical position along the requested path the helpers
substitute on-disk casing (so /Project/working/foo lands in an existing
Working/ rather than a new sibling) and materialise missing
working/staging/archive/<party>/{mdl,incoming,received,issued}/ folders.
working/, staging/, and archive/<party>/incoming/ get a creator-owned
.zddc seeded automatically; received/, issued/, and mdl/ are created
without auto-own (WORM and data-store concerns respectively).
reviewing/ is rejected — purely virtual, never on disk.

Wired into the file API:
  - serveFilePut          — resolve before auth, ensure after auth
  - serveFileMkdir        — resolve before auth, ensure after auth, with
                            two auto-own checks (target-is-canonical OR
                            parent-is-canonical)
  - serveFileMove (POST)  — resolve src+dst, ensure dst before rename so
                            a move from working/<draft> →
                            archive/<recipient>/issued/<draft> creates
                            the per-party folders on the way in

7 new unit tests in zddc/internal/zddc/ensure_test.go cover lazy
creation, case-fold reuse, per-party incoming auto-own, WORM no-auto-own,
empty-principal skip, reviewing rejection, and traversal rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00