Skip to content

1.0.0-RC: hexagonal core/adapter/compose rewrite - #20

Open
pgodwin wants to merge 350 commits into
mainfrom
feature/refactor
Open

1.0.0-RC: hexagonal core/adapter/compose rewrite#20
pgodwin wants to merge 350 commits into
mainfrom
feature/refactor

Conversation

@pgodwin

@pgodwin pgodwin commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

This merges the feature/refactor branch: a ground-up rewrite of ClassicStack onto a
hexagonal (core / adapter / compose) architecture, plus everything built on top of
it since the rewrite landed. It replaces the old internal/app, port/, protocol/,
service/, router/, pkg/, netlog, capture/, config/ tree entirely.

253 commits, 2173 files changed (+393,924 / -64,367). Latest tag on main is v0.3.0;
this is proposed as 1.0.0-RC.

  • Architecture (.refactor/00-DESIGN.md, ARCHITECTURE.md): core/ holds
    protocol-pure logic with zero I/O imports (enforced by an import-graph CI gate —
    reflect, net, encoding/binary, encoding/json etc. are all forbidden in core/,
    which is what keeps a TinyGo/embedded build possible); adapter/ holds the concrete
    I/O (pcap, sqlite, http, uci, serial, dsi, smbtcp); compose/ wires components
    together via a registry + supervisor with dependency-ordered start/stop.
  • Migration was staged and merged incrementally (Phase 1 harness → Phase 2
    strangler migration, milestones A–D, M1–M11, cutover) — see .refactor/TODO.md for
    the full step-by-step log and design rationale for each seam. The cutover itself
    (deleting the legacy runtime, repointing binaries at the new run-core) landed
    2026-06-18 (21f8d1b, 511299a); everything since is feature work on the new
    architecture, not migration.
  • Since cutover, notable additions: a unified file client (csmount/csfs)
    mounting AFP/SMB/NCP/EtherDFS shares via WinFsp/macFUSE/libfuse; a Finder-style web
    admin UI (now a git submodule, third_party/classicstack-web); macOS/Windows tray
    app (cmd/classicstack-tray); TashTalk serial and LToUDP LocalTalk transports;
    direct-hosted SMB-over-IPX and NetBIOS browser/messenger services; a Windows
    installer (Inno Setup) built in CI; read-write ZIP filesystem backend.

Compatibility notes

  • Config: legacy top-level bridge identity keys are rejected; [Bridge] is now the
    only source for backend/device/MAC/frame mode (see ARCHITECTURE.md). Existing
    server.toml files from v0.3.0 will need migration — there is no automated
    upgrade path in this PR.
  • Submodule: cloning now requires git submodule update --init --recursive
    (third_party/classicstack-web). CI and README.md are already updated for this.
  • Binaries: cmd/classicstack now boots through cmd/internal/cli → the compose
    runtime instead of internal/app. Flags/behavior should be equivalent per
    .refactor/TODO.md M9/M10, but this is the highest-risk surface for regressions
    since it's the main entry point everyone runs.

Known gaps / follow-ups (not blocking, but worth tracking post-merge)

  • ARCHITECTURE.md still describes the pre-refactor runtime topology almost verbatim
    (only one line changed vs. main) — it doesn't yet describe the core/adapter/compose
    rings, the registry/supervisor model, or the new cmd/internal/cli entry point. Worth
    a follow-up doc pass.
  • Per .refactor/TODO.md, a handful of milestones are still open: M8a (share config →
    share.Manager wiring for AFP/SMB volumes), M8-spa (new-ring SPA, explicitly
    deferred/held), M11 opener-dispatch follow-ons. None of these block a build, but
    they're real scope not yet closed out.
  • scripts/ci/compute-release-metadata.sh's tag regex only accepts strict
    vMAJOR.MINOR.PATCH — a v1.0.0-rc1 tag will fail CI's release job. If you want an
    actual pre-release tag (not just merging to main, which auto-cuts a dev-<sha>
    prerelease), that script needs a pre-release-suffix case first.

CI

Refactor Harness CI is green on the current head (75db6b8, run
32545567182).
Note this PR will run under pr-ci.yml once opened against main, which hasn't
exercised this tree before — worth watching the first run closely.

Heads-up: merging this triggers a release

release-main.yml runs on every push to main and publishes a GitHub Release
(dev-<sha>, marked prerelease) automatically — merging this PR will cut a release
build across all platform/variant matrix targets. Flagging this explicitly since it's
not something a normal PR merge does in most repos.

pgodwin and others added 30 commits June 18, 2026 11:20
Third M-ng2 slice: wire SMB direct-hosting over IPX (Microsoft "NWLink direct
hosting", IPX socket 0x0550, NetBIOS-LESS). Until now the IPX mini-router was built
only when the NetBIOS service was present (for NB-IPX session traffic on 0x0455),
so a NetBIOS-free build with SMB + an IPX port never reached the direct-IPX
transport even though core/service/smb already implemented it (NewDirectIPX +
HandleDatagram on the ipxrouter SocketHandler).

crossWireTransports no longer early-returns when NetBIOS is absent. The IPX family
now carries TWO independent transports off one mini-router, and the router is built
whenever an IPX port exists AND at least one consumer was built:

  - NB-IPX session traffic on 0x0455 — registered when NetBIOS is present.
  - SMB direct-hosted-over-IPX on 0x0550 — registered when SMB is present,
    independent of NetBIOS.

The NetBEUI family, the mailslot/datagram path, and the SMB-over-NetBIOS session
consumer stay NetBIOS-gated (they genuinely need the NetBIOS layer). Teardown is
already lifecycle-safe: NewDirectIPX self-registers on the SMB service's closers, so
SMB Stop tears its circuits down — compose owns no extra teardown.

Verified: gofmt, vet -tags all, build all-tags + default, archtest, cs-tinygo amd64
gate, full all-tags + default suites. New transports_test.go case proves the IPX
port is attached (mini-router built off the SMB consumer alone) with NO NetBIOS
service present.

This completes the wireable M-ng2 transport seams. Remaining M-ng2 items are
correctly deferred to later milestones: IPXGW SetIPXRouter waits on the MacIPX
gateway becoming a registered component (M5/M8 MacIP cutover — today still the macip
placeholder); AFP-over-ASP is already wired (reg_afp SetRouter + crossWireRouter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive REAL ports over in-memory FrameLink pairs (adapter/link/inmem) through the
REAL M-ng2 cross-wire and assert the replies that come back out — no test doubles
between the wire and the command core. Where transports_test.go injects frames
straight into the delivery callback (a wiring unit test), these exercise the WHOLE
path: the frameport read loop, the port's own Ethernet/LLC decode+encode, the
mini-router dispatch, the session engine, the command core, and the link.

Two cases in compose/runtime/integration_test.go:

  - NetBEUI session CALL: a real core/port/netbeui port over an inmem link,
    cross-wired to a real NetBIOS+SMB stack, answers a NAME_QUERY (CALL) for its
    file-server name with NAME_RECOGNIZED. Path: client → peer link → port read
    loop → NetBEUI mini-router → NBF engine → port.Send → peer link → client.
  - SMB direct-hosted-over-IPX NEGOTIATE (NetBIOS-LESS, socket 0x0550): a real
    core/port/ipx port over an inmem link, cross-wired to a real SMB stack with NO
    NetBIOS, answers an SMB NEGOTIATE with the reply flag set. Path: client → peer
    link → IPX port read loop → IPX mini-router → direct-IPX transport → SMB
    command core → reply → port.Send → peer link → client.

The frames are built from EXPORTED protocol types only (nbf.Frame.Encode,
ipxproto.Datagram.Encode, smbproto.Header.Encode + the SMB WCT/BCC wire layout), so
the integration test owns no package-internal test helper. Each reader runs with a
2s timeout so a wiring regression fails fast instead of hanging.

Verified: gofmt, vet -tags all, build all-tags + default, archtest, cs-tinygo amd64
gate, full all-tags suite, and -race on the integration tests (clean).

Note: an AFP login→OpenVol→Enumerate integration over the DDP/ATP/ASP stack is a
worthwhile follow-on (a heavier harness needing a memfs volume); AFP-over-ASP/DDP is
already covered in its own service packages, and these two cases establish the
inmem-link end-to-end pattern through real router+services that M-ng3 calls for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Close the last compose-wiring item of M8a: when both SMB and the browser service
are built, install the browser as SMB's BrowseProvider (§3-ter) so the IPC$
\PIPE\LANMAN NetServerEnum2 RAP call answers from the live browse list instead of
the no-provider empty-success fallback.

smbBrowseBridge in crossWireTransports copies the browser's ServerEntries into
smb.BrowseServer rows and forwards Available — smb.BrowseServer mirrors
browser.ServerEntry field-for-field, so the bridge is a pure copy with no package
coupling (neither imports the other; compose owns the shim, exactly like
smbSessionBridge). It is wired independent of NetBIOS, since SMB serves
NetServerEnum2 over any transport including direct-TCP :445.

This completes the M8a compose-wiring noted alongside SetSessionConsumer/
SetDatagramConsumer. The rest of the core M8a slice list (AFP/SMB volume+share
config sections, config→ShareSpec mapper, supervisor Reconfigure→share.Manager,
server identity §4-bis, §10d/§10e FS bus + host-watcher, Model.Validate Apply hook,
secret-param masking) already landed in earlier commits; the only remaining M8a-
adjacent piece is the SPA's secret-input password field, which is M8-spa front-end.

Verified: gofmt, vet -tags all, build all-tags + default, cs-tinygo amd64 gate, full
all-tags suite. transports_test.go asserts the bridge forwards Available and copies
the browse list field-for-field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e proof)

Two standalone client binaries that drive the SAME core codecs and adapters the
server uses, proving the protocol layers compose into a client as cleanly as a
server (T1's "protocol-reuse proof").

cmd/csecho — an AppleTalk Echo Protocol (AEP) client over LToUDP. It opens the real
adapter/link/ltoudp multicast link, wraps it with the real adapter/link/framing LLAP
framer, and sends a DDP type-4/socket-4 echo request (command byte 1) built straight
from core/protocol/ddp + the aep.* consts, then waits for the reflected reply
(command byte 2) and prints it. Flags: -iface/-net/-src/-dst/-count/-timeout/-data.
No server, no router — just the wire stack, over the simplest real transport (UDP
multicast, no pcap/NIC).

cmd/csnetsend — a NetBIOS Messenger ("net send" / WinPopup) payload builder. It
assembles exactly the bytes messenger.Service.SendMessage puts on the wire:
messenger.Message{From,To,Text}.Marshal() wrapped in
mailslot.Write{Name:NameMessenger}.Marshal() (the \MAILSLOT\MESSNGR
SMB_COM_TRANSACTION envelope), then prints a hex dump and optionally writes the bytes
to a file (-o). Per the M7g/T1 "core send half only" decision, it stops at the
mailslot payload the service hands the netbios.SendDatagram seam; the outer NetBIOS
datagram framing + transport (NBT/NetBEUI/NB-IPX) is M7b2.

main_test.go round-trips the assembled payload back through the SAME core codecs
(mailslot.Unmarshal → messenger.Unmarshal), proving what the client builds is exactly
what the server parses.

Verified: gofmt, vet, build all-tags + default, archtest, cs-tinygo amd64 gate, cmd
test suites (default + all-tags); csnetsend run produces a correct \MAILSLOT\MESSNGR
transaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ssicstack-ng

Bind a control.Plane over the runtime's supervisor and expose it through the
new-ring HTTP control adapter (adapter/control/http: JSON API + SSE), opt-in via a
new -http flag (empty = disabled). This is the parity gate for the M10 cutover: the
new ring now serves the management API (/status, /config, /reconfigure, /save,
start/stop/restart, users, /setup first-run gate, /subscribe SSE) that the legacy
internal/app + service/webui provided, so deleting the legacy runtime no longer
means losing the control surface.

The Plane is built from rt.Supervisor() (which satisfies control.Supervisor) plus
the SAME file Store + TOML Codec the config loaded through, and the telemetry bus —
the single contract every front-end shares (§14). The SPA (M8-spa) will layer over
this exact surface. The server starts after the supervisor is up and stops before
teardown so no control call races shutdown.

Verified: gofmt, vet, build default/pcap/all + full all-tags, and a live smoke test
— `classicstack-ng -http 127.0.0.1:PORT` boots, serves the API, and /status returns
409 (the first-run setup gate, no admin configured), then shuts down cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cli)

Point every classicstack binary at compose/runtime via a new shared run-core,
cmd/internal/cli, so the legacy internal/app run-core is no longer imported by
anything — the safe precondition for the M10 deletion (the binaries never break
across the cutover).

cmd/internal/cli is the COMPOSE EDGE: it chooses the concrete adapters compose/
runtime deliberately does not import — the file config Store, the TOML Codec, the
pcap LinkOpener, the serial opener, the HTTP control server — so the runtime ring
stays adapter-agnostic and cgo-free. It exposes Main(Version) and
Run(ctx, args, Version), mirroring the legacy app.* signatures so the service/daemon
wrappers thread the same struct through unchanged. Run is config-file driven
(server.toml + the named-instance model + the web-admin control plane); the legacy
per-transport flag wall is superseded, so Run takes only the cross-cutting flags
every entry point shares: -config, -http, -version. It blank-imports the component
set so the build-tagged init()s self-register (the §8 replacement for *_disabled.go).

Repointed:
  - cmd/classicstack       → cli.Main (thin entry, build vars only)
  - cmd/classicstack-ng    → cli.Main (now an alias of classicstack)
  - cmd/classicstack-svc   → cli.Run / cli.Version (Windows SCM wrapper)
  - cmd/classicstackd      → cli.Run / cli.Version (Unix daemon + macOS LaunchAgent)

internal/app is now orphaned (grep confirms zero importers outside itself). The
legacy tree it pulls in (router/port/service/netlog/pkg/control/logbuf/metrics/
service/webui) is therefore reachable from nothing — the M10 deletion follows.

Verified: gofmt, build all-tags + default, cs-tinygo amd64 gate; cross-compiled the
platform-tagged wrappers (linux + darwin classicstackd, windows classicstack-svc)
against cli; a prior live smoke test confirmed -http serves the control API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ring

The strangler-fig cutover. With every binary now on the new-ring run-core
(cmd/internal/cli → compose/runtime, M9) and the legacy internal/app fully
orphaned, delete the entire legacy stack it pulled in:

  internal/        the legacy run-core (Supervisor, wireXxx hooks, *_disabled.go)
  router/ port/    the legacy routing engine + transport ports
  service/         the legacy AFP/SMB/NetBIOS/MacIP/… services (+ macgarden cache)
  netlog/          the legacy logger — superseded by core/log (logging cutover)
  config/          the legacy koanf config — superseded by core/config + adapter/config/toml
  capture/         the legacy capture glue — superseded by adapter/capture
  pkg/control      legacy control plane  → core/control + adapter/control/*
  pkg/logbuf       legacy log ring        → core/log sinks
  pkg/metrics      legacy stats hub       → core/bus + compose/stats
  pkg/logging      legacy slog wiring     → core/log
  pkg/{appledouble,cnid,encoding,hwaddr,serialport,shortname,status,telemetry,vfs}
                   legacy support pkgs    → core/ equivalents

The new ring (core/ + compose/ + adapter/ + cmd/) is now the whole product:
config → compose/runtime build+supervise → cross-wired data path (DDP router +
IPX/NetBEUI mini-routers + NetBIOS/SMB/AFP/browser/messenger) → web-admin control
plane, driven by classicstack / -ng / -svc / -daemon over cmd/internal/cli.

Kept deliberately: protocol/ (atp/ddp/llap + root) and pkg/binutil — a
self-contained legacy-codec leaf with NO dependency on anything deleted — because
the cmd/pcapdiff capture-diff diagnostic still decodes through them. Porting
pcapdiff onto core/protocol (which has no exported LLAP decoder; LLAP framing lives
unexported in adapter/link/framing) is a follow-on, tracked separately; it does not
block the runtime cutover.

go mod tidy pruned 24 dependencies the legacy tree alone pulled (goquery, koanf,
tailscale.com + wireguard/netlink, sspi, …); the new ring carries its own
adapter/config/toml and a reflection-free core.

Verified: gofmt, vet -tags all, build all-tags + default, archtest, cs-tinygo amd64
gate, and the FULL all-tags + default test suites — all green with the legacy tree
gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…trol adapters

The control-plane contract add the SPA needs to render per-share forms: until now
ListFSTypes was a Supervisor placeholder returning nil, and the param schema (which
keys, which are required, which are Secret) was reachable only inside core/fs.

  - core/fs.Types() — sorted list of registered fs_type names (the dropdown source).
  - Supervisor.ListFSTypes now returns fs.Types() instead of nil.
  - control.Plane.ParamsFor(fsType) []ParamInfo — the schema half of ListFSTypes,
    a JSON-friendly mirror of fs.Param{Key,Required,Secret,Doc}. The plane reads the
    fs registry directly (a pure lookup, no supervisor state). A UI renders the chosen
    backend's form from it and shows Secret keys as password fields (the server already
    masks/unmasks Secret values on the Config/Reconfigure round-trip).

Plumbed through every control transport so all three stay at parity: the inproc
Client interface + Adapter, the HTTP server (GET /params_for?fs_type=) + AdapterClient,
and the ubus server case + AdapterClient.

Verified: gofmt, vet -tags all, build all-tags, the control parity test (exercises
inproc/http/ubus together), cs-tinygo amd64 gate, and a live smoke test of the
fs-type endpoints through classicstack-ng -http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, minimal single-page web-admin embedded in the HTTP control adapter
(adapter/control/http/spa), served under //go:build webui||all. Built with NATIVE
Web Components — custom elements + a tiny el() DOM helper, NO jQuery, no framework,
no build step. It drives the same JSON/SSE control API every front-end shares.

Views (cs-app routes on the first-run gate):
  - cs-setup     — first-run admin creation (POST /setup), shown on a 409 gate.
  - cs-dashboard — component status table with Start/Stop/Restart per unit and a
    Refresh; reads GET /status, posts /start|/stop|/restart.
  - cs-config    — the live masked config model (GET /config) as formatted JSON.

Auth flow: the page's static assets (/, /app.js, /app.css) are exempted from the
auth gate so the page can LOAD, then its JS probes /status — 409 → setup screen,
401 → the browser's Basic-auth prompt (then reload), 200 → dashboard. Every data
route stays gated; the assets carry no secrets.

Wiring: spa.go embeds the three files (embed.FS, webui||all only) and mountSPA
serves index.html's bytes directly at "/" (not via a path rewrite, which would
301-redirect to /index.html) with the file server handling named assets; the
specific API routes win over "/" by ServeMux longest-prefix. spa_stub.go (!webui
&&!all) makes mountSPA a no-op and exempts no path, so a headless/API-only build
(and TinyGo) embeds zero HTML — the §8 build-tag gate, the new-ring analogue of the
legacy service/webui embed.

The fs.Param.Secret → password-field hint (the last M8-spa-adjacent item) rides the
ParamsFor contract added in the previous commit; the share-form UI that renders it
is a follow-on enhancement over this baseline.

Verified: gofmt, vet -tags all, build all/default/webui + full all-tags suite,
archtest, cs-tinygo amd64 gate (SPA does not leak in), and a live smoke test — GET /
returns the cs-app HTML (200), /app.js + /app.css serve with correct content-types,
and /status stays 409-gated pre-setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the last legacy holdout. cmd/pcapdiff (a capture-diff diagnostic) was the
only thing still importing the legacy protocol/ (atp/ddp/llap + root) and pkg/binutil
leaf that the M10 cutover deliberately kept alive. With pcapdiff gone, both are fully
orphaned, so all three are deleted together — the repo is now purely the new ring
(core/ + compose/ + adapter/ + cmd/) plus assets, with no legacy package surviving.

go mod tidy reports no change: the leaf used only the stdlib, so nothing external
drops — what's removed is the last internal coupling to the pre-refactor codec tree.

Verified: gofmt, vet -tags all, build all-tags + default, cs-tinygo amd64 gate, full
all-tags + default test suites — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more standalone AppleTalk client utilities over the LToUDP+LLAP wire
stack, completing the netatalk diagnostic-tool trio alongside csecho (aecho):

- csnbp (nbplkup): NBP name lookup. Sends a Broadcast Request for an
  object:type@zone pattern and prints each matching node's address. Omitted
  fields wildcard ('=' object/type, '*' zone).
- csgetzones (getzones): ZIP zone-list query over ATP. Pages GetZoneList
  TReq/TResp until the router's last-flag; -local for GetLocalZones, -my for
  GetMyZone.

Both are T1 "protocol-reuse proofs" like csecho — they drive the SAME core
codecs/constants the server uses (core/protocol/nbp, core/service/zip) over
adapter/link/framing + adapter/link/ltoudp; no server or router code.

Adds nbp.BuildLkUp, the request-side partner of BuildLkUpRply, so the client
encodes through the core codec rather than hand-rolling wire bytes (DTO rule).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… responder

Two more legacy-network diagnostic utilities plus the server-side piece IPX
ping needs:

IPX ping (IPXPING equivalent):
- core/protocol/ipx/diag: the IPX Diagnostic codec (socket 0x0456) — self-
  serialising request (exclusion list) / response (component summary) DTOs.
  Observation-based; documented in spec/errata.md per the project rule.
- core/service/ipxdiag: the IPX Diagnostic Responder — a SocketHandler on the
  IPX mini-router that answers reachability probes (the IPX analogue of the AEP
  responder). Wired in crossWireTransports; registry entry gated ipxdiag||all,
  built sink-less and injected the mini-router as its egress, like the browser.
- cmd/csipxping: the client — sends a diagnostic request over the pcap NIC link
  (IPX rides Ethernet, not LToUDP) and reports per-reply round-trip time.

Enhanced net view (browser browse-list):
- core/service/browser: serverRecord/ServerEntry now retain the announced OS and
  browser-protocol versions and comment (off the HostAnnouncement frame), not
  just the type bits. SMB's NetServerEnum2 bridge is unaffected (still copies
  Name/Type/Comment).
- cmd/csnetview: a standalone passive listener that decodes Host/LocalMaster/
  Domain announcements off the wire across NetBEUI (NBF) and IPX (NBIPX/NMPI),
  chaining the same core codecs the server uses, and prints discovered hosts with
  protocol, protocol address, OS/app version, role, and comment.

All four utilities (csecho/csnbp/csgetzones/csipxping) now drive the same core
codecs the server uses; csipxping/csnetview compile in the default build via the
pcap stub and link libpcap under -tags pcap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ashtalk/pcap)

csecho/csnbp/csgetzones hardwired LToUDP. Factor the transport open into a
shared cmd/internal/atlink helper that picks the segment transport behind one
-transport flag and returns a framed link.DatagramLink:

  - ltoudp (default): LToUDP multicast + LLAP framing (unchanged behaviour)
  - tashtalk: a serial TashTalk adapter (-device/-baud) + LLAP framing
  - pcap: an EtherTalk NIC via libpcap (-iface) + Ethernet/SNAP framing
    (needs -tags pcap; errors cleanly without it via the pcap stub)

All three converge on link.DatagramLink — the type each client already drove
after framing — so the clients change only at the open site; default runs are
identical to before. atlink lives under cmd/internal (the cmd edge) because it
imports the adapter ring, which core and compose/runtime must not.

Also drops the deprecated cmd/classicstack-ng alias — it is just classicstack
now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
server.toml.example was stale (legacy [Bridge]/[AFP]/[WebUI] sections, pkg/vfs).
Regenerate it from the LIVE config schema (verified by round-tripping through the
real TOML codec): well-known [identity]/[logging]/[router]/[bridge], the
[[interface]] namespace, repeated [[ethertalk]]/[[ltoudp]]/[[tashtalk]]/[[ipx]]/
[[netbeui]] transports, and [[afpvolumes]]/[[smbshares]]. Documents that the
web-admin credential is first-run-only and the web-admin API is the -http flag.

OpenWRT packaging under openwrt/:
  - Makefile: golang-package build of ./cmd/classicstack (tags afp smb netbios
    ipx netbeui macip pcap; +libpcap dep), installs the init + UCI config.
  - files/classicstack.init: procd service, runs the daemon against the UCI
    config, reads an init-only `config classicstack 'init'` block (enable /
    http_addr / respawn) the daemon ignores.
  - files/classicstack.config: the UCI mirror of server.toml.example (verified
    round-trip through the UCI codec), installed as /etc/config/classicstack.
  - README.md: build/install/run notes.

Make the codec selectable by config path (cli.pickCodec): UCI for an /etc/config
path or a *.uci/*.config file, TOML otherwise — so ONE binary reads server.toml
on a desktop and /etc/config/classicstack on a router with no separate build. The
web-admin Save uses the same codec, so a UCI-loaded config is rewritten as UCI.

The TOML repeated-section idiom stays array-of-tables ([[afpvolumes]]) rather
than named sub-tables ([afpvolumes.Public]): both are valid TOML, but the array
form maps cleanly to the same named-instance model the UCI codec uses, keeping
the two codecs symmetric.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…instance CRUD, stats, config sections, gateways, diagnostics

The "web UI regressed" symptom traced to the M10 cutover deleting the legacy
pkg/control + service/webui tree and rebuilding the new-ring control plane
thinner than the original: contracts the UI needs were dropped, and several
services/sections the legacy UI edited were never wired into the new ring. This
restores them as backend-first layers feeding the SPA.

L0 — register the orphaned DDP services: rtmp/zip/nbp/aep existed but had no
compose factory, so the router never routed or had zones. Add reg_{rtmp,zip,nbp,
aep}.go (+ bundle components for RTMP/ZIP) and Router hardDeps; the runtime's
crossWireRouter auto-registers them on their sockets.

L1 — repeated-section CRUD: Supervisor.Reconfigure mis-wrote NamedSection
instances as singletons (volumes/shares could not be created). Route them to
Model.Lists; add AddInstance/RemoveInstance on the Supervisor + Plane; add
component.Describable so Status() carries Kind/Props.

L2 — stats producers: components implement component.Statful but nothing
published. Add a supervisor stats flush (poll Statful nodes + StatsEmitter push)
feeding the existing compose/stats collector + SSE; align SPA metric keys; add a
gated expvar metrics sink (adapter/metrics, perfcounters tag).

L3 — config sections + service wiring:
 - SMB/NetBIOS transport binding lists (serversection.go / section.go) gate the
   compose transport cross-wire per family.
 - adapter/smbtcp: a real direct-TCP/NBT SMB listener over the SessionConsumer
   seam (net-new; NBT was a stub even on main). Windows-safe: binds only an
   explicit tcp_addr, bind failure is non-fatal (OS owns :445).
 - Capture (pcap) per-interface section + cmd-edge link.Capture tee.
 - InterfaceSection.Backend (pcap/tap/tun) dispatch.
 - AFP extension-map: parser/marshal + Volume Finder type/creator defaulting +
   per-volume ExtMapPath.
 - MacIP: placeholder -> real gateway (section + NBP wiring; AppleTalk-only until
   an IP-egress adapter lands, surfaced in Props).
 - IPXGW (MacIPX): unregistered -> real gateway (reg_ipxgw.go + section + NBP +
   IPX-mini-router wiring).

L4 (partial) — control-plane surface across all three adapters (http/ubus/inproc,
parity-enforced): instance add/remove endpoints; real diagnostics (compose/diag
reads the router's zone table, replacing the unavailable stub).

Verified: go test -tags all ./... (0 failures), headless + all + all,perfcounters
builds, gofmt + vet clean, conformance harness green, and live smoke tests
(services running, SSE stats frames, SMB-TCP bind cases, MacIP/IPXGW Props,
/list_zones 200-not-501).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Continues the web-admin restore: the control-plane surface the SPA's config
forms and editors need, all live-verified.

- Extension-map editor surface (adapter/extmap): GET/POST /extmap read, validate
  (round-trip through afp.ParseExtensionMap), and write with a numbered backup —
  the file half core does not do. Server-side handlers (a path is server-local),
  not on the transport-agnostic Client contract.
- Config download: control.Plane.MarshalConfig serialises the masked model
  through the codec; GET /config_download serves a faithful server.toml backup
  (the JSON Config() shape cannot).
- NIC picker: Supervisor.SetInterfaceEnumerator + runtime Options thread an
  injected enumerator (cmd-edge pcap.ListDevices) into ListInterfaces, keeping
  the supervisor pcap-free; the stub/no-permission case degrades to empty.
- Serial picker: adapter/serial.ListPorts (ported from the legacy pkg/serialport
  — Windows SERIALCOMM registry, /dev glob on Unix) + GET /list_serial_ports.
- Path picker: GET /browse_path lists directories under a cleaned dir (+ parent)
  for the volume/share path chooser; dirs-only, no file contents, auth-gated.

Verified: go test -tags all ./... (0 failures), headless + all builds, gofmt +
vet clean, and a live smoke test — /list_interfaces, /list_serial_ports
(2 real COM ports), /browse_path (62 dirs + parent), /extmap, /config_download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rid, TOML download

Builds the front-end on the restored control-plane surface so the backend work
is operable from the browser.

- Volume/share editors (cs-instance-editor): a table per repeated-section key
  (AFPVolumes/SMBShares) with Add / Edit / Delete driving /add_instance,
  /remove_instance, /reconfigure; the owning service reconciles live. The path
  field carries a Browse… directory picker (openPathBrowser over /browse_path)
  and fs_type a dropdown from /list_fs_types. Forms key off the Go FIELD names
  the JSON uses (VName/FSType/Path), not the toml names.
- Extension-map grid (cs-extmap): a 3-column Extension/Creator/Type editor over
  /extmap, parsing the Netatalk format on load and serialising rows back on save
  (with the server's numbered backup); add/delete rows, editable file path.
- Config download switched to /config_download (faithful server.toml via the
  codec) rather than the JSON model shape.
- Diagnostics gains a Serial Ports probe; the dashboard already surfaces the new
  Kind/Props and live stats from the backend layers.

Verified: node --check, go build (headless + all), full suite (0 failures), and
a live E2E — create an AFP volume through /add_instance (VName round-trips),
remove it (count → 0), /config_download serves application/toml, and the page
serves app.js with cs-instance-editor / cs-extmap / the path browser embedded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…IP gateway

Recover web-admin functionality and the MacIP IP-egress dropped in the M10
legacy cutover, plus the control-plane/SPA surface for editing it.

MacIP gateway (adapter/macipgw): full NAT mode (OS-socket ICMP/UDP/TCP), a
DHCP-relay adaptor, and a proxy-ARP etherlink so the router-member gateway
egresses real IP and listens for inbound. Core gains an optional
AddressAssigner seam (async DHCP assignment off the inbound loop) and the
cgo-bearing egress opener is injected at the cmd edge so compose stays
cgo-free.

Control plane / adapters:
- afp.ServerSection (advertised name/zone + ddp/tcp transport bindings),
  registered + wired into the AFP service (Describable props).
- Diagnostics gains RegisteredNames (NBP table) and MacIPLeases drill-downs,
  threaded core/control -> compose/diag -> inproc/http/ubus adapters.
- Interface-namespace CRUD (SetInterface/RemoveInterface) through plane ->
  supervisor (referencing-port reconcile) -> all three control adapters.
- User store wired at last: registry.BuildUserStore (always-compiled hook)
  -> supervisor.SetUserStore + AFP/SMB authenticators. It was defined but
  never called, silently leaving services guest-only.
- runport now implements Describable (Kind "port" + seed range/zone props).

SPA (native web components):
- One FIELD_META registry drives full labels + descriptions on every form;
  the dashboard cog modal and the config page share one sectionEditor.
- Dashboard groups components by role (AppleTalk router / file services /
  transports); SMB-TCP nests under SMB by its dependency.
- Model-aware widgets: Router members + bridge members as checkbox lists, a
  transport port's interface as a dropdown, SMB/NetBIOS/AFP transport
  bindings as checkboxes, and EtherTalk/LToUDP/TashTalk zone + network seed
  fields surfaced. Interface-namespace CRUD panel; clickable NBP-name and
  MacIP-lease drill-down modals.

Verified: go build (all / headless / all+webui+pcap), go test -tags all,
go vet, gofmt, node --check all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…spaces

Add a NetWare 3.x-compatible NCP file server so NETx/VLM/Client32 (DOS,
Win 3.x/9x), MacIPX, and OS/2 NetWare requesters can attach and use shares.
Build tag `ncp` (and `all`); reuses the AFP/SMB storage + auth + bus seams.

- core/protocol/ncp: NCP request/reply framing, SAP, and the function-0x57
  name-space DTOs (NW_HPATH, INFO_MSK_* bits, build_dir_info entry builder).
- core/service/ncp: connection table, DOS file/dir engine (0x14-0x4D), bindery
  login over core/auth (guest fallback), NCP-over-IPX transport (socket 0x0451),
  SAP advertiser (0x0452), and dashboard stats (connected machines / logged-in
  users / open files / throughput).
- Name spaces: function 0x57 serves OS/2 + Mac long filenames, reusing the AFP
  FilenameCodec + NameEngine (DOS 8.3 / Mac 31-char MacRoman / OS2 native long).
- core/fs.ResolveFold: shared case-insensitive store-path resolver so mis-cased
  long names still resolve on a case-sensitive host (NFS stays case-sensitive).
- compose wiring (reg_ncp, transports wireIPX/wireAuthenticator), config
  ([[ncpvolumes]]), client tool csncpinfo (SLIST-style SAP probe), spec/17-ncp.md.

DTOs and function codes verified field-for-field against github.com/davidrg/mars_nwe
(src/nwconn.c, src/nwbind.c, src/namspace.c, include/{net,namspace}.h).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cross-service naming

Add an EtherDFS server (The Ethernet DOS File System): DOS clients map a remote
drive to a drive letter over raw Ethernet (EtherType 0xEDF5, no IP/TCP/NetBIOS).
The service embeds the frameport raw-frame base (own NIC link, no cross-wire) and
serves the full read/write AL_* opcode set over the shared fs/share seam, with 8.3
short names, FAT attributes, sequence-dedup, and a per-client open-file table.

  core/protocol/etherdfs  frame header + BSD checksum + opcodes/FCB/path + DTOs
  core/port/etherdfs      frameport-based port, EtherType demux, reply framing
  core/service/etherdfs   service/dispatch/drive/session over the storage seam
  compose/registry        single-factory wiring + conformance stager
  adapter/control/http/spa EtherDFS Drives grid + [EtherDFS] singleton panel
  spec/18-etherdfs.md      wire format, opcodes, errata cross-links

Add a per-share DOS-attribute + naming layer shared by EtherDFS, SMB, NCP, and AFP:

- metastore.DOSAttrStore: definitive per-share store (sqlite/mem) keyed d/a/<path>,
  with a Samba-compatible XATTR_DOSINFO v3 value codec so the metastore, sidecar,
  and user.DOSATTRIB xattr all share one on-disk format (and interop with Samba).
- DOS-attr backends selected per share by dos_attr_backend (auto|metastore|sidecar|
  native|xattr): Windows-native passthrough, Samba xattr (linux/darwin, xattr tag),
  an all-filesystems sidecar, always caching in the metastore. auto = native →
  xattr → sidecar. Host-path resolution via the new fs.HostPather seam (local_fs).
- BuildShare assembles the store and exposes it via fs.DOSAttred / fs.Named on the
  built ForkFS; SMB persists/serves attrs via TRANS2_SET/QUERY_*_INFO, EtherDFS via
  AL_SETATTR/GETATTR.
- Name engine: case-insensitive lookup with preserved stored case (Windows-FS
  semantics), one generator on Win/Mac/Linux; 8.3 uppercased, 31-char medium keeps
  case. AFP long names now flow through the medium engine; NCP 8.3 through the name
  engine (unique, reversible) instead of raw truncation.

dos_attr_backend is wired on all four service config sections. Docs:
spec/16-storage-seam.md + errata (Samba DOSATTRIB interop, casing) + server.toml.example.

Verified: gofmt + go vet -tags all clean; builds for no-tags, all, sqlite,
GOOS=linux/darwin -tags xattr; full test suite green (incl. new codec, backend,
casing, and SMB/EtherDFS attribute-persistence tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he new ring

Recover the macintoshgarden.org abandonware archive as a read-only AFP volume —
a backend listed in .refactor/00-DESIGN.md (fs/macgarden) and whose CatSearch
behaviour spec/errata.md was designed around, but which was dropped in the M10
cutover and never ported.

It lives entirely in adapter/macgarden (it does real HTTP I/O, so it cannot be
in core) and self-registers fs_type "macgarden" into the core/fs factory
registry. AFP is fully decoupled: it knows only core/fs.FileSystem + the optional
core/fs.CatSearcher capability and never imports this package or names the type —
the registry virtualises the backend away. (The legacy port leaked afp.* error
types into the FS layer; those are replaced with package-local errors here.)

- dom.go: a tiny CSS-selector engine over golang.org/x/net/html (child/adjacent/
  attribute/compound/list selectors) so the refactor ring does NOT re-add the
  goquery + cascadia modules it dropped. Proven against the captured fixtures.
- client.go: the HTTP scraper (categories / search / item pages / ranged reads)
  on the new dom engine, logging through a core/log shim (was netlog).
- fs.go: the virtual tree (Apps/ Games/ search/) as a core/fs.FileSystem +
  CatSearcher; FPCatSearch becomes an upstream archive query materialised as
  virtual folders, paging through an opaque CatSearchCursor. Read-only: all
  mutating methods return fs.ErrPermission.
- stub.go: in an afp/smb build WITHOUT the macgarden tag, registers the fs_type
  with an actionable "rebuild with -tags macgarden" error (vs. a generic miss).
- reg_macgarden.go: blank-imports the backend under afp||smb||all so either the
  real backend (macgarden/all) or the stub links; never in a no-file-service build.

Verified: go build all / headless / afp-stub, go test, go vet, gofmt — all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The local_fs backend reported 0/0 ("unknown") for volume size; fill in the
real host-volume total/free at the VFS level and SATURATE it to each
protocol's field width at the consumer (the protocol-specific cap belongs
where the field width is known, not in core/fs which cannot tell who is
asking).

core/fs (VFS, real uncapped bytes):
- DiskUsage resolves the path under the share root and delegates to a
  build-tagged diskUsage helper: statfs(2) on unix, GetDiskFreeSpaceExW on
  Windows (both via stdlib syscall, no x/sys added to the core ring), and a
  0/0 fallback for unsupported GOOS / TinyGo so the file services keep
  compiling on the cs-tinygo gate.

Per-protocol caps (saturate, never wrap):
- AFP: the old uint32(free/total) cast WRAPPED a >4 GiB disk (a 6 GiB volume
  reported 2 GiB). Replaced with sat32, capping at 0xFFFFFFFF, the AFP 2.x
  BytesFree/Total 32-bit field limit (the 64-bit ExtBytes fields are AFP 3.x,
  not implemented here).
- SMB (clamp16, ~2 GiB unit ceiling) and NCP (block-scaling loop) already
  saturate correctly; added tests to lock them.

Tests: real DiskUsage non-zero + traversal guard; sat32 (6 GiB/1 TiB capped,
not wrapped); clamp16. gofmt/vet clean; core/fs cross-compiles
windows/linux/darwin; full core suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e ring

The core import-graph gate (core/internal/archtest) was red: several core
packages transitively pulled reflect / encoding/binary, which the
no-reflection rule (TinyGo + allocation discipline, §1) forbids. Two root
causes, both from features that landed after the rule was last enforced:

- core/fs/dosattr_native_windows.go imported golang.org/x/sys/windows for
  three calls (GetFileAttributes/SetFileAttributes/UTF16PtrFromString) + the
  FILE_ATTRIBUTE_* consts; x/sys/windows pulls encoding/binary -> reflect.
  Swapped to stdlib syscall (already a permitted core dep), which carries all
  of them. This cleared core/fs and everything downstream of it (afp/smb/
  share/control/etherdfs/ncp).

- The extension-map parser (afp/extmap.go, extmap_parse.go) and the MacIP
  config section (macip/section.go) used fmt.Errorf/Fprintf; fmt pulls reflect
  too. Replaced with errors.New + strconv.Quote/Itoa and a hand-built
  strings.Builder for the Netatalk `.ext "TYPE" "CRTR"` line — the same
  fmt-free idiom the rest of core uses.

No behaviour change (error text and on-disk format identical). archtest now
passes fresh (-count=1); gofmt/vet clean; build all/headless + core/fs
cross-compile (windows/linux/darwin) green; full core test suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rchive RAM)

Add an optional `zipfs` fs_type backend (adapter/zipfs, -tags zipfs||all) that
serves an entire share tree from a single .zip archive — the canonical "VFS
structure works standalone" check: it pins appledouble forks + the mem metastore
so it exercises the whole §9 storage seam with no host directory and no sqlite.

Memory model: the archive is never loaded into RAM (a 2 GiB volume must not cost
2 GiB), and no long-lived OS handle is held between calls (it would lock the file
on Windows and pin across a slow client). Reads stream — each read handle owns a
short-lived zip.Reader and inflates on demand behind a forward cursor (backward
ReadAt reopens). Writes stage in a host temp file (random-access there); on flush
the archive is rebuilt by streaming member-by-member old→new (Writer.Copy copies
unchanged members raw, no re-deflate), then atomic rename. Peak RAM ≈ one buffer.

Lives in adapter/ because archive/zip pulls compress/flate → reflect, forbidden in
the core ring; mirrors the macgarden backend's wiring (real backend + disabled
stub + reg_zipfs.go blank-import). Validates the existing read-only-zipfs ⇒
appledouble-fork-only constraint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ice Stop

The share lifecycle had no close hook, so a backend owning a GC-invisible
resource leaked: macgarden's scraper goroutine and zipfs's per-handle archive
fds were never released at teardown.

Add `fs.FSCloser interface { Close() error }` as an OPTIONAL capability (like
HostPather/Coded/DOSAttred), not a method on FileSystem — existing backends are
unaffected. `fs.CloseFS(f)` calls it when present, else no-ops. shareFS forwards
Close to the base backend; share.Share.Close threads it through; and each file
service's share wrapper (smb.Share, afp.Volume, etherdfs.Drive, ncp.Volume)
exposes Close.

Wire the call at DEFINITIVE teardown only — every file service's Stop() snapshots
its live shares/volumes under lock, unlocks, then closes each. NOT called from
RemoveShare/UpdateShare, which keep the in-flight contract (a session holding the
displaced share rides it out; GC reclaims it) — closing there would pull the FS
out from under a live handle.

macgarden and zipfs now assert `_ fs.FSCloser`. Tests: fs.TestFSCloserSeam
(no-op + direct + through-shareFS forwarding) and smb.TestStopClosesShares
(Stop closes the live share; RemoveShare does not).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… backend

Two boundary cleanups from the storage-seam review.

1. Per-backend validators (Open-Closed). validateShareSpec no longer hardcodes
   plugin names (`if fsType == "hfs-image"`, `"zipfs"`, …). RegisterFSWithValidator
   lets a backend declare its own fs_type×codec / fs_type×fork constraints via a
   Validator(SpecConstraints) error; BuildShare calls the registered validator for
   the share's fs_type instead of branching on the type string. zipfs now declares
   its read-only⇒appledouble rule from its own package; the core knows no plugin
   names. Only a genuinely cross-component rule owned by no single backend
   (macroman-native codec × xattr fork) remains in core.

2. Remove the readOnlyFS wrapper. Read-only is now enforced INSIDE memFS (a
   readOnly bool guarding its mutators + Capabilities), exactly how local_fs and
   zipfs already honour spec.ReadOnly. The old wrapper re-listed every method and
   would silently drop any optional capability the inner FS gained (HostPather,
   CatSearcher, …) unless hand-updated to forward it. Folding the policy into the
   backend removes that whole bug class — one concrete value carries every
   capability it implements, read-only or not.

Tests: validator hook exercised via test-only hfs-image/zipfs factories;
TestReadOnlyMemFSEnforcesAndPreservesCapabilities asserts RO rejects mutations,
reports ReadOnly, and still satisfies CatSearcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hardcoded forkEngineByName switch with a self-registration registry
mirroring RegisterFS, and make a fork adapter MANDATORY for every share. Pure
refactor — every previously-valid share builds identically; all fork tests pass
unchanged.

- New core/fs/fork_registry.go: RegisterForkAdapter(name, factory) +
  forkAdapterByName(name, base), guarded like fsFactories. The set of fork
  backends is now the set linked into the build.
- Built-in adapters self-register from init(): appledouble (+ auto/native aliases)
  in fork.go, ads in fork_ads.go, xattr in fork_xattr.go. native carries a
  TODO(phase4) — it aliases appledouble until the real host-fork adapter lands.
- nullForkEngine -> noForkAdapter, exposed as NewNoForkAdapter() and registered as
  "nofork" (aliases "null"/"none"): the EXPLICIT "this share has no resource forks"
  choice, so a fork-less share is deliberate, not a silent fallback.
  NewNullForkEngine kept as a deprecated alias for existing callers.
- BuildShare resolves the adapter via forkAdapterByName (default appledouble,
  unknown = hard error); ForkFS doc states the always-present-adapter invariant.

Also lands .refactor/fork-adapter-phases.md (the phase prompts for this refactor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…se 2)

The AppleDouble adapter hardcoded the Netatalk "._name" sidecar location. Make each
sidecar LAYOUT its own registered fork adapter that inherits one base AppleDouble
engine and overrides only where the sidecar lives, so an OS-X-created zip
(__MACOSX/dir/._name) and a .AppleDouble/ folder volume work. The AppleDouble byte
format (core/appledouble) is identical across all variants — only the container
location moves.

- appleDoubleForkEngine is the shared BASE: it holds an injected
  sidecar func(path) string and contains all fork logic. The variants differ only
  in that function.
- Registered adapters: appledouble-default (._name), appledouble-osxzip
  (__MACOSX/…), appledouble-dir (dir/.AppleDouble/name) — consts
  ForkAppleDoubleDefault / ...OSXZip / ...Dir. Plain "appledouble" (+ "auto"/
  "native") aliases appledouble-default, so existing configs and the default are
  unchanged.
- Selection is by ADAPTER NAME (ForkBackend) through the registry — no Extra config
  key, no layout-strategy interface. An unknown name is a hard error as before.

Caveat for a later slice: osxzip / dir layouts imply an intermediate dir; memfs/
zipfs auto-vivify parent keys, local_fs would need MkdirAll on sidecar write.

Tests: fork_layout_test.go — per-layout sidecar-path mapping; the registry resolves
each variant + the aliases to the right layout; per-layout fork+FinderInfo
round-trip with location assertions; Move/DeleteMetadata follow the layout;
BuildShare selects a variant by name and "appledouble" stays the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion (phase 3)

Make the fork adapter the owner of its metadata containers and let a same-host-path
peer follow them, so an AFP+EtherDFS/SMB pair sharing one host path stays
metadata-consistent across a rename/delete.

- New optional fs.ForkContainers{ MetadataPaths(storePath) []string }: the
  AppleDouble base returns its single sidecar path (whatever layout placed it);
  ads/xattr/nofork keep metadata with the file and return nil (don't implement it).
  shareFS forwards the capability. shareFS.Rename/Remove already delegate the
  container move/delete to the adapter (MoveMetadata/DeleteMetadata) — the adapter
  owns what its containers are; docs clarified.
- share.Reactor coordination: NamedPath gains an optional FS fs.ForkFS field (AFP/
  SMB/NCP set it from v.FS()/sh.FS()). On a foreign OpRename under a shared root,
  Reactor.coordinate re-derives the new name's shortname via fs.Named so the peer's
  NameEngine mapping is fresh and stable; MetadataPathsFor(np, hostPath) surfaces the
  sidecars the peer must re-stat (host->store conversion + ForkContainers) for the
  deferred wire-push slice.

Wire push (AFP attention / SMB CHANGE_NOTIFY) stays DEFERRED — this lands the
in-memory metadata + shortname consistency and the seam the push will consume.

Tests: fork_containers_test.go (capability present on appledouble per layout,
absent/nil for ride-with-file adapters, forwarded through shareFS);
reactor_coord_test.go (MetadataPathsFor host->store + outside/no-FS nil; coordinate
re-derives a stable 8.3 shortname on foreign rename; nil-FS no-op).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Updates the TinyGo amd64 build gate from 0.34.0. No Go-version conflict
(0.41.0 supports the go.mod 1.23.0); the gate script has no separate pin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pgodwin and others added 25 commits August 29, 2026 18:52
…or (P1)

adapter/link/framing/localtalk.go kept its own ddpChecksum copy only because
core/protocol/ddp's checksum was unexported. Export it as ddp.Checksum and
have stampChecksum call it directly.

Added a characterization test (TestLocalTalk_CalcChecksumStampsWire) first:
CalcChecksum/stampChecksum had zero test coverage before this change (no
test in the package ever set CalcChecksum true), so the refactor is now
provably behavior-preserving rather than unverified.
client.go, clientfileops.go, namespace.go, ncp.go, and sap.go each hand-rolled
their own BE16/BE32/LE16/LE32 append (and, in clientfileops.go, decode)
helpers, byte-for-byte duplicates of core/binaryprimitives.AppendBE16/32,
AppendLE16/32, and BE16/BE32. Deleted the local appendBE16/appendBE32/
beU16b/appendLE16/appendLE32/be16/be32 helpers and repointed every call site
at binaryprimitives, including the scattered inline byte(v>>8), byte(v)
header-encoding in client.go/ncp.go/sap.go.

Added core/protocol/ncp/namespace_test.go first: ParseHPath, HPath.BaseHandle,
and DirEntryInfo.MarshalDirInfo had no dedicated protocol-layer test (only
indirect happy-path coverage via core/service/ncp/namespace_test.go), so the
error paths (truncated NW_HPATH, overrunning component lengths) and the
exact per-field wire layout MarshalDirInfo emits are now pinned before the
encode helpers underneath them changed.
core/csnet already had these from P0; round out the packages an
external developer reaches for first: ddp (encode/decode round trip),
nbp (lookup/reply build+parse), client/link (ParseSpec, RandomMAC),
and client/atalk (Addr formatting, Endpoint Send/receive over a
loopback DatagramLink). These double as compiler-checked usage
samples and as tests (go test verifies the // Output: comments).
A standalone AFP file server: one AppleTalk node, no router services
(no RTMP/ZIP, no compose/registry), serving a single read-only
in-memory volume built entirely in Go. Demonstrates the three things
an embedder needs: implementing fs.FileSystem from scratch and
registering it via fs.RegisterFS (memfs.go), wiring router+NBP+AFP by
hand (main.go), and opening a link transport the same way
compose/registry does but without the config.Section plumbing
(transport.go, plus a pcap-tagged variant / no-pcap stub).

Builds clean under both the default and -tags pcap; runs end-to-end
(binds LToUDP, starts AFP, shuts down on SIGINT) verified locally.
NBP name resolution against it could not be exercised end-to-end in
this sandbox (UDP multicast loopback between two processes doesn't
work here at all, even for a trivial two-line send/receive using the
existing ltoudp adapter with no custom code) — the wiring itself
matches compose/registry/reg_localtalk.go's production pattern
exactly; live verification against a real Mac/emulator is still
worth doing per the plan's verification section.
main.go.bak was a sed(1) backup left over from local testing; not
meant to be tracked.
Add "Extending ClassicStack — the server SDK" (§6, renumbering
Credits to §7), parallel in shape to §5's client-SDK section: a
package map, a walkthrough of examples/memfs-afp-server's three
pieces (FileSystem-from-scratch, hand-wired transport, hand-wired
router/NBP/AFP), and the SetZone gotcha the example's own comments
warn about. Cross-link the new example from README.md.
Pre-1.0, so no compatibility guarantee applies yet, but the P0/P1
work already landed touches public API (ddp.Checksum export) that a
downstream importer of core/protocol/* would want announced rather
than silently discovered. Backfills entries for v0.1.0-v0.3.0 as
placeholders (no prior changelog existed) and records the Unreleased
P0/P1/P1b changes so far.
docs.yml now installs gomarkdoc and runs it over the packages meant
for external reuse (core/protocol/..., core/service/..., client/...,
plus the shared core/{csnet,binaryprimitives,config,fs,encoding,link,
buf} packages — adapter/* and cmd/* excluded as internal composition/
CLI layer), writing one page per package into site/content/reference/
with a minimal injected Hugo frontmatter. Also widen the workflow's
trigger paths to those source trees so the reference regenerates when
they change, and point Set up Go at the root go.mod (was site/go.mod)
so `go list`/`go install` run against the real module.

The generated pages aren't committed (gitignored, apart from the
hand-written _index.md landing page) — verified locally end-to-end:
ran the exact script, then `hugo --minify --gc` against the real repo
content, and confirmed the reference pages render with correct
per-package titles.
release-main.yml's build-embedded job (Pico/Pico W/Pico 2/Pico 2 W +
best-effort WT32-ETH01) had its outputs pulled into the release by
the release job's blanket download-artifact (no name: filter), so
every tagged release shipped embedded .uf2/.bin zips alongside the
desktop builds. Compile validation for these targets already runs
independently on every PR (pr-ci.yml's own build-embedded job, plus
refactor-harness.yml's tinygo-gate.sh), so dropping the job from the
release workflow loses no CI signal -- only the artifact attachment.

Deleted the job entirely (removing it from needs: alone would still
let it run and upload in the same workflow run, given the blanket
download-artifact) and dropped it from the release job's needs: list.
The tag-only trigger gating (a separate part of this plan item) was
already correct -- release-main.yml only runs on push: tags: ['v*'],
and compute-release-metadata.sh fails closed on a non-tag ref -- so
no change was needed there.
The Wireshark Lua dissectors (etherdfs/ltoudp/macipx/mactcp/netboot)
existed but were never packaged into any release artifact.

- Windows installer: new opt-in "wiresharklua" task (unchecked by
  default, matching the "tray" task's pattern), copying *.lua to this
  user's Wireshark "Personal Lua Plugins" folder
  (%APPDATA%\Wireshark\plugins) via a Tasks-guarded [Files] entry --
  no [Code] logic needed. Untestable here (ISCC is Windows-only);
  reviewed carefully against the file's existing conventions.
- All other platforms (plus the plain Windows zip, which shares this
  script): unconditional tools/ folder in the release archive,
  alongside the existing README/server.toml.example/extmap.conf copy.
  Verified locally end-to-end for all three packaging paths (Linux
  tar.gz, macOS zip, Windows zip via pwsh) -- each archive now
  contains a tools/ directory with all five .lua files.
expandStuffItClassic was a stub that always returned
ErrUnsupportedFormat -- every .sit file routed through the Finder
web UI's "expand archive" feature has always failed, despite
Sniff/expandStuffIt doing real signature detection first. isStuffIt
was also checking the wrong magic bytes for StuffIt 5 (SIT5/SITD
instead of the real "StuffIt (c)1997-..." banner), so real SIT5
archives weren't even recognised by content.

- adapter/archive/stuffit.go: rewritten to parse and extract via
  github.com/ObsoleteMadness/StuffIt-Go/stuffit, entirely in memory
  (stuffit.NewParser over a bytes.Reader, no temp file). A small
  ForkWriter/DirectoryWriter adapter (stuffitSink) feeds entries into
  the shared tree builder. isStuffIt now matches the real "SIT!" and
  "StuffIt (c)1997-" signatures.
- adapter/archive/tree.go (new): the flat-path-to-nested-[]Node
  assembly zip.go already had (ensureParentDirs/buildZipTree) pulled
  out into a shared treeBuilder, so stuffit.go doesn't grow a second
  copy. zip.go now builds its tree through the same type. One
  behavioral side effect of sharing the logic: addDir now backfills
  ancestor directories for an explicitly-listed empty directory entry
  too (the original zip-only ensureParentDirs call was skipped on
  that path), which only ever matters for a zip with a nested empty
  directory and no explicit parent entries -- a strict fix, not a
  behavior change anything could have depended on.
- Test-first per the working agreement: adapter/archive had zero
  tests before this change. Added stuffit_test.go against four real
  archives (two classic SIT!, two StuffIt 5/Arsenic, one nested) from
  the classicstack-web submodule's already-vetted StuffIt fixtures --
  first capturing the "always ErrUnsupportedFormat" baseline against
  the stub, confirmed passing, then flipped to assert the real
  extracted tree once the fix landed. Also added zip_test.go (nested
  dirs, AppleDouble sidecar merge) since this change touched zip.go's
  internals directly.
- No docs mention StuffIt as unsupported/TODO anywhere in this repo
  (checked) -- the plan's doc-update item is a no-op here.

go.mod: manual edit (not `go mod tidy`) to move the new dependency
into the direct require block -- tidy itself errors out on an
unrelated, pre-existing hardware/peripherals/sdcard TinyGo package
resolution issue and would have made unrelated changes trying to
route around it.
Pre-existing bug carried over verbatim from the original zip.go
buildZipTree/roots loop this session's earlier refactor (P1e)
generalized into tree.go: the outer loop iterated every entry in
b.dirs — nested paths included — as a potential root, relying on a
`seen` map populated only by a PARENT's own recursive build to skip
children. Since map iteration order is undefined, a nested dir (e.g.
"a/b") visited before its parent ("a") had nothing in `seen` yet, so
it was wrongly emitted as its own top-level root; when "a" was
processed later it built "b" again, correctly, as a real child --
net result, a duplicate/misplaced node, nondeterministically (~1 in
6 runs on the two-level fixture in zip_test.go).

Caught by TestExpandZip_NestedDirs (added in the P1e commit) failing
intermittently under `go test -count=N`. Fix: the outer loop now
skips any path containing "/" outright -- only a genuine top-level
directory is ever a root, exactly mirroring the existing top-level
filter on the files loop just below it. Verified with 50 repeated
runs (`go test -count=50`), all clean.
core/service/afp/parms.go redefined its own fdBitmap*/fileBitmap*/
dirBitmap* constants -- an exact byte-for-byte duplicate (same names
minus casing, same bit values) of core/protocol/afp's already-exported
FDBitmap*/FileBitmap*/DirBitmap* constants in afp.go. Per the plan's
staged approach (constants first as the near-zero-risk half of the
AFP DTO-duplication cleanup, struct unification separately since
that's the higher-blast-radius half): deleted the private const block
and repointed every one of its 18 usages, across 11 files, at the
protocol package (aliased `protocol`, matching the
core/service/smb -> core/protocol/smb convention already used
elsewhere in this codebase, since core/protocol/afp and
core/service/afp share the plain package name "afp").

Careful about the false-positive collision here: catsearch.go/
handlers.go/dispatch_test.go also have LOCAL VARIABLES literally
named `fileBitmap`/`dirBitmap` (the request-parsed runtime values,
completely unrelated to the exported constants of almost the same
name) -- confirmed the replacement only touched the 18 specific full
constant identifiers (fdBitmapAttributes, fileBitmapFileNum, etc.),
never the bare local-variable names, before running it.

No new tests added for this pass: the existing service/afp test suite
(parms_test.go, catsearch_test.go, dispatch_test.go, golden_test.go,
forkio_test.go, and the rest -- 109 passing subtests) already
exercises every bitmap path this touches and is the safety net a pure
constant-value rename needs; nothing here changes behavior. Full
build/vet/test/archtest swept clean after.
core/service/afp.ServerInfo (handlers.go) and core/protocol/afp.ServerInfo
(srvrinfo.go) were field-for-field identical -- ServerName, MachineType,
AFPVersions, UAMs, Flags -- one for the server's FPGetSrvrInfo pack side
(serverInfoBlock), one for the client's parse side (ParseServerInfo).
No server-only fields to carry over, and nothing in the package builds
it via a positional/unkeyed struct literal (checked), so this unifies
as a plain type alias rather than a deeper merge: `type ServerInfo =
protocol.ServerInfo`. External callers are unaffected -- grepped the
whole repo; nothing outside core/service/afp itself references
afp.ServerInfo or SetServerInfo, and an alias is source-compatible
with the old type's zero value/field-assignment usage anyway.

Also folded the duplicate srvrInfoSupportsSrvrMsg flag constant (same
0x0008 value as the already-exported protocol.SrvrInfoSupportsSrvrMsg)
into its two call sites (afp.go, message_test.go).

serverInfoBlock's marshal logic stays in core/service/afp as-is --
it's genuinely server-only (core/protocol/afp has no corresponding
Marshal, only ParseServerInfo), so there's no duplicate there to
fold; moving it into the protocol package would be a bigger change
than this DTO-unification pass calls for.

Existing coverage (109 subtests across parms_test.go, message_test.go,
dispatch_test.go, golden_test.go, conn_test.go) already exercised
every path this touches; ran it before and after as the safety net
for what is otherwise a pure rename with no behavior change.
…rt 3)

Per the middle-ground approach: rather than forcing a full merge of
each pair (the server side packs/parses live against Volume state
with no equivalent typed struct on the client side -- a materially
bigger, riskier rewrite of the hottest AFP wire paths for what turned
out to be a small amount of true duplication once inspected closely),
pull out just the genuinely shared header shape each pair opens with
and have both sides embed it. Params/Entries bodies and the
PathType/name handling (which really do differ -- MoveAndRenameRequest
sends one shared PathType across all three names, FPMoveAndRenameReq
parses three independent ones off the wire) are untouched.

- New core/protocol/afp.FileDirBitmaps{FileBitmap, DirBitmap} with a
  Marshal helper, embedded by GetFileDirParmsReply and EnumerateReply
  (client-parse side) and by core/service/afp's FPGetFileDirParmsRes
  and FPEnumerateRes (server-marshal side, now calling
  FileDirBitmaps.Marshal instead of hand-rolling the same two
  AppendBE16 calls).
- New core/protocol/afp.MoveHeader{VolID, SrcDirID, DstDirID},
  embedded by MoveAndRenameRequest (client) and FPMoveAndRenameReq
  (server) -- the server's field was previously named VolumeID;
  renamed to VolID (nothing else read it, confirmed by grep) so both
  sides share one name too, not just one shape.

Every construction site updated to the nested-embed literal form
(core/protocol/afp/commands.go, core/service/afp/handlers.go +
models_test.go, client/afp/filesystem.go). Verified byte-exact:
the existing golden tests (TestFPGetFileDirParmsRes_Header,
TestFPEnumerateRes_Header/_MarshalGolden, TestFileDirParmsHeader_*)
all still pass unchanged, plus the full 109-subtest service/afp suite,
core/protocol/afp, and client/afp. Full repo build/vet/test/archtest
swept clean.
zip.go and stuffit.go already got coverage in the P1e StuffIt work;
archive.go (the Sniff/Expand dispatch), binhex.go, and macbinary.go
had none. Tests use real samples already present via the
classicstack-web submodule (a real BinHex-4.0-wrapped GIF, a real
MacBinary-wrapped StuffIt download) rather than synthetic wrapper
bytes, so they exercise the actual decoders end-to-end -- probed each
sample first (a throwaway local test, discarded) to get the real
expected Name/fork-length/FinderInfo values before writing assertions
against them, rather than asserting whatever the code happened to
currently produce.

- binhex_test.go: looksBinHex detection + rejection, expandBinHex
  against the real sample (checks the decoded GIF8 magic), a
  synthetic corrupt-but-plausible input, and unsupported input.
- macbinary_test.go: isMacBinary detection + rejection (including a
  one-byte-short header and a zip-signature false lead), expandMacBinary
  against the real sample (which is itself a MacBinary-wrapped StuffIt
  archive -- checks the unwrapped SIT! magic and the FinderInfo
  type/creator), truncated and unsupported input.
- archive_test.go: Sniff's three independent paths (extension, Finder
  type code, content sniff) and its false case; Expand's dispatch to
  all four formats through the one public entry point; the
  unsupported-format case; and a corrupt-zip case confirming Expand
  propagates ErrCorrupt directly rather than falling through the
  other three decoders and masking it as ErrUnsupportedFormat.

Verified with `go test -count=20`, no flakes. Full repo build/vet/test
swept clean after.
This package (checksum/fragmentation/NAT engine for the MacIP
gateway) had zero tests.

- iputil_test.go: RawChecksum against an independently-verified
  worked example plus the self-verify and odd-length-padding
  properties; TransportChecksum against a by-hand pseudo-header
  computation plus its own self-verify property; BuildIPv4Packet's
  fixed fields and checksum; FragmentIPv4's single-piece passthrough,
  DF-bit refusal, offset/MF-flag/8-byte-alignment chaining across a
  real multi-fragment split with payload reassembly, and the
  too-short-to-parse guard.
- osnat_test.go: allocICMPNatID's uniqueness, used-id skipping, and
  wraparound; Forward's drop paths (too-short packet, unsupported
  protocol, ICMP with no raw socket available) via a directly
  constructed *OSNAT that bypasses New()'s real ICMP socket and
  background goroutines entirely; and the three TCP segment-building
  helpers (sendTCPSYNACK/sendTCPSegment/sendTCPRST) against a
  directly-constructed flow, checking ports/seq/ack/flags/payload/MSS
  option and a self-verifying IP checksum.

osnat.go's actual flow-handling logic (forwardICMP/forwardUDP/
handleTCP's live-socket paths) is intentionally not covered here --
it's built entirely around real OS sockets (net.DialUDP, TCP dial,
a raw ICMP socket) with no injection seam, and adding one would be a
production-code redesign beyond what a baseline-coverage pass calls
for. What's covered is everything reachable without a real socket:
every pure helper, the dispatch/guard logic, and the packet-building
that's actually exercised by the TCP write-back paths.

Verified with `go test -race -count=5`, no flakes. Full repo
build/vet/test swept clean after.
adapter/control/ubus had no _test.go of its own -- it did have real
indirect coverage via adapter/control/parity_test.go (Status,
Subscribe, Start, Config, ListFSTypes, ListZones, Users CRUD), but
Stop/Restart/HostInfo/ShareBackends/ParamsFor/Reconfigure/AddInstance/
RemoveInstance/Save/ListInterfaces/SetInterface/RemoveInterface, the
four DiagProvider-backed drill-downs, and the wire-protocol edge
cases (unknown method, malformed JSON) were untouched.

Built on the same real-Plane-over-real-Supervisor pattern
parity_test.go already established (control.New + supervisor.New)
rather than hand-writing a fake against control.Plane's large
interface, using a real Unix domain socket in t.TempDir() throughout.

Two real findings surfaced while writing these (not bugs -- both are
now documented in the test comments rather than "fixed" away):
- AdapterClient.Config() can't round-trip a model whose Sections/Lists
  actually hold populated config.Section values -- plain encoding/json
  can't unmarshal into an interface with no concrete type to target.
  Tests that need to observe a mutation verify server-side against the
  live *config.Model instead (safe: the RPC response is only sent
  after the handler's mutation completes, so there's a real
  happens-before by the time the client call returns).
- port.Section implements config.NamedSection, so Reconfigure on one
  routes it through Model.Lists, not Model.Sections (setSectionLocked's
  documented NamedSection branch) -- TestReconfigure follows that
  real routing rather than asserting a singleton path a NamedSection
  never takes.

Verified with `go test -race -count=10`, no flakes. Full repo
build/vet/test swept clean after.
The only untested pair in core/protocol/core/service per the plan's
survey (every other protocol/service pair already had coverage).

- core/protocol/rip/rip_test.go: Marshal/Unmarshal round trip pinned
  to exact wire bytes, the append-style contract, a bare (no-entry)
  packet both directions, the too-short-for-a-header rejection, and
  the trailing-partial-entry-ignored tolerance (Ethernet minimum-frame
  padding).
- core/service/rip/rip_test.go: a fakeSender records/channels every
  IPXSender.Send call, so the Responder's actual behavior is checked
  without a real IPX network. Covers SetNetworks' zero-entry filter;
  HandleDatagram answering an owned network, the wildcard matching
  every owned network, silently ignoring an unowned network or a
  Response packet (this responder doesn't learn routes), and safely
  dropping nil/malformed input; Start's immediate directly-served
  broadcast and Stop's shutdown broadcast at HopsUnreachable, both to
  the IPX broadcast address; Start/Stop idempotency (no panic on a
  second Stop's channel double-close); and the no-owned-networks
  no-op case.

Verified with `go test -race -count=15` across both packages, no
flakes. Full repo build/vet/test/archtest swept clean after.
client/trace is process-wide singleton state (one shared stderr sink,
a muted-scope set, an extra-sinks slice) driving every client
transport's wire narration, and had zero tests.

Covers: SetVerbose toggling Verbose()/a Logger's Trace-enabled state;
SetScope muting one named scope independently of the global verbose
toggle (and restoring it); the documented AddSink contract that an
extra sink's own threshold can capture records even while the shared
verbose toggle is off (client log files keep working with -v absent);
AddSink(nil) being a safe no-op rather than a future nil-pointer panic
on Write; CloseExtraSinks actually closing sinks and stopping the
fan-out to them; and SetLevel driving the same shared threshold
SetVerbose does, at an arbitrary level.

Since this is real global mutable state shared across every test in
the package, each test resets it via t.Cleanup (SetVerbose(false) +
CloseExtraSinks) and uses a scope name unique to itself, so ordering
never matters -- verified with `go test -race -count=10 -shuffle=on`,
no flakes. Full repo build/vet/test swept clean after.
Per the plan's own investigation of this package (a documented,
correctly-scoped compatibility shim over client/link with no
independent protocol logic): just the flag-binding surface, low
priority. Covers Flags' defaults and argument parsing, plus Open's
one piece of real logic -- srcNode 0 with Claim false must error
rather than silently asserting the invalid node 0.

This closes out P3 (baseline tests for previously-untested packages):
adapter/archive, adapter/macipgw/nat, adapter/control/ubus,
core/protocol/rip + core/service/rip, client/trace, and this package.
The capability-tag table (afp/smb/pcap/webui/...) was already
well-maintained; the embedded-target tags (tinygo/pico/picow/pico2/
pico2w/esp32/wt32eth01) were only a one-line aside pointing at a
design charter, not documented as a family a reader could act on.

Added a real "Embedded target tags" subsection: what core/tinygo
means as the implicit umbrella tag (and why there's no separate
"embedded" tag -- Go has no tag-alias mechanism), the pico/pico2/
picow/pico2w relationship to TinyGo's -target vs -tags split, the
esp32/wt32eth01 scope including the documented ESP-IDF linking gap,
and the concrete build/CI entry points (scripts/build_pico.sh,
scripts/build_wt32eth01.sh, make tinygo-gate). Kept a pointer to
.refactor/00-DESIGN.md for the deeper design rationale rather than
duplicating it.

Also pointed core/csnet's doc comment at the new table, since it's
the package whose !tinygo/tinygo split this documents most directly.

Verified: `hugo --minify --gc` renders the updated page cleanly.
core/auth/cred.go carried a stale `// TODO: Move this to are shared
binaryprimitives` comment that directly contradicted the file's own
design-rationale paragraph above it (which already claimed hex coding
"stays hand-rolled below regardless") -- and the TODO was right: this
was real, not just theoretical, duplication. core/csnet/
macparse_tinygo.go had a byte-for-byte identical hexNibble function
(confirmed before touching anything).

- New core/binaryprimitives/hex.go: EncodeHex/DecodeHex/HexNibble,
  test-first (hex_test.go), matching the existing PutBE16-style
  package conventions.
- core/auth/cred.go: SaltHex/HashHex/ParseCredential now call
  bp.EncodeHex/DecodeHex; deleted the local encodeHex/decodeHex/
  hexNibble/hexDigits and the stale TODO. Updated the design-rationale
  comment to match. cred_test.go's TestHexRoundTrip (now redundant
  with core/binaryprimitives' own tests) removed; TestPBKDF2SHA256Vector
  repointed to bp.EncodeHex.
- core/csnet/macparse_tinygo.go: ParseMAC's decode loop now calls
  bp.HexNibble instead of its own copy.
- core/csnet/mac.go's FormatMAC keeps its own inline hex encoding --
  genuinely different shape (upper-case, colon-separated), not a
  duplicate of EncodeHex's plain lower-case form, so left as-is rather
  than forced into the same helper.
- Fixed the identical stale "hex coding stays hand-rolled... matching
  core/binaryprimitives' style" comment in cmd/cs-tinygo/main.go's
  M8a note, found while touching this.

core/csnet/macparse_tinygo.go is tinygo-only (a normal `go build`
never compiles it); verified with a real
`GOOS=linux GOARCH=amd64 tinygo build ./cmd/cs-tinygo` (which
transitively reaches core/csnet per `go list -tags tinygo -deps`),
exit 0. Full repo build/vet/test/archtest swept clean otherwise (one
TestElectionWon flake in core/service/browser under full-suite load,
confirmed pre-existing and unrelated -- passes 5/5 in isolation and
clean on a full-suite rerun).
CLAUDE.md described a flat pre-restructuring layout (internal/app as
the run-core, top-level appletalk/router/port/service/pkg/config
directories, koanf+go-toml config parsing, [LToUdp]/[TashTalk]/
[Volumes.*] TOML sections) that predates the current five-ring
core/adapter/compose/client/cmd structure ARCHITECTURE.md already
documents accurately and keeps current -- contributors reading
CLAUDE.md first were getting conflicting guidance.

Replaced the stale "Core Data Flow"/"Key Packages" section with a
short accurate summary of each ring plus a strong pointer to
ARCHITECTURE.md as the authoritative source, rather than hand-
maintaining a second package table that would just go stale again.
Also fixed: the go test example path (./service/afp/... ->
./core/service/afp/...), the CNID-tracking claim (defaults to
in-memory, not SQLite -- sqlite is an opt-in build tag), the
config-parsing claim (koanf isn't even in go.mod anymore; go-toml/v2
only) and its TOML section names (checked against the real
server.toml.example: [[afpvolumes]], [[smbshares]], [MacIP], etc.,
not the stale [AFP]/[Volumes.*]/[LToUdp] spellings), and the
hardcoded Go version (now points at go.mod instead of duplicating a
number that will drift again). Also removed an accidental duplicated
"### AFP Architecture" heading from the edit.

Verified every cited path/file exists, and that the two example
commands (go build .../cmd/classicstack, go test .../core/service/afp/...)
actually work. Docs-only change; full repo build/vet/test swept clean
(one core/service/browser flake under full-suite load on the first
pass, gone on rerun -- confirmed pre-existing/unrelated, this commit
touches only CLAUDE.md).

This closes out P5's items worth doing now: the P0 csconnect
stale-comment note turned out already resolved by P0 itself, and the
runport.go/ntcreate.go TODOs are deliberately-deferred design
decisions the plan says to leave alone. The diagnostic-tool shared-
flags helper remains open (cosmetic-only, no bug) if wanted later.
…s (P5)

The 8 AppleTalk/IPX diagnostic probes (csecho, csgetzones, csnbp, cspap,
csipxping, csncpinfo, csnetsend, csnetview) each hand-rolled their own
flag.* calls for the same option groups, with drifting help text and
inconsistent coverage: -v and -ifacetype existed on some tools but not
others. New package cmd/internal/diagflags gives each group (Common:
-v/-version; LLAPSource: -net/-src + range validation; and the
per-flag Iface/IfaceType/MAC/ListIfaces registrars) a single definition
that all 8 commands now call, instead of retyping flag definitions and
validation logic by hand in each main.go.

Consistency fixes made possible by unifying the registration:
- -v/-version are now present on every probe, including csipxping and
  csncpinfo, which previously had neither; both gained real (not
  cosmetic) stderr trace lines on send/receive so the new flag isn't a
  no-op.
- -net/-src help text and the src-node range check (0, or 1..254) are
  now identical across csecho/csgetzones/csnbp/cspap; cspap's slightly
  different validation expression was equivalent but is now literally
  shared code, not just equivalent code.
- The AppleTalk transport flags (-transport/-iface/-device/-baud
  /-list-ifaces/-claim) stay owned by cmd/internal/atlink, which this
  package composes alongside rather than duplicates.

No functional change to existing flag names/behavior for the 7 flags
that already existed; -v is new for csipxping/csncpinfo, matching the
plan's stated allowance to rename/extend options for consistency
without preserving exact prior surface.

Verified: go build/vet -tags "all pcap" ./..., go test -tags all
-count=1 ./..., go test -tags all ./core/internal/archtest/...,
tinygo build ./cmd/cs-tinygo (unaffected — cmd/ is outside the
TinyGo blank-import set, checked per the session's verification bar
anyway), plus a manual run of all 8 built binaries' -version/-h/
-list-ifaces and a bad -src validation error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@pgodwin

pgodwin commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Architecture review remediation (P0–P5)

Ran a full architecture/code-quality review against this branch ahead of 1.0 and worked through the prioritized findings, test-first, one commit per fix:

  • P0 — new core/csnet package: a single shared MAC/IP address implementation (parse, format, random-MAC) replacing 4 separate hand-rolled parsers, with a !tinygo/tinygo build-tag split matching the existing core/buf convention.
  • P1 — exported ddp.Checksum (deleted the adapter's mirror copy); core/protocol/ncp now uses core/binaryprimitives instead of hand-rolled byte-order helpers.
  • P1b — library consumability for external Go consumers: ExampleXxx functions on the core reuse-surface packages, a new "Extending ClassicStack — server SDK" doc section + runnable examples/memfs-afp-server, CHANGELOG.md, and a gomarkdoc-generated API reference wired into the docs site build.
  • P1c — release engineering: dropped the embedded/TinyGo build job from the tagged-release workflow (still fully covered by PR CI; just no longer shipped as a release artifact).
  • P1d — packaged the Wireshark Lua dissectors (tools/wireshark/*.lua) into release archives and as an opt-in Windows installer task.
  • P1e — StuffIt archive expansion: replaced a stub that always returned ErrUnsupportedFormat with a real implementation on StuffIt-Go.
  • P2 — AFP DTO duplication between core/protocol/afp (client) and core/service/afp (server): deduped bitmap constants, aliased ServerInfo, and unified the shared reply/request header shapes (FileDirBitmaps, MoveHeader) after confirming the deeper structs are genuinely different (live-packed vs. typed marshal), not just cosmetic duplicates.
  • P3 — baseline tests added for every previously-untested package (adapter/archive, adapter/macipgw/nat, adapter/control/ubus, core/protocol/rip+core/service/rip, client/trace, cmd/internal/atlink), surfacing and fixing one real pre-existing bug along the way (nondeterministic root-detection in the archive tree builder).
  • P4 — documented the embedded-target build-tag family (tinygo/pico/picow/esp32/wt32eth01) in docs/build.md.
  • P5 — moved core/auth's hex codec onto core/binaryprimitives (closing a stale TODO that was real duplication, not just dead comment); refreshed the root CLAUDE.md's stale pre-restructuring architecture description; and added cmd/internal/diagflags, a shared CLI-flag implementation for the 8 AppleTalk/IPX diagnostic probes (csecho, csgetzones, csnbp, cspap, csipxping, csncpinfo, csnetsend, csnetview) — -v/-version are now present and consistent on every one (including two that previously had neither), and -net/-src/-iface/-mac/-list-ifaces/-ifacetype are each defined once instead of retyped per command.

Every change was verified with go build/go vet/go test -race across the full module, core/internal/archtest.TestCoreImportGraph, and a real tinygo build for any core-ring or TinyGo-tagged change.

…parsing

localtalk_tinygo.go's openLToUDP/openTashTalk stubs still had the pre-claim
signatures (2 return values, no claim bool), while localtalk.go had grown a
claimed-node return value and claim parameter — broke the TinyGo/Pico build.

core/csnet.ParseMAC's doc comment promised bare-hex support ("001122AABBCC")
but delegated straight to net.ParseMAC, which has no separator-less form —
failing tests in every package that converges on csnet.ParseMAC (macipgw,
csconnect, core/port, adapter/control/finder). Added an explicit 12-hex-digit
fallback.
cmd/cs-tinygo (the amd64 gate target) never imports client/link, so it
missed the localtalk.go/localtalk_tinygo.go signature drift that broke
PR #20's separate "Build Embedded (TinyGo)" job. hardware/pico does pull
in client/link, so building it here closes that gap for `make tinygo-gate`.
…rors

golangci-lint's errorlint linter caps identical-message output at 3, so
the CI Quality job only ever surfaced 3 of the 8 actual violations across
these four test files, leaving the other 5 to fail on the next pass.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev builds of Wireshark 4.7 have full MacIP support with IP GP config packet dissection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants