Commit graph

177 commits

Author SHA1 Message Date
c8d0afd1b8 chore(zddc): migrate mkdir auto-own hook to the cascade, drop dead predicates
The file API's mkdir post-hook still seeded auto-own .zddc files via the
hardcoded IsAutoOwnPath path-segment predicate, while
EnsureCanonicalAncestors had already moved to the cascade's auto_own:
flag. Point the hook at AutoOwnAt / AutoOwnFencedAt so both paths agree
and an operator's .zddc reshaping actually takes effect — fenced when
the new directory's own cascade level declares auto_own_fenced (per-user
working homes), unfenced otherwise.

Retires IsAutoOwnPath and WormMask (the latter already superseded by
WormZoneGrant's & VerbsRC) plus their tests, and the now-unused
path/filepath import in special.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:42:49 -05:00
9aa587aac0 feat(zddc): incoming/ is a controlled drop zone — project_team read-only, doc controller QCs
Clarify the incoming/ semantics per the workflow: it's the
counterparty's drop zone, not a free-for-all.

  - project_team gets read only here (inherited from the project
    level — they have no c/w, so they can see what's been dropped
    but not touch it). No change in effect; documented explicitly.
  - document_controller gets rwcd here (restated at the incoming/
    cascade level). The QC + transfer workflow — classifier renames
    files in place (w), then they move to received/ (delete here +
    worm-create there) — needs the delete bit, which the inherited
    project-level `rw` lacked.
  - The counterparty's uploader still gets access via a deployment
    .zddc (acl: { permissions: { "*@acme.com": cr } } at
    archive/Acme/incoming/.zddc) or by mkdir'ing a dated subfolder
    under incoming/ and owning it via the existing auto_own — both
    flows unchanged.

Test: standardroles_test now asserts the doc controller has rwcd at
incoming/ and a project_team member has only r there.

All Go + Playwright tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:29:44 -05:00
54dff4dcd3 feat(zddc): standard roles (document_controller, project_team) + role union/reset
Answers "can roles reset as well as add?" — yes, both now.

Role membership UNIONS across the cascade:
  - A deeper .zddc that defines an inherited role again with one
    extra member ADDS that member (was: deepest definition shadowed
    the ancestor's entirely).
  - New `reset: true` on a role definition breaks the union — that
    level's members are authoritative, ancestor definitions above
    are excluded; descendants below still union on top. Use it to
    give a project its own team independent of a deployment-wide
    default.
  - lookupRoleMembers / RoleMembers reworked: walk deep→shallow,
    union members, stop at the first reset:true; finally fold in
    chain.Embedded.Roles as the baseline so a role declared only in
    defaults.zddc.yaml is "defined" (and a deployment's on-disk
    redefinition unions on top).

Admin checks are now role-aware:
  - IsSubtreeAdmin / CanEditZddc's strict-ancestor scan use
    MatchesPrincipal instead of MatchesPattern, so `admins:
    [document_controller]` resolves to the role's members. The
    strict-ancestor scan resolves roles only up to level i, so a
    role defined at the deepest level (= dirPath) never confers
    self-edit rights.

Two standard roles ship in defaults.zddc.yaml (empty members — a
fresh deployment grants nothing until they're populated):

  document_controller — files into the WORM zones. Gets:
    - rw at the project level (read + overwrite-existing; NOT c, so
      it can't make arbitrary folders)
    - rwc at archive/ (can create party subfolders)
    - subtree-admin at working/ and staging/ (full create + manage,
      including taking over a fenced per-user home) — scoped HERE,
      not at the project root, so the WORM constraint still binds
      it in archive/<party>/received|issued
    - listed in worm: on received/ and issued/ → write-once-create
      survives the WORM mask

  project_team — read-only across the project. The per-user
    working home's fenced auto-own .zddc (rwcda for the creator)
    wins via deepest-match, so "read-only except what I own" falls
    out of the cascade with no special rule. Inside received/issued
    their r is preserved (worm: doesn't strip read).

archive/<party>/ gains `auto_own: true` (UNFENCED) so whoever
creates a party subtree (normally the doc controller) owns it and
can set up that counterparty's .zddc afterward — without fencing,
project_team:r still cascades through to received/issued.

Tests: roles_test (union + reset), standardroles_test (the
doc-controller scoped-create matrix + project-team read-only-except-
owned), ensure_test updated for the new party-folder auto-own.
fileapi_test's WORM doc-controller test already uses worm: [role].
All Go + 248 Playwright tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:17:46 -05:00
2de2fdf92c refactor(zddc): worm: is a list of principals, not a {principal: verbs} map
Per design feedback: the verb string in a worm: entry was always
effectively "cr" (the key's whole job is to restore write-once-create
inside the locked zone, and you need read to see what you filed), so
spelling it out per-entry was redundant. worm: is now just a list of
principal patterns — email-globs, @role:name, or bare role names —
and every listed principal gets read + write-once-create. An empty
list ([]) still marks the WORM zone with no create-capable
principals.

Changes:
  - ZddcFile.Worm: map[string]string → []string
  - mergeOverlay: concat-dedupe (a deeper .zddc adds controllers);
    mergeStringSlicePreserveEmpty keeps `worm: []` non-nil through
    the overlay so it still marks the zone
  - WormZoneGrant: walks the list, grants VerbsRC to each matching
    principal; result is always ⊆ {r, c}
  - ValidateFile: validates each entry as an email-glob (role refs
    skipped — validated by the role machinery)
  - defaults.zddc.yaml: received/ and issued/ carry `worm: []`
  - tests updated to the list form (worm_test.go, fileapi_test.go)

All Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:40:15 -05:00
918f330a6f feat(zddc): WORM as a cascade key (worm:), retiring hardcoded path predicates
WORM (write-once-read-many) is no longer a special folder type keyed
off the literal names "received"/"issued". It's a cascade key —
`worm:` on any directory's .zddc — with the ACL-shaped semantics the
user described.

Schema:
  worm:
    "doc-control@example.com": cr   # email-glob or @role:name → verbs ⊆ {r, c}
  # an empty map ({}) is a WORM zone with no create-capable principals

Effect inside a WORM zone (any cascade level declares worm:), applied
AFTER the normal cascade ACL and BEFORE the admin escape hatch:
  - w / d / a stripped for everyone
  - c survives only via the worm: map
  - r survives via the normal ACL OR the worm: map (so a document
    controller who isn't in the project ACL still gets read+create)
  - worm: grants UNION across the cascade — deeper .zddc can name
    more controllers
  - admins (root / subtree) bypass entirely — handler does the
    IsAdmin check before the policy evaluator

defaults.zddc.yaml: archive/<party>/received and archive/<party>/issued
carry `worm: {}` (WORM zone, no controllers — the deployment names
its document controller by adding a deeper .zddc with
`worm: {<principal>: cr}`). The canonical convention is unchanged;
the difference is an operator can now mark any directory WORM, or
rename received/issued, without a code change.

Removed (hardcoded path predicates, superseded by the cascade walk):
  zddc.IsWormPath
  zddc.WormFolderLevelIndex
  zddc.splitPathSegments  (only IsWormPath used it)
Kept: zddc.WormMask (generic verb-set primitive), zddc.VerbsRC.

New:
  zddc.WormZoneGrant(chain, email, mode) → (verbs, inWormZone)
    Walks the chain for worm: declarations; unions the principal's
    grants masked to {r, c}.
  policy.InternalDecider.Allow: WORM block rewritten to consult
    WormZoneGrant instead of IsWormPath/WormFolderLevelIndex.
  ValidateFile: worm: keys validated as email-glob (or @role:name);
    values validated as verb strings ⊆ {r, c}.

Tests:
  - new worm_test.go covers the embedded convention, operator-granted
    controller, w/d masking, cross-cascade union.
  - special_test.go's TestIsWormPath / TestWormFolderLevelIndex
    retired; TestWormMaskStripsWDA kept.
  - fileapi_test.go's WORM tests updated: the doc-controller grant is
    now `worm: { _doc_controller: cr }` at issued/.zddc, not
    `acl.permissions: { _doc_controller: cr }`.
  - federal-parity and admin-bypass tests unchanged — the WORM mask
    still strips w/d/a and admins still bypass.

All Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:29:11 -05:00
9c7858c60a feat(zddc): Phase 4c — stage strip driven by cascade-declared children
The shared/nav.js stage strip previously hardcoded four stages
(archive/working/staging/reviewing) with their labels and target
URLs baked into the file. Operators couldn't add a fifth stage or
rename "Working" to "In-Progress" without forking shared code.

Now cascade-driven end-to-end:

  Server-side:
    listing.FileInfo gains a Declared bool field. fs.ListDirectory
    stamps Declared=true on every entry whose name matches the
    cascade's ChildrenDeclaredAt(parent) — both real on-disk dirs
    and virtual canonical injections. Bugfix in the same patch:
    virtualCanonicalFolders was passing the relative dirPath to
    ChildrenDeclaredAt (which expects absolute); now passes absDir.

  Client-side:
    shared/nav.js fetches the project root's JSON listing on
    DOMContentLoaded, filters to declared+is_dir entries, sorts by
    canonical workflow order (archive → working → staging →
    reviewing, then any extras alphabetically), and renders the
    strip. Labels read e.display_name → falls back to titleCase(name).

    Hardcoded FALLBACK_STAGES kicks in only on fetch failure
    (offline / file:// / non-zddc-server backend). Rendered
    immediately so the strip appears without flicker, then the
    cascade-fetched list replaces it once available.

  Effect:
    Project-3 (which has display: { archive: "Records",
    working: "In-Progress", ... } in its .zddc) now shows
    "Records · In-Progress · Outbox · Pending Responses" in every
    tool's strip. Project-1 still shows "Archive · Working ·
    Staging · Reviewing". No code change to render either; the
    cascade decides.

Tests:
  - tests/nav.spec.js relies on the mock server returning HTML at
    every URL, so the fetch fails over to fallback stages — the
    test renders the same Archive/Working/Staging/Reviewing labels
    it always did, with no test changes needed.
  - All 248 Playwright + all Go tests green.

Remaining client-side hardcode: archive/js/source.js +
archive/js/app.js's mode detection. Phase 4d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:34:56 -05:00
d90975662f feat(zddc): Phase 4b — grid mode driven by cascade default_tool
The /incoming/ path regex in browse/js/grid.js was the second-most
visible client-side hardcode of the canonical convention. Migrating
it to the cascade:

  Header surface:
    X-ZDDC-Default-Tool: <name>   The cascade-resolved default tool
                                  for the listing's directory. Empty
                                  header = no default declared.

  Client wiring:
    loader.fetchServerChildren reads the header into
    state.scopeDefaultTool on every listing fetch (initial mount,
    rescope on dblclick, popstate). grid.classifierAvailableHere
    now returns scopeDefaultTool === 'classifier' instead of
    regex-matching the URL.

  Effect:
    Grid mode auto-activates wherever the cascade picks classifier
    as the default — currently archive/<party>/incoming per
    defaults.zddc.yaml. An operator who sets default_tool: classifier
    on a custom directory gets grid mode there too, no code change.
    An operator who removes the default at incoming sees grid mode
    stop auto-activating there.

  Bootstrap timing fix:
    The initial events.init() runs applyResolvedViewMode before the
    detection fetch completes, so state.scopeDefaultTool is empty
    at that point and grid never auto-activates on first paint.
    app.js bootstrap now re-applies the resolved view mode after
    autoDetectServerMode returns, so a fresh /incoming URL lands
    on grid mode immediately.

The /incoming/ regex is gone. Two client hardcodes remaining
(archive source heuristics, shared/nav stage strip) — Phase 4c/d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:15:25 -05:00
4b04f61e4b feat(zddc): Phase 4a — drop_target cascade key, browse upload zone migrated
The last hardcoded client-side knowledge of the canonical convention
was the upload-zone regex in browse:

    var UPLOAD_SCOPES = /\/(working|staging|incoming)(\/|$)/i;

Now declared in the cascade:

  Schema:
    drop_target: true|false   leaf-only; describes THIS dir
                              (not propagated to descendants)

  Lookup:
    zddc.DropTargetAt(root, dir) bool

  Surfaced to clients:
    Directory listings carry an X-ZDDC-Drop-Target: true response
    header when the cascade declares this leaf as an upload zone.
    No header = no drop target.

  Defaults populated:
    working / working/* / staging / archive/<party>/incoming
    all carry drop_target: true. Operators can extend (e.g. drop
    files on archive/<party>/received via override) or disable
    (e.g. drop_target: false at a specific staging subtree) without
    touching code.

  Browse migration:
    loader.fetchServerChildren reads the response header and stamps
    state.scopeDropTarget on every listing fetch. upload.js's
    currentScopeAllows now reads that flag instead of regex-
    matching the URL. Initial value is false in init.js so a
    listing failure (offline / server doesn't emit the header)
    safely defaults to "no drop zone".

Phase 4a closes the most visible asymmetry between server-side and
client-side cascade knowledge. The remaining client hardcodes
(browse grid-mode regex, archive source heuristics, shared/nav
stage strip) follow the same pattern when needed — Phase 4b/c/d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:12:41 -05:00
6310afa922 chore(zddc): remove dead canonical-folder predicates
Phase 3 retired these symbols by migrating their consumers to the
.zddc cascade lookups. Removing them now that nothing references
them:

  - var  zddc.ProjectRootFolders
  - var  zddc.PartyFolders
  - var  zddc.AutoOwnCanonicalNames
  - var  zddc.VirtualOnlyCanonicalNames
  - func zddc.IsProjectRootFolder
  - func zddc.IsArchivePartyFolder
  - func zddc.IsArchivePartyMdlDir
  - func handler.isArchivePartyDir

The canonical convention is expressed in defaults.zddc.yaml and
consulted via lookups.go's DefaultToolAt / AutoOwnAt / VirtualAt /
IsDeclaredPath / ChildrenDeclaredAt / AvailableToolsAt /
IsToolAvailableAt. Operators override per-directory via on-disk
.zddc files; the embedded layer is the documented baseline.

Test removals:
  - TestCanonicalLists (lists no longer exist)
  - TestIsProjectRootFolder (function no longer exists)

Equivalent coverage lives in lookups_test.go's
TestDefaultToolAt_FromEmbeddedConvention,
TestIsDeclaredPath_FromEmbeddedConvention, etc. — which assert the
convention via the cascade's actual lookup path rather than the
predicates' return values.

handler.isAtArchivePartyMdlDir is RETAINED — it's still actively
consumed by RecognizeTableRequest's default-MDL fallback in
table.html URL resolution. That's a tighter file-path predicate
than the cascade walker would naturally express; can revisit if it
ever needs to become configurable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:01:43 -05:00
5e393cbeaf feat(zddc): Phase 3 completion — all canonical-folder behaviour now cascade-driven
Final consumer migration. The Go-coded lists that previously encoded
the ZDDC convention all defer to the .zddc cascade now.

Schema added:
  available_tools: [tool1, tool2, ...]   concat-union across cascade;
                                          tools not in the union are
                                          denied auto-route at that path
  auto_own_fenced: true|false             generated auto-own .zddc
                                          carries inherit:false (private
                                          to creator)

Lookups added:
  AvailableToolsAt(root, dir)   union of available_tools across cascade
  IsToolAvailableAt(root, dir, tool)
  AutoOwnFencedAt(root, dir)    leaf-only

Cascade semantics finalised (per field):
  default_tool      → leaf→root walk (parent applies to descendants)
  available_tools   → leaf→root union (each level adds; baseline at root)
  auto_own          → leaf-only (creating THIS dir specifically)
  auto_own_fenced   → leaf-only (same)
  virtual           → leaf-only (THIS dir is virtual, not subtree)

Consumers migrated:
  apps.DefaultAppAt        → zddc.DefaultToolAt
  apps.AppAvailableAt      → zddc.IsToolAvailableAt (+ landing special)
  EnsureCanonicalAncestors → AutoOwnAt + AutoOwnFencedAt
  fs.ListDirectory empty-list fallback     → zddc.IsDeclaredPath
  fs.virtualCanonicalFolders               → zddc.ChildrenDeclaredAt
  dispatcher canonical-folder branches     → unified into one
                                              cascade-declared block

Hardcoded helpers REMOVED (dead code):
  apps.inAncestorWithName
  zddc.autoOwnDepthMatch / isAutoOwnDepthMatch

Hardcoded lists kept as data sources for the cascade walker but
no longer drive routing logic:
  ProjectRootFolders / PartyFolders / AutoOwnCanonicalNames /
  VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder /
  IsArchivePartyMdlDir — all still defined; only `ProjectRootFolders`
  is used by special.go's IsProjectRootFolder. The rest are dead.

Dispatcher unified: the previously-two branches (per-party folder vs
project-root folder) collapse into one cascade-declared-path block
that handles the slash/no-slash convention uniformly:
  - no-slash, default_tool=tables  → ServeTable (default-MDL fallback)
  - no-slash, default_tool set     → apps.Serve(tool)
  - no-slash, no default_tool      → 302 to slash form
  - slash, any                     → ServeDirectory empty-list fallback

The IsDir branch's switch also un-hardcoded — any cascade tool is
served (not just the legacy 3 names), so e.g. /Project/archive/<party>
/incoming (no slash) now serves classifier directly rather than 302'ing
to the slash form.

defaults.zddc.yaml populated with the canonical convention as the
recipe. Operators edit it (or override per-directory on disk) to
change any behaviour — no Go code changes required.

Browse drag-drop scope (working/staging/incoming) is the one remaining
client-side hardcoded regex; cascading that requires the cascade JSON
to be served to the client, which is its own Phase 4 piece.

Tests updated for the new no-slash mdl URL convention (landing MDL
card test) and no-slash stage URLs (nav strip test). All 248
Playwright + all Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:36:33 -05:00
9d18047a46 feat(zddc): Phase 3 — DefaultToolAt cascade propagation + apps.DefaultAppAt migration
Two pieces:

1. Lookup helpers walk chain.Levels from leaf back to root. The
   "parent applies to descendants unless overridden" cascade rule
   means a working/ default_tool=mdedit propagates to deep paths
   like working/alice@example.com/notes/sub/deep without anyone
   declaring it at every level. AutoOwnAt and VirtualAt follow the
   same walk; explicit false at a descendant can override an
   ancestor's true (*bool semantics).

2. apps.DefaultAppAt delegates to zddc.DefaultToolAt. The hardcoded
   switch on parts[1] (archive→archive, staging→transmittal,
   working→mdedit, reviewing→mdedit, mdl→tables) and its case-
   sensitivity quirks now live in defaults.zddc.yaml. Operators can
   override any of these per-directory with an on-disk .zddc; no
   code change required.

Semantic improvement: archive/<party>/incoming previously defaulted
to "archive" (because parts[1]=archive and the switch didn't look
deeper). The new convention routes it to "classifier" — incoming/ is
the bulk-rename surface, not a record browser. Updated
availability_test.go to reflect.

All other DefaultAppAt cases — including case-fold (Archive/MDL),
mdl override, reviewing virtual, project root returning "", random
non-canonical names returning "" — produce bit-identical output.

Two new tests in lookups_test.go cover the propagation:
  - TestDefaultToolAt_PropagatesToDescendants
  - TestAutoOwnAt_DescendantCanDisable

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:05:36 -05:00
ea0d29ed17 feat(zddc): Phase 3a — populated defaults + cascade lookup helpers
Schema:
  - default_tool: string  (tool name served at this dir's no-slash URL)
  - auto_own: *bool        (mkdir post-hook auto-grants the creator)
  - virtual: *bool         (never materialise on disk; aggregator routes)

defaults.zddc.yaml: populated with the full canonical convention via
paths:. Top-level "*" matches any project; nested archive/working/
staging/reviewing declare the project-stage tools; archive's "*" /
mdl|incoming|received|issued tree declares the per-party surfaces.
All four party folders and all four project-root folders get their
default_tool; working / staging / archive/<party>/incoming get
auto_own; reviewing / archive/<party>/mdl get virtual. None of these
need on-disk dirs to exist.

Lookups (zddc/internal/zddc/lookups.go):
  DefaultToolAt(root, dir)     → cascade-resolved default tool name
  AutoOwnAt(root, dir)         → does mkdir auto-own here?
  VirtualAt(root, dir)         → never materialise on disk?
  IsDeclaredPath(root, dir)    → does the cascade say anything about this dir?
  ChildrenDeclaredAt(root, dir)→ literal child names declared by Paths

Each looks up via EffectivePolicy → leaf level → Embedded fallback,
so operators' on-disk overrides win and the embedded baseline carries
the convention.

Tests cover the embedded convention, operator overrides, and
inherit:false blocking the embedded layer. No consumer migration yet
— that's Phase 3b. Behaviour is bit-identical for current callers
since none of them consult the new lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:00:45 -05:00
2f08418fb0 feat(zddc): Phase 2 — paths: walker, recursive cascade
Adds the recursive paths: schema and the cascade walker that threads
ancestor virtual contributions through to descendant levels.

Schema:
  paths:
    "*":              # literal-segment or "*" segment-wildcard key
      paths:          # recursive — each step matches one segment
        archive:
          paths:
            "*":
              paths:
                incoming:
                  title: "demo"

Each on-disk .zddc and the embedded defaults can declare paths:; the
walker collects every matching subtree and merges its contributions
into chain.Levels[depth] using mergeOverlay (per-field overlay with
on-disk most specific). The matched glob descends one segment at a
time; the value's own paths: becomes a new virtual source for deeper
matches.

Semantics:
  - matchGlob: literal key first (case-insensitive on segment),
    "*" wildcard fallback.
  - mergeOverlay: top wins per-field on scalars; maps merge key-by-
    key with top overriding; lists concat-dedupe; Paths replaces
    (recursive walker threads it through naturally).
  - inherit:false at any on-disk level drops accumulated ancestor
    virtual sources AND zeroes chain.Embedded — the operator owns
    every rule from that level outward.
  - Behaviour is bit-identical when no .zddc declares paths:; the
    walker reduces to the prior linear cascade.

Eight new tests cover the glob match table, ancestor-paths
contribution, on-disk-wins override, paths-absent bit-identical
behaviour, and inherit:false dropping ancestor paths: contributions.
All existing tests still pass.

Phase 3 next: populate defaults.zddc.yaml with the canonical
ZDDC convention via paths:, and replace apps.DefaultAppAt /
AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames /
IsProjectRootFolder / IsArchivePartyFolder with cascade lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:12 -05:00
d84c1908f6 feat(zddc): Phase 1 — embedded defaults.zddc + inherit + show-defaults
First step of the .zddc-first-configuration rollout: pure plumbing
that makes the future move-everything-out-of-Go work mechanically
possible without changing any current behaviour.

New pieces:

1. zddc/internal/zddc/defaults.zddc.yaml — a real YAML file in the
   repo. Single source of truth for the baked-in baseline; intentionally
   minimal in Phase 1 (just title + empty acl) so existing deployments
   stay bit-identical until Phase 2 starts populating the schema.

2. //go:embed (defaults.go) bakes the bytes into the binary so
   shipped deployments don't need the file. Operators who want a
   starting point export with:

       zddc-server show-defaults > /var/lib/zddc/root/.zddc

3. PolicyChain gains an Embedded ZddcFile field. EffectivePolicy
   layers in the embedded defaults as a baseline below the on-disk
   chain. Consumers that want the full effective view consult both;
   existing consumers that only read chain.Levels keep working
   bit-identically (the new field is additive).

4. New top-level `inherit:` key on ZddcFile. Default true. Set
   `inherit: false` on any on-disk .zddc to zero out chain.Embedded
   — the operator owns every rule from that level outward. Useful at
   the on-disk root to fully reject the embedded defaults; useful at
   deeper levels for sandbox subtrees.

5. `zddc-server show-defaults` (also accepts --show-defaults) subcommand
   dumps the embedded bytes to stdout — same shape as --print-rego.
   No flag plumbing needed beyond the existing args walk.

6. Tests: parse-roundtrip on the embedded file, presence in chain by
   default, inherit:false drops it, explicit inherit:true is a no-op
   versus the default.

Phase 2 (next): add a `paths:` recursive map + `default_tool:` /
`auto_own:` / `virtual:` keys, populate defaults.zddc.yaml with the
canonical ZDDC convention, and migrate apps.DefaultAppAt /
AutoOwnCanonicalNames / VirtualOnlyCanonicalNames to cascade lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:46:51 -05:00
5debd552ae feat: virtual fallback for archive/<party>/* folders + incoming fixture data
Three coupled fixes:

1. landing MDL card: Open button now navigates to /<project>/archive/
   <party>/mdl (no trailing slash) so the tables tool loads. The
   slash form would route to browse instead, which is not what users
   want when they click "Open MDL".

2. zddc-server canonical-folder fallback extended to
   archive/<party>/{mdl,incoming,received,issued}. New
   zddc.IsArchivePartyFolder() recognises any of the four party
   folders at depth 4. fs.ListDirectory returns [] for missing
   on-disk variants (mirroring the project-root behavior added in
   commit 3fc3717); the dispatcher routes slash forms to
   ServeDirectory and the no-slash mdl form to ServeTable, with
   non-mdl no-slash forms 302'ing to the slash form.

   So /Project-N/archive/<party>/incoming/ now lands on an empty
   browse listing rather than 404 when nobody has dropped files yet.

3. Fixture seeded with 3 files per party under incoming/ — naming
   intentionally NOT in transmittal-envelope form, so classifier
   (loaded automatically by browse's grid mode at /incoming/
   per the URL-driven view convention) has something to rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:36:03 -05:00
02bdf851c1 fix(shared/nav): stage strip uses no-slash targets so each stage opens its tool
The shared header strip pointed Working/Staging/Reviewing at the slash
form (working/, etc.), which now serves browse per the slash/no-slash
convention established earlier. The user expected those links to open
the stage's tool (mdedit for working, transmittal for staging, etc.) —
which is what the no-slash form serves.

Also drops the .html suffix from the archive target: <project>/archive
(no slash) → archive tool, same as the other stages. The currentStage
recognizer still accepts /archive.html as a fallback for any direct
URLs that survive in bookmarks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:11:00 -05:00
e85d5fc660 feat(zddc): canonical lowercase + .zddc display map + archive project titles
User report: project root listings showed both "Archive" (PascalCase on
disk) and "archive (empty)" (lowercase virtual) — confusing duplicates.
This sweep:

1. Test fixture migrated to lowercase canonical folder names.
   tests/data/test-archive.sh now creates archive/, received/, issued/
   on disk. Three projects also get human-friendly .zddc titles
   ("Wabash Industrial Refit — Phase 1", etc.), and Project-3 carries
   a display: override demonstrating the new map. Party names
   (PartyA/B/C) stay unchanged — non-canonical.

2. New .zddc display: schema. Maps a child entry's on-disk name to a
   human-friendly label. The on-disk name stays canonical (lowercase
   for project-root folders); only the rendered label changes. Match
   is case-insensitive. Example:

     display:
       archive:   "Records"
       working:   "In-Progress"

   No upward cascade — a parent .zddc doesn't relabel grand-children;
   each directory sets display: on its own children.

3. listing.FileInfo gets a DisplayName field. fs.ListDirectory reads
   the directory's .zddc display map and stamps DisplayName per entry.
   The field is omitempty so listings without overrides stay
   byte-identical to before.

4. Virtual canonical project-root folders (archive/working/staging/
   reviewing) are now emitted by zddc-server (fs.ListDirectory) at any
   project root where the on-disk variant is absent in any case. This
   replaces the client-side injection in browse and lets the display:
   map apply to virtual entries the same way it applies to real ones.
   Browse drops its withVirtualCanonicals helper; the loader carries
   display_name through from the server's listing.

5. Archive app project picker dropdown shows the .zddc title of each
   project (sourced from ProjectInfo.Title in the server's project
   list), falling back to the folder name when no title is set. When
   they differ, the folder name is rendered in muted mono after the
   title for traceability. data-name still carries the canonical
   folder name so URL state stays stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:03:53 -05:00
ee67b9e596 fix(zddc-server): mdl slash form serves browse; .zddc viewable at every depth
Two related routing fixes:

1. /<project>/archive/<party>/mdl[/] now follows the slash/no-slash
   convention uniformly with the rest of the system:

     - mdl  (no slash) → tables app (default tool for mdl/)
     - mdl/ (slash)    → browse (ServeDirectory empty-listing fallback)

   Previously the slash form auto-redirected to mdl/table.html, which
   forced the user into the table view from any party-folder click and
   produced a confusing "Unrecognized table URL" error when the
   redirect race-conditioned. tableRowsRedirect now only redirects
   when a real on-disk table.yaml exists; the default-MDL virtual case
   stays in browse via the convention.

   New zddc.IsArchivePartyMdlDir helper recognises the canonical
   <project>/archive/<party>/mdl pattern at depth 4 (relative path).
   fs.ListDirectory uses it to return [] for the missing-on-disk case
   so browse renders the empty workspace cleanly. Test updated
   (TestServeDirectoryRedirectsDefaultMdl → TestServeDirectoryDefaultMdlNoRedirect).

2. <dir>/.zddc URLs now work at every directory depth.

   The dispatcher previously 404'd anything beginning with a dot
   (except /.archive and /<dir>/.zddc.html). New IsZddcFileRequest +
   ServeZddcFile handlers carve out the raw .zddc leaf so an operator
   can navigate to /Project-1/archive/PartyA/mdl/.zddc and inspect
   the rules effective at that depth.

   Semantics:
     - Method: GET / HEAD only. Writes go through the existing admin-
       gated form at <dir>/.zddc.html (unchanged).
     - ACL:    parent directory's read permission gates access; 404
       (not 403) is returned to non-readers so existence isn't leaked.
     - On disk: file bytes served verbatim with
       Content-Type: application/yaml and X-ZDDC-Source: file:<rel>.
     - Virtual: when no file exists at this level, a synthetic
       placeholder body is returned with a YAML-comment cascade
       summary so the reader sees exactly what rules apply here from
       ancestors. X-ZDDC-Source: virtual:zddc distinguishes it.

   The virtual body parses as valid YAML (`{}` after the comments) so
   downstream tooling that consumes the URL isn't confused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:45:16 -05:00
d052e9fed3 Round of UX fixes: tool strip removed, MDL routing, browse markdown layout, reviewing depth-2
Four user-reported items:

1. landing: remove the standalone-tool strip from the site picker.
   Per user, it was awkward — links pointing at zddc.varasys.io
   releases from inside a deployment is a layering confusion. The
   nav.tool-strip block in landing/template.html and its CSS are
   gone.

2. zddc-server: route /Project/archive/<party>/mdl[/] to the tables
   app for the virtual-MDL case where the on-disk folder doesn't
   exist yet. Previously fell through to 404 because the dispatcher
   only routed virtual mdl/ via the IsDir branch — the IsNotExist
   branch was missing the equivalent check. Now both shapes (with
   and without trailing slash) hit RecognizeTableRequest's default-
   MDL fallback and ServeTable serves the embedded tables.html.

3. browse: re-layout the markdown editor to mirror mdedit's layout.
   Was: sidebar on right with TOC top + front-matter bottom.
   Now: sidebar on LEFT with YAML front matter top + Outline bottom,
        content on RIGHT with an informational header (file title +
        save controls + status + source) above the Toast UI editor.
   New horizontal resizer between the front-matter and outline
   sections inside the sidebar (drag the row boundary; arrow keys
   step by 24 px). Browse test selectors updated.

4. zddc-server reviewing aggregator: extend to depth ≥ 2 so the
   user can preview files inside virtual reviewing/<tracking>/
   received/ and staged/ folders. IsReviewingPath now returns a
   sidePath ("received[/rest]" or "staged[/rest]"); ServeReviewing's
   depth-2 branch proxies the underlying real folder's listing,
   emitting folder entries with virtual reviewing/ URLs (so
   navigation stays in the aggregator) and file entries with
   canonical archive/ or staging/ URLs (so byte fetches resolve
   directly). ACL is enforced against the real path; depth-1
   received/ + staged/ URLs are now virtual too (was canonical),
   so the user smoothly descends into the depth-2 listing.

Tests updated for the new IsReviewingPath signature and the depth-1
URL shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:30:34 -05:00
b1479c5104 feat(zddc-server): include browse/form/tables in apps cascade
Wires up live alpha-dev iteration on bitnest. With this change a
`.zddc apps: <tool>: <path>` entry overrides the embedded copy for any
of the eight tools, not just five.

Two coupled fixes:

  1. zddc.AppNames had a five-entry list (archive/transmittal/
     classifier/mdedit/landing) — predating browse/form/tables.
     ResolveWithOverride's `if !IsKnownApp(app)` gate silently rejected
     those three before ever looking at the cascade, falling back to
     embedded with an "unknown app" error.

  2. handler.ServeDirectory hard-coded `apps.EmbeddedBytes("browse")`
     for the HTML directory-listing fallback, bypassing the apps
     subsystem entirely. Now takes an optional *apps.Server and
     delegates to appsSrv.Serve(w, r, "browse", chain, absDir) when
     wired, so the cascade is honored at bare directory URLs too
     (the most common way browse gets surfaced).

Both call sites in main.go and the test signatures in
directory_test.go updated. ValidateFile error message now lists all
eight known apps.

Verified end-to-end on bitnest with a root .zddc apps cascade
pointing at /srv/.zddc.d/source/<tool>/dist/<file>: every `./build`
on the host is now immediately visible after a hard refresh. Iteration
loop is `./build` (or `sh tool/build.sh`) then reload — no container
restart needed, since the apps subsystem reads the path source on
each request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:09:24 -05:00
c87fb7f4fa chore(embedded): cut v0.0.17-beta 2026-05-11 11:51:58 -05:00
436e8ca066 feat(landing): standalone-tool strip on the site picker
Above the Groups / Projects cards, a horizontal strip of one link per
tool — each pointing at the latest stable single-file build on the
canonical release host (zddc.varasys.io/releases/<tool>_stable.html).
Useful for "try this tool" / offline use without first picking a
project.

Seven links (Archive, Transmittal, Classifier, Markdown, Browse, Form,
Tables). Landing itself is omitted from the strip — clicking landing
from landing is a no-op. Each card has the tool name in the display
serif and a short sans hint underneath. Wraps on narrow widths instead
of scrolling horizontally; sits inside pickerView so it auto-hides on
the per-project landing view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:17:24 -05:00
6d72f5c770 feat(responsive): shared narrow-viewport baseline for the header chrome
Only transmittal had any @media (max-width) rules; the other seven
tools silently break below ~900px. Adds a baseline shared rule that
every tool inherits — desktop-first stays the same, but a tablet in
landscape or a window split next to a document remains usable.

@media (max-width: 800px):
  - tighter header padding + gaps
  - .app-header__title drops 18px → 16px
  - .build-timestamp inside .header-title-group hidden (it's
    traceability info, not a primary affordance — still reachable
    via help panel)
  - header text buttons get a smaller padding so they fit

@media (max-width: 480px) phone-width:
  - .app-header switches to column layout
  - .header-left and .header-right each span full width with
    justify-content: space-between

prefers-reduced-motion was already covered for the page-load stagger.

Each tool can still override in its own css/layout.css; this is the
shared floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:12:17 -05:00
1f03631d2d feat(motion): staggered page-load reveal on header chrome
The header is the first thing a user sees. A short staggered fade-in
(logo → title → action button → right-side icons over ~360ms) turns the
instant-pop-in feel into a subtle "the tool is composing itself" beat.

Pure CSS @keyframes (no JS), cubic-bezier(0.2, 0.7, 0.2, 1) for the
"settle in" easing curve. Respects prefers-reduced-motion. Total budget
~260ms before everything is visible — well under the threshold where it
becomes a perceptible delay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:10:45 -05:00
6260aa4860 feat(typography): bake IBM Plex Sans + Source Serif 4 into every tool
System-default font stack ('-apple-system, BlinkMacSystemFont, Segoe UI,
…') is the textbook generic admin-tool look. The tools have a real point
of view (engineering documents, traceability, immutability); the
typography should reflect that.

Picks:
  --font          → IBM Plex Sans (400 + 600). UI body text. Distinctive
                    engineering sans with tabular nums and proper figures.
  --font-display  → Source Serif 4 (600). Headings, page titles,
                    .app-header__title. Reads as "document" not "UI label."
  --font-mono     → unchanged. Platform mono fonts are already excellent
                    and engineering tools rarely benefit from a custom mono.

Wiring:
  - Raw .woff2 files live in shared/fonts/ (~60 KB total, latin subset,
    SIL OFL 1.1 — both families)
  - shared/fonts.css is base64-inlined data URIs for those three fonts
    (~80 KB after b64 overhead). Generated once from the snippet in
    shared/fonts/README.md.
  - Every tool's build.sh prepends shared/fonts.css before shared/base.css
    so @font-face is parsed before any rule references the family names.
  - Headings (h1-h6) and .app-header__title now use var(--font-display);
    .app-header__title bumped 17→18px and letter-spacing reset since the
    serif doesn't need the original sans-text tightening.
  - table/code/.tabular-nums get font-variant-numeric: tabular-nums so
    tracking-number columns align vertically.

"Ship the record player with the record": zero CDN dependency at render
time. Tools render identically offline and online. Per-tool dist sizes
grew by ~80 KB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:59 -05:00
8be6c4d98b feat(shared): route window.alert() to non-blocking toast
There are 76 alert() call sites across the eight tools — three different
ad-hoc error-surfacing patterns (alert, console.error, classifier's own
showToast). Touching every site is a sweep with no judgment payoff:
every alert is "something went wrong, the user should know," which is
exactly what toast at level='error' is for.

Shim is one if-block at the bottom of shared/toast.js. It saves the
native window.alert as window.alertNative (so any truly modal-blocking
call site can opt back in by name), then replaces window.alert with a
function that forwards through window.zddc.toast(msg, 'error'). Effect
is global — every existing alert in every tool becomes a non-blocking,
ARIA-announced (aria-live=assertive) toast that the user can click to
dismiss.

handler/tables.html refreshed by ./build as a side effect (it bakes the
current tables/dist/ into the binary every build).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:30:36 -05:00
f2af379ff5 chore(embedded): cut v0.0.17-beta 2026-05-10 19:22:14 -05:00
0b69367901 feat(browse): sort dropdown in the tree toolbar
The tree's underlying setSort API was carried forward from the old
table-with-clickable-headers UI but had no widget driving it after
the layout reshape. Adds an explicit dropdown in the toolbar:

  Sort: [Name (A→Z)         ▾]
        [Name (Z→A)            ]
        [Modified (new→old)    ]
        [Modified (old→new)    ]
        [Size (large→small)    ]
        [Size (small→large)    ]
        [Type (A→Z)            ]

Implementation:
- new tree.setSortExplicit(key, dir) — sets both axes in one call
  (the existing tree.setSort toggles direction on repeat-clicks,
  which is the right semantics for column-header clicks but wrong
  for an explicit dropdown).
- events.js parses the dropdown value as "<key>:<asc|desc>" and
  calls setSortExplicit. The dropdown is initialised to reflect
  the current sort state on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:22:03 -05:00
89d96b784f chore(embedded): cut v0.0.17-beta 2026-05-10 19:02:43 -05:00
0b382716e3 feat(browse): TOC pane + FS-API saves in the markdown plugin
Completes the markdown plugin's deferred v2 items:

1. TOC pane

A third pane to the right of the Toast UI editor lists every heading
in the current document, hierarchically indented by level. Click an
item → editor scrolls to that heading (markdown-mode uses
setSelection + preview scroll; WYSIWYG mode uses DOM text matching;
the target heading flashes briefly via primary-light background).
The TOC re-renders on every editor change (debounced 250ms) so it
stays in sync with edits.

Heading parser supports ATX-style `^#{1,6}\s+` lines, strips inline
markdown emphasis/code/links/strike from the displayed label.
Empty file → "Empty file." Headingless file → "No headings."

2. FS-API writes

Saves now route to whichever source the file came from:

  - node.handle + createWritable available → FileSystemWritableFileStream
    (local folder picker). The user's chosen file gets overwritten
    via the browser's File System Access API.
  - node.url + server source → PUT to the server URL (as before).
  - zip-virtual file → save disabled (no writable stream from JSZip).
  - Anything else → save disabled with a tooltip.

Save status surfaces via the existing toolbar (`Saved 10:42:18`) AND
a shared toast notification ("Saved readme.md" / "Save failed: …")
so the success/failure is visible regardless of whether the user is
looking at the toolbar.

Source-hint chip on the toolbar shows "local" / "server" /
"read-only (inside zip)" so the user knows which write path is
active before they make changes.

CSS additions in browse/css/tree.css for .md-toolbar, .md-split,
.md-editor-host, .md-toc-pane, .toc-list, and the .toc-level-1..6
indentation rules.

A new Playwright test exercises the markdown plugin end-to-end:
mounts the editor on a .md click, asserts the three DOM regions are
visible, verifies the TOC contains the three expected headings from
the test fixture's markdown content, and confirms the source hint
reads "local" for FS-API mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:02:32 -05:00
d77981407c chore(embedded): cut v0.0.17-beta 2026-05-10 15:47:02 -05:00
7d4d2dc9a2 feat(browse): two-pane shell + markdown plugin + grid mode (Phases A/B/C/D)
Reshape browse from "tree-as-table with popup preview" into a unified
file-experience tool with three layered behaviors:

  Phase A — Two-pane shell
  Phase B — Markdown plugin (Toast UI inline)
  Phase C — Grid mode (classifier workflow)
  Phase D — Deprecation banners on standalone classifier + mdedit

= Phase A: two-pane shell + lightweight preview plugins =

Browse's table view becomes a tree-pane on the left + preview-pane on
the right with a draggable resizer. Click a folder → expand inline.
Click a file → render in the right pane. The previous popup window
becomes an explicit "⤴ Pop out" button in the right-pane header for
users with a second monitor.

Preview rendering reuses shared/preview-lib.js (PDF iframe, image
<img>, TIFF, ZIP listing, text <pre>). Unknown types show a download
link. browse/js/preview.js refactored into renderInline (default) +
renderInPopup (Pop out button); both share the same plugin
dispatch logic.

Filter rows were already removed earlier this session. Sort columns
likewise — the tree is alphabetical by default; the underlying
setSort API still exists for future re-introduction.

= Phase B: markdown plugin =

New browse/js/preview-markdown.js: when a .md or .markdown file is
clicked, the right pane mounts a Toast UI editor (initial-value =
file contents) with a small toolbar containing Save + dirty indicator
+ status text. Save sends PUT through the file API for server-mode
files; non-server sources are read-only for now (deferred to a
follow-up that wires zddc-source.js writes too). Ctrl+S / Cmd+S
inside the editor saves.

Toast UI Editor (~700 KB JS + ~160 KB CSS) was previously bundled
only in mdedit/vendor/. Moved to shared/vendor/ so browse and mdedit
both pull from one location.

= Phase C: grid mode =

View-mode toggle [Browse | Grid] in the toolbar. Grid mode loads the
classifier tool as an iframe scoped to the current directory (server
mode at working/staging/incoming locations) — classifier's full
bulk-rename workflow without leaving browse. v1 implementation; a
future iteration could bundle classifier's modules directly into
browse for tighter integration. Hostile cases (file:// origin, paths
outside working/staging/incoming) show a friendly explanation
instead of a blank iframe.

new browse/js/grid.js handles the activation logic.

= Phase D: deprecation banners =

mdedit and classifier standalones gain a "this tool is being absorbed
into Browse" advisory banner. Both standalones remain fully
functional and continue to ship — they're useful for offline single-
file editing and air-gapped environments. The banner just points
users toward the unified browse experience.

= Files =

  + browse/js/preview-markdown.js   (markdown plugin)
  + browse/js/grid.js               (grid-mode plugin)
  M browse/template.html            (two-pane layout, view toggle, banners)
  M browse/css/tree.css             (two-pane CSS, replaces table styles)
  M browse/js/init.js               (state additions: selectedId, viewMode)
  M browse/js/tree.js               (rowHtml: <tr>+<td> → <div>)
  M browse/js/preview.js            (renderInline / renderInPopup split)
  M browse/js/events.js             (toggle wiring, resizer, click handlers
                                     adapted from <table> to <div>)
  M browse/build.sh                 (Toast UI vendor + new modules)
  R mdedit/vendor/toastui-*         → shared/vendor/  (one bundle, two tools)
  M mdedit/build.sh                 (paths)
  M mdedit/template.html            (deprecation banner)
  M classifier/template.html        (deprecation banner)
  M tests/browse.spec.js            (selectors updated for new layout +
                                     new "click file → preview" test)

Bundle sizes after this commit:
  browse:     ~1020 KB  (was ~290 KB; added Toast UI ~700 KB)
  classifier: ~1470 KB  (unchanged from prior baseline)
  mdedit:     ~2140 KB  (unchanged; vendor location moved but not added)

What's deferred:
  - TOC + front-matter pane in browse's markdown plugin (mdedit has
    these; browse v1 uses just the editor).
  - FS-API writes from browse's markdown plugin (server PUT works).
  - Classifier modules bundled directly into browse (v1 uses iframe).
  - Sort UI in the new tree (model still supports it; no widget yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:46:51 -05:00
875870501e chore(embedded): cut v0.0.17-beta 2026-05-10 15:09:49 -05:00
7ac2e1cc73 chore(embedded): cut v0.0.17-beta 2026-05-10 14:37:13 -05:00
e2c4700d32 refactor(zddc-server): demote routing-shape redirects from 301 to 302
301 Moved Permanently is cached by browsers effectively forever — when
we changed /<project> no-slash from "redirect to slash form" to
"serve project landing" earlier today, anyone who had visited the URL
under the prior behavior got stuck on the cached 301 indefinitely. No
server-side fix is possible after the fact; only a manual cache clear
in each user's browser releases the binding.

Demote every routing-shape redirect to 302 Found, which browsers do
not cache by default. Five sites:

  - handler/directory.go: no-trailing-slash → slash on directory URLs
  - main.go (4 sites):
      .archive/ canonicalization (deep /<project>/<sub>/.../.archive/
        path collapses to /<project>/.archive/)
      reviewing/<tracking> no-slash → slash
      reviewing/ default-app fallback to slash form
      generic IsDir + no-slash + no-default-tool fallback

301 → 302 trades "permanent semantics in the protocol" for "we can
change our mind later without trapping users on old behavior." For
these routes — all of which are convention-driven shapes the server
owns — the latter is what we want.

Test updates: five httptest assertions switch from
http.StatusMovedPermanently → http.StatusFound, plus five comment
strings ("301" → "302").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:37:02 -05:00
9a98901683 chore(embedded): cut v0.0.17-beta 2026-05-10 14:23:00 -05:00
4acf348b21 chore(embedded): cut v0.0.17-beta 2026-05-10 14:21:18 -05:00
baf5958174 chore(embedded): cut v0.0.17-beta 2026-05-10 14:16:34 -05:00
b3e8c4127f chore(embedded): cut v0.0.17-beta 2026-05-10 14:09:33 -05:00
4cd39998aa chore(embedded): cut v0.0.17-beta 2026-05-10 07:57:40 -05:00
315d039880 refactor(landing): project landing is now a single-file SPA, not server-rendered
The /<project> landing page was server-rendered via
internal/handler/projecthandler.go's html/template — an inconsistency
against the project's "every tool is a single-file HTML" convention.
Convert it to a mode of the existing landing/ tool: same bundle now
serves both / (project picker) and /<project> (project workspace).

Mechanics:

  - landing/template.html: pickerView (existing markup) + projectView
    (new: stage cards, browse-all, MDL section, party-list slot).
    Mode toggles by adding/removing .hidden on the two containers.
  - landing/js/landing.js: detectMode() reads location.pathname;
    renderProjectMode() populates stage hrefs from the project segment
    and fetches /<project>/archive/?json=1 for the party list. init()
    forks based on mode; picker init was extracted to initPicker().
    Existing public API + behaviour unchanged for picker mode.
  - landing/css/landing.css: appended ~115 lines for the project view
    (.stages grid, .stage-card hover, .party-list, MDL formatting).
  - cmd/zddc-server/main.go: dispatcher's IsProjectRootURL fork now
    calls appsSrv.Serve(w, r, "landing", chain, absPath) rather than
    the deleted ServeProjectLanding handler.
  - internal/handler/projecthandler.go: trimmed to just the
    IsProjectRootURL predicate (the dispatcher still needs it for
    routing). Template + render code (~220 lines) deleted.

Net effect: same UI as before — same logo wrapping (now via
shared/logo.js, no longer a hand-rolled inline anchor), same stage
cards, same MDL instructions with party links — but the page is now a
single-file SPA that themes like the rest, follows the same logo and
stage-strip conventions, and could in principle be downloaded and
served standalone.

Tests:
  - 3 new tests/landing.spec.js cases: detectMode exposure, project
    workspace renders at /<project> with correct stage hrefs + title,
    party listing populates from JSON fetch and filters dot-prefixed
    entries.
  - The dispatcher test for /Project no-slash still asserts 200 +
    no-redirect; the served body is now landing.html instead of the
    server-rendered template, but both pass the assertion.

LOC: roughly net-neutral. -220 in projecthandler.go, +115 in
landing.css, +130 in landing.js, +60 in template.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:57:30 -05:00
bc5fcf6c73 chore(embedded): cut v0.0.17-beta 2026-05-10 07:47:10 -05:00
3fa2762c28 fix(zddc-server): project landing logo links to deployment root
The project landing page at /<project> had its own hand-rolled
header with <svg class="logo"> — not the canonical app-header__logo
class, and not loading shared/logo.js. So the logo on that page was
purely decorative while every other tool's logo (in the same beta
build) was wrapped by shared/logo.js into a clickable link to
/<project>. Inconsistent and surprising — clicking the logo from
mdedit/archive/etc. takes you to project landing, but clicking the
logo on project landing did nothing.

Inline the wrap directly in the template (the page is server-
rendered, so it can't lean on shared/logo.js the way bundled tools
do):

  <a class="app-header__logo-link" href="/" title="ZDDC home">
    <svg class="app-header__logo" ...>...</svg>
  </a>

href="/" because "next up" from the project landing is the
deployment root (the project picker / landing tool).

Also rename .logo → .app-header__logo for visual consistency, and
add the matching hover/focus styles inline. The test asserts both
the wrapping anchor and the canonical class name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:46:59 -05:00
cf5d7c2ea6 chore(embedded): cut v0.0.17-beta 2026-05-10 07:34:38 -05:00
7fd96c7c78 feat(shared): clickable logo links every tool's header to project home
The .app-header__logo SVG was decorative on every tool. Web's
strongest convention is "click logo → go home" — so users tapping
it expecting that fallback got nothing. Now the logo is wrapped in
an anchor whose href reflects the URL the page was loaded from:

  file://                    → no wrap (no server home to point at)
  /                          → wrap, href=/         (deployment root)
  /index.html / /<tool>.html → wrap, href=/         (root, no project)
  /<project>/...             → wrap, href=/<project> (project landing)

The wrap happens client-side at DOMContentLoaded via shared/logo.js,
loaded by every tool's build.sh after toast/nav. Idempotent — a
template-supplied anchor or a second mount call is a no-op.

The companion shared/logo.css adds a subtle hover/focus affordance
(opacity 0.82, focus ring) so the logo reads as clickable without
otherwise altering its visual weight. Tools opt out by setting
window.zddc.logo.disabled = true before DOMContentLoaded (e.g. for
deployments that pin the logo to an external destination).

Five Playwright tests (tests/logo.spec.js) lock the contract:
no-wrap on file://, href=/ at root, href=/<project> in project
subtree, aria-label matches target, idempotent re-mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:34:28 -05:00
837cf47924 chore(embedded): cut v0.0.17-beta 2026-05-10 07:26:31 -05:00
6145bb0c87 feat(zddc-server): synthetic project landing page at /<project>
GET /<project> (no trailing slash) used to 301 to /<project>/ which
served the browse listing. Now it serves a small server-rendered
landing page with:

  - Four lifecycle-stage cards (archive/working/staging/reviewing)
    linking to the no-slash form of each canonical folder, so each
    card opens its default tool (archive view, mdedit sandboxed to
    working/, transmittal at staging/, mdedit at reviewing/).
  - A "Browse all files" link to the slash form for the generic
    file tree.
  - A "Master Deliverables List" section with step-by-step
    instructions for editing any party's MDL plus direct links to
    the MDL of each party already present under archive/ (sorted,
    case-preserved). Falls back to a friendly "no parties yet"
    message when the archive is fresh.

Trailing-slash form (/<project>/) is unchanged — still 200 +
embedded browse.html. The slash-vs-no-slash convention now extends
all the way up the URL tree:

  /                       → landing tool (project picker)
  /<project>              → project landing (this commit)
  /<project>/             → browse
  /<project>/working      → mdedit
  /<project>/working/     → browse
  ... etc.

Implementation:
  - new internal/handler/projecthandler.go — IsProjectRootURL
    predicate + ServeProjectLanding rendering an inlined html/template.
    Page styles are inline; tokens mirror shared/base.css and
    auto-flip on prefers-color-scheme: dark.
  - dispatcher in cmd/zddc-server/main.go: at the IsDir branch's
    no-slash fork, intercept depth-1 single-segment URLs before
    the historical 301. Other depths still 301 unchanged.

Tests:
  - internal/handler/projecthandler_test.go (4 cases): predicate
    coverage; landing page renders project name + four stage cards;
    on-disk parties surface as MDL links with case preserved; fresh
    project falls back to the no-parties-yet copy.
  - cmd/zddc-server/main_test.go TestDispatchSlashRouting: the
    "project root no-slash → 301" case becomes "→ landing (200)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:26:21 -05:00
e51d9fe908 chore(embedded): cut v0.0.17-beta 2026-05-10 06:44:45 -05:00
94323ea356 chore(embedded): cut v0.0.17-beta 2026-05-09 22:34:24 -05:00
464c80c1e1 chore(embedded): cut v0.0.17-beta 2026-05-09 21:51:08 -05:00
13e929b029 chore(embedded): cut v0.0.17-beta 2026-05-09 21:39:04 -05:00
d1a5a14132 fix(reviewing): case-insensitive on-disk lookup for archive/+staging/+
party/{received,issued}

The synthetic test fixture and many real deployments use PascalCase
folder names (Archive/, PartyB/, Received/, Issued/, Staging/). The
aggregator was hard-coding lowercase joins, which on case-sensitive
filesystems (Linux ext4) meant os.ReadDir returned NotExist and the
listing was empty even when the data was present.

Use zddc.ResolveCanonical to find the on-disk casing for each
canonical segment (archive/, staging/, then per-party received/ and
issued/), and emit URLs with the resolved casing so the dispatcher's
URL canonicalisation is a no-op pass-through.

The case-insensitive lookup was already used elsewhere (file API's
mkdir, tree.go's virtualUserHomeEntry); reviewing/ now matches that
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:38:50 -05:00
002e034119 chore(embedded): cut v0.0.17-beta 2026-05-09 21:37:22 -05:00
45005d164e feat(zddc-server): reviewing/ virtual aggregator + mdedit at the URL
Implements the reviewing/ aggregator described in the saved
project memory (~/.claude/projects/-home-user-src-zddc/memory/
project_reviewing_folder_design.md). reviewing/ stays in
VirtualOnlyCanonicalNames — never materialised on disk — and is
served as a join over archive/<party>/received/, archive/<party>/
issued/, and staging/, recomputed on every read.

Two depths, both trailing-slash:

  GET <project>/reviewing/?json=1
    → array of virtual <tracking>/ entries, one per submittal in
      archive/<party>/received/ that doesn't yet have a matching
      archive/<party>/issued/ entry. Sorted by tracking. URLs stay
      under reviewing/ so the user can drill into the per-submittal
      view. ACL: per-party, filtered like fs.ListDirectory.

  GET <project>/reviewing/<tracking>/?json=1
    → array of two virtual entries, received/ + staged/, with
      canonical URLs pointing back to archive/<party>/received/...
      and staging/... respectively. staged/ is omitted when no
      response draft exists yet.

When the response moves staging/ → archive/<party>/issued/, the
entry vanishes from depth-0 on the next listing. No mutation of
the reviewing/ subtree itself; pure join, recomputed on read.

Front-end at <project>/reviewing[/<tracking>/] is mdedit (per
user request). DefaultAppAt + AppAvailableAt extended to recognise
"reviewing" as a canonical mdedit-bearing folder. The polyfill in
shared/zddc-source.js is updated to follow listing entries' explicit
url field when present (absolute or root-relative) — that's how
mdedit's tree follows the depth-1 received/ + staged/ links into
the canonical archive/staging subtrees.

Dispatcher routing in zddc-server/main.go:
  - GET <project>/reviewing/[<tracking>/] with Accept: json
    → ServeReviewing
  - GET <project>/reviewing/[<tracking>/] with Accept: html
    → mdedit (rooted at the virtual path; polyfill fetches the
      JSON listing on its own)
  - GET <project>/reviewing (no slash) → mdedit (via DefaultAppAt)
  - GET <project>/reviewing/<tracking> (no slash) → 301 to slash form

Tests:
  - handler/reviewinghandler_test.go (6 cases): IsReviewingPath
    classification + ServeReviewing depth-0/depth-1 with and without
    staged drafts + 404 on unknown tracking + empty when archive/ is
    absent.
  - apps/availability_test.go updated: reviewing/ now expects mdedit
    rather than "" (no default).
  - cmd/zddc-server/main_test.go: TestDispatchEmptyCanonicalProjectFolders
    extended to assert reviewing → mdedit at the no-slash form;
    older "no-slash/reviewing → 301" test removed.

Future work (not in this commit): write translation. Editing a file
under reviewing/<tracking>/staged/<f>.md works today because the
polyfill rewrites to /<project>/staging/<response>/<f>.md before
fetching — the user's URL bar moves to the canonical path on click.
A virtual-filesystem mode where the URL bar stays under reviewing/
throughout would require server-side write rewriting (translate
PUT/DELETE on reviewing/.../staged/... into the canonical staging/
path). Not needed for the MVP — links in mdedit's tree work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:37:08 -05:00
d08dcce211 chore(embedded): cut v0.0.17-beta 2026-05-09 21:13:24 -05:00
03babd34d2 chore(embedded): cut v0.0.17-beta 2026-05-09 20:59:43 -05:00
1d12cfe804 chore(embedded): cut v0.0.17-beta 2026-05-09 20:35:10 -05:00
702ccf3be0 chore(embedded): cut v0.0.17-beta 2026-05-09 20:27:35 -05:00
585e84f2f4 chore(embedded): cut v0.0.17-beta
Beta cut of the eight HTML tools into zddc/internal/apps/embedded/*
and the unified form/tables bundle into zddc/internal/handler/tables.html.
Each tool's on-page label changes from alpha → beta-stamped bytes;
no source changes beyond the build label itself.

The dev image (Dockerfile, devshell, ZDDC_REF=main) and the bitnest
test container both pick this up automatically — bitnest's path-unit
fired on the rebuild of zddc/dist/zddc-server-linux-amd64 and
restarted the container with the new embedded apps:

  embedded_apps=archive=v0.0.17-beta browse=v0.0.17-beta
                classifier=v0.0.17-beta form=v0.0.17-beta
                landing=v0.0.17-beta mdedit=v0.0.17-beta
                tables=v0.0.17-beta transmittal=v0.0.17-beta

Source-side commits since the previous beta:
  feat(landing): single-project click → <project>/archive.html
  feat(shared): non-blocking toast helper
  feat(shared): lateral project-stage strip
  feat(form): standalone empty-state welcome
  fix(tables): keepalive on beforeunload save path
  refactor(mdedit): drop window.* TOC globals
  refactor(archive): remove dead debounce
  style(transmittal): tokenize utility classes, drop !important block
  style: replace inline styles with CSS
  test(shared): zddc-source.js + toast + nav specs
  test(browse): smoke spec
  docs: tool counts + state pattern + polyfill gaps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:13:21 -05:00
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
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
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
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
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
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
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
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
ecd48e5f74 chore(embedded): cut v0.0.17-beta 2026-05-07 12:11:53 -05:00
7c72ca3b1d chore(embedded): cut v0.0.17-beta 2026-05-07 12:08:23 -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
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
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
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
9f97bfab3e feat(zddc)!: per-party WORM + auto-own; case-fold tool availability
BREAKING CHANGE. Project-level Issued/Received/Incoming folders no
longer carry special semantics. WORM enforcement and auto-ownership
move to the per-party canonical layout:

  - WORM mask now triggers on archive/<party>/received/ and
    archive/<party>/issued/ (any case, any party)
  - Auto-own .zddc writes on first mkdir under working/, staging/,
    or archive/<party>/incoming/ (any case)

Predicate API:
  - IsAutoOwnPath(parentDir, fsRoot)  — replaces IsAutoOwnParent(name)
  - IsWormPath(requestPath)           — same name, new pattern
  - WormFolderLevelIndex unchanged signature, new pattern

Legacy SpecialFolderNames / AutoOwnFolderNames / WormFolderNames /
IsAutoOwnParent are deleted (no Deprecated: stubs — early-development
project, no back-compat to preserve).

Tool availability (apps/availability.go) is case-fold throughout:
  - mdedit:     descendants of working/
  - transmittal: descendants of staging/
  - classifier: descendants of working/, staging/, or
                archive/<party>/incoming/
Working/, WORKING/, working/ all match identically.

Test fixtures rewritten:
  - special_test.go: covers IsAutoOwnPath / IsWormPath /
    WormFolderLevelIndex / ResolveCanonical / canonical lists
  - availability_test.go: per-party rules, case-fold scenarios
  - fileapi_test.go: rolePermissionsTestSetup now seeds
    Project-X/archive/Acme/{incoming,issued,received}/ rather than
    Vendor/{Incoming,Issued,Received}/ at the project root

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
a471de8788 refactor(zddc): extract writeAutoOwnZddc into zddc.WriteAutoOwnZddc
Pure refactor. The mkdir post-hook in handler/fileapi.go duplicated
zddc-package types; lifting the body into the package itself lets the
upcoming EnsureCanonicalAncestors helper share it without re-exposing
the file API's internals.

No behaviour change. The grant shape (creator email → rwcda + CreatedBy
audit field) and the atomic-write path through zddc.WriteFile are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
f6dc9d557a chore(embedded): cut v0.0.17-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
2026-05-07 06:28:22 -05:00
a0f9fca95d feat(archive): canonicalize deep .archive URLs + permissions follow the file
The .archive virtual prefix is now project-scoped at exactly one URL
depth: any /<project>/<sub>/.../.archive/... gets a 301 to the
canonical /<project>/.archive/.... The dispatcher does this before
calling the handler; query strings are preserved (the browser handles
the fragment automatically). .archive is also GET/HEAD-only — anything
else returns 405 with Allow: GET, HEAD, ahead of the file API.

Why: offline-built HTML files reference siblings as
"../.archive/<tracking>.html" from arbitrary depths. All of those refs
should converge on a single stable URL per (project, tracking) so
external links and bookmarks don't fork by entry point.

Permissions now follow the resolved file, not .archive itself.
.archive is a virtual surface — it has no on-disk directory and no
.zddc of its own, so gating it as if it did is wrong. Two gates only:

  - Resolve: only the per-target file's ACL chain decides. A user
    explicitly allowed at one transmittal folder but denied at the
    project root can still fetch tracking numbers that resolve there.
    Per-target denial returns 404 (not 403) so existence doesn't leak.

  - Listing: filter entries by per-target ACL. If the project bucket
    has zero indexed entries → 404 (unknown / empty project, indistinguishable
    from a probe). If the bucket is non-empty but the caller can read
    no entries → 403 (existence-leak guard: don't confirm an inaccessible
    project's archive exists). Otherwise → 200 with the filtered subset.

The listing endpoint is now content-negotiated like ServeDirectory:
Accept: text/html serves the embedded `browse` SPA bytes (with the
embedded ETag and X-ZDDC-Source: embedded:browse); Accept:
application/json returns the JSON entry array (with content-hash ETag
and 304 short-circuit). Vary: Accept set on both. The browse SPA's
auto-detect path-fetch then renders the archive entries as a sortable,
filterable flat list at /<project>/.archive/.

ServeArchive's signature is now (cfg, idx, w, r, project, filename) —
the dispatcher hands the normalized project string in directly, so
projectFromContextPath is gone. Old behavior was to derive project
from contextPath inside the handler; with the upstream redirect that's
redundant and the handler's preconditions are simpler.

Tests: archivehandler_test.go rewritten around the new semantics;
added per-target-only resolve, project-root-deny + per-target-allow
rescue, listing 403/404 distinction, JSON/HTML content-negotiation,
and conditional GET. main_test.go gains TestDispatchArchiveRedirect
(deep paths, query preservation, already-canonical no-op) and
TestDispatchArchiveMethodGate (PUT/POST/DELETE → 405).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 06:28:07 -05:00
8cf1aa1002 release: v0.0.16 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
Build + deploy releases / build-and-deploy (push) Failing after 9s
Build + deploy releases / notify-chart-prod (push) Has been skipped
2026-05-06 09:41:01 -05:00
610b7ef65a feat(archive): periodic rescan + admin reindex endpoint
The fsnotify watcher only sees events the local kernel generates, so on
SMB/CIFS-backed roots (Azure Files) writes from any other client are
invisible — the archive index would silently miss them until pod
restart. Add two backstops:

1. Periodic full re-walk via Index.Rebuild on a configurable interval
   (--archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL, default
   60s, 0 to disable). Atomically swaps ByProject under the existing
   RWMutex; concurrent reads stay safe.
2. Admin-only POST /.profile/reindex that triggers an immediate rebuild
   and returns {duration_ms, project_count, tracking_count}, for the
   "I just dropped 50 files and don't want to wait" case. Gated by
   IsAdmin with the same 404-on-non-admin pattern as the other admin
   sub-resources.

Tests: TestRebuild_PicksUpAddsAndDrops covers add+drop semantics and
returned counts; TestServeProfileReindexPOST covers the happy admin
path; matrix entries cover the gate (anonymous/non-admin → 404, admin
GET → 405 method-not-allowed since the route is POST-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:50:51 -05:00
748cc59ce3 chore(embedded): cut v0.0.16-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
2026-05-05 20:48:22 -05:00
4e6473a5cd chore(embedded): cut v0.0.16-beta with tables tool
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
Refreshes //go:embed bytes off fe28a73. Dev image now ships the new
tables renderer (handler-local embed at handler/tables.html) plus
build-label refresh on the six cascade-served tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:35:38 -05:00
fe28a73f59 feat(archive): serve in-place instead of redirecting (preserves #anchor links)
Resolved `.archive/<tracking>.html` URLs now serve the target file's
bytes inline via http.ServeFile with Cache-Control: no-cache, replacing
the previous 302 redirect to the per-transmittal URL.

Why: external links like `.archive/<tracking>.html#section` are meant
to track the latest revision. A redirect exposes the snapshot URL — any
forwarded link then pins to that snapshot instead of "latest." Serving
in-place keeps the `.archive/` URL stable as the resolver's "current"
target moves over time.

Cache-Control: no-cache is intentional. Each load revalidates against
the on-disk file's Last-Modified/ETag, so when a new revision lands the
resolver picks it and the browser refetches transparently.

ACL is unchanged: enforced on both the `.archive` context directory and
the resolved target file (per-target denial returns 404, not 403, to
avoid disclosing that a tracking number exists in a hidden subtree).

archivehandler_test.go status expectations updated 302 → 200; fixture
bodies adjusted for body-content verification of the in-place serve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:32:29 -05:00
9ca36f25d8 feat(tables): new sortable/filterable grid tool for directories of YAML files
Tables is the eighth HTML tool: a read-only tabular view over a
directory of YAML files declared via `tables:` in `.zddc`. Anchor use
case is the Master Deliverables List, where each row is one
`<tracking>.yaml` under `Archive/<Party>/MDL/`. Rows click through to
the existing form renderer for editing.

Schema (zddc/internal/zddc/file.go)
  - New `Tables map[string]string` on ZddcFile. Map key becomes the URL
    stem (`tables[MDL]` → `<dir>/MDL.table.html`); the value is a path
    relative to the .zddc pointing at a `*.table.yaml` spec describing
    columns + the rows directory. No upward cascade in v1 — each
    directory hosting a table declares it directly.

Server handler (zddc/internal/handler/tablehandler.go)
  - `RecognizeTableRequest` matches GET `/<dir>/<name>.table.html`
    against the cascade's `tables:` declarations. Dispatch routes
    table requests before the form-system intercept.
  - `ServeTable` ACL-gates with `policy.ActionRead` and serves the
    embedded `tables.html` template; client walks the directory itself
    via the listing JSON or FS Access API.
  - tables.html embedded via //go:embed — same pattern as form.html.

Frontend (tables/)
  - Vanilla JS: app/context/util/filters/sort/render/main modules.
  - Reads spec + row YAML files via window.zddc.source (HTTP polyfill
    or local FS handle); js-yaml 4.1.0 vendored in shared/vendor for
    client-side parsing.
  - Sample fixtures under tables/sample/ for local testing.

Build + CI
  - Lockstep build registers tables alongside the other 7 tools (HTML
    output, embed mirror, versions.txt, release-output, tags).
  - Playwright project added; `npx playwright test --project=tables`
    is part of `npm test`.

Drive-by: rename mdedit Playwright selectors `#select-directory` →
`#addDirectoryBtn` to fix three pre-existing failing tests.

Drive-by: ignore locally-built `zddc/zddc-server` binary so it doesn't
get accidentally staged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:32:01 -05:00
2b17c9f030 chore(embedded): cut v0.0.16-beta with file API + RBAC
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
Refreshes the //go:embed bytes off 3115e38. Dev image (ZDDC_REF=main)
now ships the file API and verb-based RBAC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:59:51 -05:00
3115e388fc feat(server): authenticated CRUD + verb-based RBAC with WORM archive folders
Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.

File API (zddc/internal/handler/fileapi.go)
  - PUT <new>      → action c
  - PUT <existing> → action w
  - PUT <.zddc>    → action a (CanEditZddc strict-ancestor rule)
  - DELETE         → action d
  - POST mkdir     → action c (auto-writes creator-owned .zddc when the
                     parent is Incoming/Working/Staging)
  - POST move      → action w on src + c on dst, atomic via os.Rename
  - Optional If-Match for optimistic concurrency, --max-write-bytes cap,
    audit log emits a structured file_write event per operation.

Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
  - acl.permissions: { principal → verb-set } map; principals are email
    patterns or role names. Empty verb set is an explicit deny.
  - roles: { name → members } definitions, available at the level they
    declare and all descendants. Closer-to-leaf shadows ancestor.
  - Legacy acl.allow/deny still work; they fold into permissions at
    parse time (allow → "rwcd", deny → "").
  - Cascade walks leaf→root; first level with any matching entry wins;
    the union of matching verb sets at that level decides.
  - --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
    ancestor explicit-deny is absolute (NIST AC-6). Default delegated
    preserves the existing commercial behavior.

Special folders (zddc/internal/zddc/special.go)
  - Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
    subdir granting created_by + that email rwcda directly. Same form
    operators write by hand; creator can edit it later to add others.
  - Issued / Received: server-enforced WORM split. Cascade grants
    inherited from above the WORM folder are masked to r only; grants
    placed at-or-below the WORM folder retain r,c. Operators grant
    write-once (cr) to the doc controller via an explicit .zddc at the
    Issued/Received folder. Admins exempt — only escape hatch.

Browser polyfill (shared/zddc-source.js)
  - HttpDirectoryHandle + HttpFileHandle implement the FS Access API
    surface (values, getFileHandle, createWritable, removeEntry,
    queryPermission/requestPermission) over zddc-server's listing JSON
    and file API. Existing tools written against showDirectoryPicker
    work unchanged.
  - detectServerRoot() returns { handle, status }: tools auto-load on
    HTTP, surface a clear "no permission to list" message on 403, and
    fall back to the welcome screen on 0.
  - classifier renames take the atomic POST move path on HTTP-backed
    handles; mdedit and transmittal route reads/writes through the
    polyfill so prior FS-API code paths cover both modes.

Tests
  - zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
    delegated vs strict, role membership / shadowing / legacy fallback,
    WORM split semantics, verb-set parser round-trip.
  - zddc/internal/handler/fileapi_test.go now also covers role-based
    vendor scenarios, WORM blocking vendor & doc controller writes,
    explicit Issued .zddc unlocking the cr drop-box, admin bypass,
    auto-ownership on mkdir, and strict-mode lockouts.

Docs
  - ARCHITECTURE.md + zddc/README.md document the verb model, role
    syntax, special-folder behaviors, cascade-mode flag, and full file
    API surface. Federal-readiness gap analysis strikes AC-3(7) and
    AC-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:58:04 -05:00
2607ca9b8a feat(server): /.profile/effective-policy cascade tracer (admin-only)
Eliminates the manual cascade-trace ritual when debugging "why can't
alice see /Project-X" reports. New endpoint returns the resolved
policy chain plus the active decider's verdict in JSON:

  GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@…

Response shape:

  {
    "path": "/Project-X/sub/",
    "email": "alice@…",
    "decision": true,
    "decider_kind": "*policy.InternalDecider",
    "chain": {
      "has_any_file": true,
      "levels": [
        {"index": 0, "zddc_path": "/.zddc", "exists": true,
         "acl": {...}, "admins": [...],
         "matches_email": false, "decision_at_level": "no_match"},
        {"index": 1, "zddc_path": "/Project-X/.zddc", "exists": true,
         "acl": {...}, "matches_email": true, "decision_at_level": "allow"}
      ]
    }
  }

Per-level email matching reuses the same MatchesPattern code the live
evaluator uses, so the trace can never disagree with the actual
verdict — and when ZDDC_OPA_URL points at an external OPA, the
decision goes through that OPA, making the endpoint a useful smoke
test for OPA wiring too.

Admin-only via the existing /.profile gate (404 to non-admins).
Required params; 400 if either is missing or path doesn't escape ROOT.

Test coverage:
  * TestServeProfileGateMatrix: anonymous → 404, non-admin → 404,
    admin without params → 400 (gate cleared, validator rejected)
  * TestServeProfileEffectivePolicy: full payload-shape assertion
    against a worked-example fixture (closed project where alice is
    allow-listed but bob is not)

Also fixes pre-existing doc drift: README's "Admin Debug Page"
section referenced /.admin/whoami|config|logs but the actual code
mounts /.profile/* (the rename predates this PR; the doc was stale).

Closes the "/.admin/effective-policy debug endpoint" item from the
federal-readiness future-work list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:01:24 -05:00
460d5fdada feat(server): TLS hardening per NIST SP 800-52 Rev. 2 + HSTS
The TLS configuration was using Go stdlib defaults — secure for typical
commercial use, but federal evaluators need an explicit cipher
allowlist they can map to a FIPS-validated implementation. Pin the
cipher and curve lists to NIST SP 800-52 Rev. 2 § 3.3 conformant
values:

  Ciphers (TLS 1.2):
    TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
    TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
    TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
    TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
    TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
    TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305

  Curves: X25519, P-256, P-384

  MinVersion: TLS 1.2 (already set; 1.3 used when negotiated)

TLS 1.3 cipher selection is not operator-controllable in Go stdlib
(the runtime picks from a fixed AEAD-only set); all of those
already meet the federal bar so no change needed there.

Also adds HSTSMiddleware emitting `Strict-Transport-Security:
max-age=31536000; includeSubDomains` when zddc-server is itself
terminating TLS (ZDDC_TLS_CERT != none). Behind an upstream proxy
terminating TLS the proxy is responsible for HSTS, so the middleware
only wraps the chain when useTLS=true.

Test coverage:
  * TLSConfig(none) returns nil + useTLS=false
  * TLSConfig(selfsigned) sets the exact NIST allowlist
  * Negative test asserting weak ciphers (CBC, RC4, 3DES, RSA-key-
    exchange) are NOT in the list — guardrail against regressions

Federal-readiness gap analysis updated: this control is now partially
complete. OCSP stapling and CT-log inclusion remain on the list for
full DoD STIG conformance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:55:52 -05:00
a01315fd00 feat(server): reference Rego, parity test, decision cache, listing ETags
Phase 2 enhancements to the policy decider, plus listing-level ETags
that benefit every deployment regardless of decider mode.

Reference Rego policy
---------------------
internal/policy/rego/access.rego mirrors InternalDecider's semantics
exactly — bottom-up walk, deny-first within a level, default-deny when
HasAnyFile=true, glob matching with @-boundary semantics (special-cased
bare "*" because OPA's glob.match treats empty delimiters
inconsistently for that pattern).

Embedded into the binary via go:embed; --print-rego dumps it to stdout
so federal customers standing up an external OPA can use it as a
parity-tested baseline:

    zddc-server --print-rego > /etc/opa/policies/zddc-access.rego

Parity test runner
------------------
parity_test.go imports the OPA Go module as a TEST-ONLY dependency
(github.com/open-policy-agent/opa@v0.70.0). Every fixture from the
internal Go evaluator's test set runs through both implementations;
any divergence fails CI. The test-only import means production
binaries (built by `go build ./cmd/zddc-server`) stay OPA-free —
release-flag binary size unchanged at ~13 MB.

The parity test caught a real bug on first run: bare "*" patterns
didn't match through OPA's glob.match with empty delimiters. Fixed
in access.rego with a special-case rule. This is exactly the kind of
subtle drift the parity guard exists to catch.

External-mode decision cache
----------------------------
HTTPDecider is now wrapped in a cachingDecider with a default 1s TTL.
Bursty patterns like .archive listings (one OPA round-trip per entry
before, one per (email, decision-input) tuple per TTL window after)
amortize cleanly. Verified: 20 identical /D/ requests produce 1 OPA
hit with cache, 40 hits without (each listing makes 2 ACL queries).

ZDDC_OPA_CACHE_TTL knob (default 1s) lets operators tune. 0 disables.
1s matches the fsnotify watcher debounce window — staleness is
bounded the same way other policy-edit propagation already is.
Internal mode unchanged; the in-process Go evaluator is already
cheaper than a cache lookup would be.

Listing ETags
-------------
GET / (project list) and GET /<dir>/ (directory listing JSON) now
carry content-hash ETag + Cache-Control: private, max-age=0,
must-revalidate. SHA-256 of the rendered JSON, truncated to 16 hex
chars (64 bits — collision risk on a listing of any realistic size
is vanishingly small).

Server-side caching deliberately not added: it would require
mtime-based invalidation, and Azure Files SMB mounts (a common
deployment substrate) don't support fsnotify reliably. The
content-hash ETag delivers the bandwidth savings (304 on identical
fetches) without depending on watcher correctness — the hash is the
actual response, so it can't lie about staleness regardless of
underlying watcher behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:46:24 -05:00
e911806eda feat(server): pluggable OPA-compatible policy decider
Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:

  * InternalDecider — wraps the existing zddc.AllowedWithChain. The
    default. No new dependencies, identical semantics to the legacy
    code path. ZDDC_OPA_URL=internal (or unset).

  * HTTPDecider — POSTs the canonical OPA wire format
    (POST /v1/data/zddc/access/allow with {"input": {...}}, response
    {"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
    For federal customers running their own audited Rego policies
    alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….

External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.

The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.

zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).

Test coverage:
  * InternalDecider parity tests against zddc.AllowedWithChain (every
    documented cascade scenario: empty chain, leaf-allow-wins, leaf-
    deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
    wins, etc.)
  * HTTPDecider happy-path test (canonical wire format)
  * Fail-closed / fail-open / malformed-response tests

Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:45:07 -05:00
73a05e4e46 chore(embedded): cut v0.0.16-beta — refresh SHA off the dangling 8df0def
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
The recent history rewrite (squash of 4 thrash CI commits) made the
chart's previous appVersion (0.0.16-beta-8df0def) reference a now-
dangling commit. The dev pipeline failed clone "remote ref not
found" until we re-bumped to a SHA in the new history.

Re-cut beta with the new HEAD parent (ae75855) so notify-chart-dev
rewrites the chart's appVersion to a SHA the BMCD dev pipeline can
actually fetch. Combined with the Dockerfile clone-via-fetch fix in
tnd-zddc-chart 86c5758 (handles bare SHAs), the dev pipeline should
build cleanly.
2026-05-04 07:57:35 -05:00
8925345129 chore(embedded): cut v0.0.16-beta with loading-efficiency wins
Bake into the dev binary:
  - ETag + max-age=0 on embedded HTML (304s on repeat loads)
  - gzip compression middleware (~75% wire-size reduction)
  - vendored jszip + docx-preview in archive/transmittal/classifier
  - tee'd file-based access log via --access-log
2026-05-04 07:49:17 -05:00