summaryrefslogtreecommitdiff
path: root/svc/doc
diff options
context:
space:
mode:
authorCalvin <calvinm@kissingerassoc.com>2026-08-16 20:52:12 -0400
committerCalvin <calvinm@kissingerassoc.com>2026-08-16 20:52:12 -0400
commit147b0ca1e4e00f66abb47fff3c543b853033f609 (patch)
treea388172fc16723c139deff1e45a74c3d1c567cc0 /svc/doc
Initial commit: svc supervisor, design docs, session transcript
Existing work moved from /storage/vms/9front/svc, previously unversioned. Object files and linked binaries excluded via .gitignore.
Diffstat (limited to 'svc/doc')
-rw-r--r--svc/doc/design.md437
-rw-r--r--svc/doc/inventory.md113
-rw-r--r--svc/doc/todo.md101
3 files changed, 651 insertions, 0 deletions
diff --git a/svc/doc/design.md b/svc/doc/design.md
new file mode 100644
index 0000000..343d23a
--- /dev/null
+++ b/svc/doc/design.md
@@ -0,0 +1,437 @@
+# init
+
+**init is the service manager. It provides svcfs if you want to interact with services.**
+
+Status: design. Nothing implemented yet.
+
+## Why
+
+A machine is a set of running services. That's all it is. 9front instead has
+three half-mechanisms that each know a piece of that and never talk to each
+other.
+
+`service=` in `plan9.ini` picks between `/rc/bin/termrc` and `/rc/bin/cpurc`.
+One word decides "what kind of machine is this."
+
+`/rc/bin/service/` is a directory of scripts named by port, where the enable bit
+is a `!` prefix on the filename:
+
+ /rc/bin/service/!tcp23 disabled
+ /rc/bin/service/tcp17019 enabled
+
+Everything else is a bare line in a shell script — `plumber`, `webfs`, `ndb/cs`,
+`ndb/dns`, `aux/timesync` — started once, owned by nobody, restarted never.
+
+So: nothing is supervised, nothing has state, nothing has logs, nothing has
+dependencies, and nothing can be managed remotely without a shell and an editor.
+
+A real example from this project. The serial control shell was one line in
+`termrc`, non-interactive:
+
+ rc </dev/eia0 >/dev/eia0 >[2=1] &
+
+One malformed command produced a syntax error, rc exited, and the machine was
+unreachable over serial for the rest of the session. The fix was to make it
+interactive and wrap it in a restart loop:
+
+ while(){ rc -i </dev/eia0 >/dev/eia0 >[2=1]; sleep 1 }&
+
+That line is a service manager for exactly one service. Every daemon on the box
+wants the same thing and none of them get it.
+
+## Roles are vocabulary
+
+"Terminal", "cpu server", "auth server", "file server" are not kernel concepts.
+The kernel does not know which one it is running. Each is a bundle of userspace
+programs:
+
+| Role | What it actually is |
+|---|---|
+| auth server | `auth/keyfs` + `auth/authsrv` on 567 |
+| cpu server | a listener on 17010, plus `exportfs` |
+| file server | `hjfs`/`cwfs`/`gefs` + a 9P listener on 564 |
+| terminal | a window system, if you want one |
+
+Nothing ever stopped one machine being all four. The words are the obstacle:
+they imply a machine *is* one thing, and that exactly one machine holds each
+role in a single administrative domain.
+
+We delete the words. Role is emergent and plural.
+
+## How it works
+
+`init` runs services. It:
+
+1. reads service definitions from `/lib/svc`
+2. starts the enabled ones in dependency order
+3. supervises them, restarting per policy
+4. serves `svcfs` — posted at `/srv/svc`, mounted at `/mnt/svcs`
+5. handles `reset` and `reexec`
+
+None of that depends on how it was started, and an instance can be run by
+anyone over any directory of services in its own namespace. Run as the program
+that brings a machine up, it does four more things: builds the base namespace,
+offers a rescue console, halts the root file server last, and carries out
+`halt` and `reboot`.
+
+**init is not pid 1 on 9front today.** Pid 1 is `rc` running `/bin/bootrc`,
+sitting in `Await`; init is an ordinary child — pid 143 on the machine this was
+checked against. Since the boot chain is being replaced anyway, whether init
+runs as pid 1 is a decision rather than an inherited fact, and nothing in this
+design requires it.
+
+`svcfs` is a namespace, not a program. There is no second daemon. It's what init
+looks like from outside, the same way `/proc` is what the kernel's process table
+looks like from outside.
+
+**The control plane is optional.** Services start at boot whether or not anything
+ever mounts `/mnt/svcs`. If posting the filesystem fails, init logs it and keeps
+supervising. A bug in the 9P layer must never be a boot failure.
+
+## A service
+
+`/lib/svc/dns`, in ndb format — the parser already exists and it's the same
+shape as factotum keys and `/lib/ndb/local`:
+
+ svc=dns
+ exec=/bin/ndb/dns
+ args=-r
+ needs=cs
+ ready=srv:dns
+ restart=always
+ enable=yes
+
+Ten attributes exist in total: `svc exec args needs ready restart ns user
+enable stop`. There are deliberately no tuning knobs — restart delays and
+readiness timeouts are constants in the code until something proves it needs to
+be configurable.
+
+An unknown attribute is an **error**, not a warning. ndb has no schema, so
+`exce=/bin/ndb/dns` parses fine as ndb and means nothing. Ignoring unknowns is
+how a typo silently does nothing forever.
+
+## Three shapes of service
+
+Not everything is a daemon that stays in the foreground, and the difference is
+declared rather than guessed:
+
+| Shape | Liveness is | On exit |
+|---|---|---|
+| foreground | the pid | restart per policy |
+| detaching | the `/srv` file | not a failure — don't restart |
+| oneshot | nothing, it's done | never restart |
+
+Detaching matters because the `postmountsrv` idiom forks a server proc and lets
+the original exit *on purpose*. A supervisor that assumes exit means death will
+restart-loop something that is running fine.
+
+Which is why **readiness and liveness are separate questions**. `ready=srv:dns`
+answers "is it up yet"; the same file answers "is it still up" for a service
+whose pid is already gone.
+
+## Namespaces and identity
+
+Each service gets `rfork(RFNAMEG)` and a namespace built from its `ns=` file via
+`newns` — the same machinery `cpu` already uses.
+
+This is the part that isn't a systemd transliteration. Two services on one
+machine can hold completely different views of the filesystem: different `/net`,
+different `/srv`, different roots. That is what would let one box participate in
+two grids at once, each with its own `/mnt/factotum` and its own identity —
+impossible today, because a machine has one hostowner, one `authdom` and one
+namespace.
+
+Identity comes from the capability device (`/dev/caphash`, `/dev/capuse`), the
+path `cpu` and `rx` already use, since Plan 9 has no setuid. That means init runs
+as hostowner and is security-critical. Not a detail to discover later.
+
+**Services consume namespaces; they never construct the shared one.** A service
+that runs `bind` inside its own namespace group affects nobody, and would appear
+to work while doing nothing. Base namespace construction belongs to init, before
+any service starts.
+
+## Talking to it
+
+The filesystem is the mechanism, commands are what people type. Both.
+
+ svc list services, their state, and why
+ svc start dns
+ svc restart dns
+ svc log dns
+ halt stop services, halt the fs, power off
+ shutdown alias for halt
+ reboot ... and restart the kernel
+ reset stop services, then start them again
+
+Commands are pure translation — a few lines that open a file and write a word.
+If `svc restart dns` can ever do something a write to `/mnt/svcs/dns/ctl`
+cannot, we've built two interfaces and they will drift.
+
+`reset` is worth naming because it's the cheap one: it's the stopping half
+followed by the starting half, both of which already exist. No new machinery.
+
+## Ctl files say what they accept
+
+The worst thing about Plan 9 ctl files is that they're write-only channels for
+magic strings. `/net/tcp/0/ctl`, `#S/sdctl`, `/dev/mousectl` — the only way to
+learn the verbs is to read source.
+
+So every ctl file here reads back its own command list:
+
+ % cat /mnt/svcs/dns/ctl
+ start
+ stop
+ restart
+ note <string> post a note to the process
+
+and every rejected write names the alternatives:
+
+ % echo frobnicate >/mnt/svcs/ctl
+ echo: write error: unknown command "frobnicate"; try
+ halt reboot reset start stop restart enable disable reexec
+
+This deviates from convention — most Plan 9 ctl files return nothing or return
+state on read. We can afford to spend the read on documentation because `status`
+carries the state. Given that the convention is the complaint, it's the right
+one to break.
+
+## State is ndb too
+
+`/proc/mdstat` on Linux is the canonical disease: positional, bracket-encoded,
+with an ASCII progress bar inside the data. Plan 9 does it too —
+`/net/ipifc/0/status` opens with genuine attribute/value pairs (`maxtu 1514
+sendra 0`) and then collapses into unlabelled positional columns the moment
+there's a list.
+
+So status output is ndb, not columns:
+
+ svc=dns state=running pid=231
+ svc=authsrv state=failed restarts=3 exit='cannot open /adm/keys'
+ svc=listen state=waiting needs=cs
+
+Self-describing, quoting handles the exit string, and fields can be added
+without breaking readers. `svc` with no arguments prints aligned columns for
+humans; the file stays ndb for programs.
+
+Nothing in the tree currently serves ndb — all 15 libndb consumers are readers
+of static config. This would be the first, which also means it shouldn't be
+called a convention until a second, genuinely different server carries it.
+
+## Configuration goes one way
+
+`/lib/svc` is the only source of truth. The filesystem accepts *verbs*, not
+configuration. No writing `exec=` into `/mnt/svcs/dns/args` and having init
+persist it back — that's two paths to one state, and they will disagree. It's
+also how the system already behaves: you configure an interface by writing
+`/net/ipifc/0/ctl`, not by editing `status`.
+
+`reload` re-reads `/lib/svc`, applies what parses, and reports what doesn't:
+
+ % echo reload >/mnt/svcs/ctl
+ echo: write error: /lib/svc/dns:4: unknown attribute "exce"
+
+One bad file never costs you the whole machine — same behaviour at boot.
+
+## Permissions
+
+init is the file server, so it checks the 9P attach identity itself. One
+mechanism covers local writes and remote mounts; there's no separate "is this
+allowed remotely" path to get wrong later.
+
+ /mnt/svcs/ctl 0644 hostowner halt, reboot, reset
+ /mnt/svcs/<n>/ctl 0664 hostowner:user start, stop, restart
+ /mnt/svcs/<n>/status 0444 anyone
+ /mnt/svcs/<n>/log 0640
+
+Note the ctl files are world *readable*. Restricting reads was the first
+thing tried and it immediately broke the rule above — reading a ctl file is
+how you learn what it accepts, which is no use if only one user may do it.
+Writing is the thing that wants restricting.
+
+This isn't cosmetic: if `/srv/svc` is exported, anyone who can mount it can halt
+the machine.
+
+## Shutdown
+
+`fshalt` is the proof that this is needed. It spends ninety lines rediscovering
+the system at shutdown — a hardcoded glob for every file server 9front has
+shipped (`cwfs*cmd`, `hjfs*cmd`, `ext4*cmd`, `gefs*cmd`, `fscons*`), plus
+hardcoded knowledge of each one's stop protocol (`echo halt` for most,
+`echo fsys all halt` and a `sleep 2` for fscons).
+
+Then the strangest part:
+
+ ramfs
+ cp /bin/echo /tmp
+ cp /bin/rc /tmp
+ # put this in a shell function so this rc script doesn't get read
+ # when it's no longer accessible
+ fn x { ... }
+
+It copies its own tools into a ramfs and hides its body in a function so rc
+won't re-read the script — because the shutdown logic lives *on the filesystem
+it is shutting down*. init has none of that problem: it's resident, it's process
+1, and it re-reads nothing.
+
+So shutdown is:
+
+1. stop services in reverse dependency order — `hangup` note, wait, then `kill`
+2. halt the adopted file server
+3. power off or reboot
+
+A wedged service must never stall shutdown, because the thing being protected is
+a clean filesystem. Timeout, hard kill, proceed.
+
+**Adopted services.** The root file server is started by `bootrc` before init
+exists. init isn't its parent, can't `await` it, can't `kill` it — it can only
+stop it the way fshalt does, by writing its `/srv` ctl file. So it's a second
+category: known but not owned, stop-only, always last. Each file server declares
+its own `stop=` rather than init carrying a table of how to kill everyone else.
+
+`fshalt` goes away.
+
+## Logging
+
+Service output is appended to `/log/<name>`. `/mnt/svcs/<name>/log` is a view of
+that file, and a blocking read at EOF gives live tailing.
+
+**Rotation is a non-goal.** Appending is twenty lines; rotation is a subsystem,
+and it isn't going in init. Files grow until something else deals with them.
+
+Two places this doesn't work, both the same problem: there's no filesystem yet,
+or there won't be one shortly. Early boot messages go to console. Once teardown
+starts, logging to disk stops and the rest goes to console.
+
+## Testing without booting
+
+Nothing about supervising services requires being process 1:
+
+ init -d /tmp/svc.test -s svc.test -m /mnt/svcs.test
+
+runs the real init as an ordinary user, supervising toy services, serving a test
+filesystem. No reboot, no risk.
+
+That only works if it's designed in, so it's a rule: **no hardcoded paths, no
+assumption of being pid 1, every path a flag.** The only parts needing a real
+boot are base namespace construction, the rescue console, and adopted-fs
+shutdown — which is the argument for keeping those three thin and separable.
+
+This is where recoverability actually comes from, not from splitting init into
+two programs.
+
+## Implementation
+
+C, and it isn't close: the program that brings a machine up should need nothing but libc at runtime.
+
+- **lib9p** — the file server. `threadpostmountsrv`, fill in a `Srv`.
+- **libndb** — service definitions and status output.
+- **libauth** — `newns`.
+- **libthread** — `threadwaitchan()` hands you a channel of `Waitmsg*`, so the
+ main loop is literally a select over "a child exited" and "a 9P request
+ arrived." The concurrency model matches the problem.
+
+Roughly 1500–2500 lines. Small enough to hold in your head, which for
+something this hard to restart is the point.
+
+Services written in rc are just `exec=/bin/rc args=/bin/foo`. **An rc service
+script must `exec` its final program, not background it** — otherwise init
+supervises rc, rc exits immediately, and the real work is orphaned.
+
+## Getting there
+
+Staged because you need a working machine tomorrow, not because the end state
+keeps any of it. First: init starts `svcfs` and supervises a handful of services
+alongside the existing `termrc`, fully reversible. Then service definitions
+replace `/rc/bin/service/` and the loose daemon lines. Then `termrc`, `cpurc`
+and `service=` are deleted.
+
+## Open questions
+
+- **`reexec` state handover.** Init replacing itself without dropping services
+ means supervision state survives the exec — handed over in memory, or
+ re-derived from `/proc` and `/srv`. The genuinely hard part.
+- **Detaching liveness.** Plan 9 has no file-change notification, so noticing
+ that a detached service's `/srv` file vanished means polling, or only
+ noticing on a `status` read.
+- **Who restarts init?** If init dies, nothing catches it. Confirm `bootrc`
+ honours `init=` in `plan9.ini` — that's the escape hatch for a bad init.
+- **A machine-readable interface description.** An ndb file next to `ctl`
+ describing fields, actions, and which files block, so a generic client can
+ render a UI over any 9P server. Not part of init, but init is a reasonable
+ first carrier — and it shouldn't be frozen until a second server carries it
+ too.
+
+## Namespaces are the capability set
+
+This started as a federation feature and turned out to be the privilege
+mechanism, which is a better justification and changes the defaults.
+
+In Plan 9 there is no access control for devices beyond what is in your
+namespace. If `#S` is not bound, you cannot touch storage — not "you get
+permission denied", but there is no name to open. That is stronger than
+Unix, where `/dev/sda` exists for everyone and mode bits are the only guard.
+
+But termrc binds all nine kernel devices into `/dev` for everything:
+
+ for(i in P S f æ t L A J '$')
+ bind -qa '#'^$i /dev
+
+So today `timesync` — which talks to an NTP server — can reach raw disk and
+PCI config space. `#p` is worse: every process can walk `/proc`, read other
+processes' memory, and post notes to them, so any service can kill init.
+
+### Consequences for the design
+
+**No default namespace.** A service with no namespace declaration is a load
+error, like an unknown attribute. Inheriting by default means granting
+everything by default to services that asked for nothing.
+
+**Ship profiles so explicit is not verbose.** The base is ~40 lines and most
+services want most of it; hand-written namespaces per service would be
+copy-pasted and would drift.
+
+ /lib/ns/net #I #c #e #d — dials out, no disk, no /proc
+ /lib/ns/disk net plus #S
+ /lib/ns/draw net plus #i #m
+ /lib/ns/full everything termrc currently binds
+
+A service says `nsfile=/lib/ns/net` plus optional inline `ns=bind …` lines.
+
+**`full` will be abused, and that is survivable, because it is greppable.**
+`grep -l ns/full /lib/services/*` is a list of over-privileged services, which
+is a work queue. Inheritance produces no such list.
+
+### /srv is a hole
+
+Binding `#s` undoes most of the above. A service that can see `/srv` can
+`mount /srv/boot` and get the whole root filesystem back, write
+`/srv/hjfs.cmd` to halt the machine, or mount factotum and reach keys.
+
+Three layers, in order of strength:
+
+1. **Do not bind `#s`.** The only airtight one — the channels are not nameable.
+2. **`rfork(RFNOMNT)` before exec.** Plan 9's pledge: a one-way drop after
+ which the process cannot mount, bind or unmount. Default on; `nsmount=yes`
+ is the visible opt-out for services that genuinely mount things.
+3. **`user=`.** File permissions on whatever remains.
+
+RFNOMNT is not a wall. It blocks the `mount` call, not a process that opens
+`/srv/foo` and speaks 9P down the fd by hand. It stops accidents and casual
+escalation; only omitting `#s` stops intent.
+
+### Unresolved: how this interacts with everything else
+
+- **Providers of namespaces need mount privilege.** A service that imports a
+ remote `/net` for others must mount, and must post to `/srv` so dependents
+ can reach it — so the exception to rule 1 and the exception to rule 2 are
+ the same services. Circular and not thought through.
+- **Namespace changes do not propagate.** A provider's mount dies with its
+ process group, so `/srv` is the only rendezvous, which is the thing we are
+ trying to restrict.
+- **init's own bootstrap** predates all of this: it needs `/lib/services` and
+ `#s` before it can read a profile.
+- **`user=` ordering.** The capability device must still be in the namespace
+ at the moment we drop privilege, so profiles cannot omit `#¤` blindly.
+- **Adopted services** were started by bootrc in a namespace we never chose.
+- **Migration.** `cs`, `dns` and `timesync` work today with no namespace
+ declaration. Making it mandatory breaks them until profiles exist.
diff --git a/svc/doc/inventory.md b/svc/doc/inventory.md
new file mode 100644
index 0000000..a63907d
--- /dev/null
+++ b/svc/doc/inventory.md
@@ -0,0 +1,113 @@
+# What termrc and cpurc actually contain
+
+Taken from reading both scripts and from `ps` on a booted machine, which is
+the more reliable of the two — the scripts are full of conditionals that never
+fire.
+
+Kernel processes (0K, `Wakeme`/`Idle`: `pager`, `alarm`, `etherread4`,
+`iasata`, …) are not in scope. They are not started by anything in userspace.
+
+## Adopted — init can stop these but never starts them
+
+Started by `bootrc`, before init exists. No `exec=`, `adopt=yes`, stop only.
+
+| what | evidence | how to stop it |
+|---|---|---|
+| `hjfs` | pids 320–328, `/srv/hjfs.cmd` | `stop=write:/srv/hjfs.cmd:halt` |
+| `factotum` | pid 260, `/srv/factotum` | note |
+| `paqfs` | pid 8, serves the boot archive | note |
+
+`hjfs` is the one that must be stopped last, after everything using it.
+
+## Base namespace — init does these directly, they are not services
+
+None of this survives as a service, because a service runs in its own namespace
+group and so cannot change anyone else's.
+
+- `for(i in P S f æ t L A J '$') bind -qa '#'^$i /dev`
+- `mount -qb /srv/cons /dev`
+- binding the `mntgen` channels onto `/n`, `/mnt`, `/mnt/exportfs`
+- `mount /srv/factotum /mnt/factotum` and the `bind -q` after it
+
+## Services
+
+| service | evidence | ready | needs | notes |
+|---|---|---|---|---|
+| `slashn` | pid 11 | `srv:slashn` | | `mntgen` for `/n` |
+| `slashmnt` | pid 14 | `srv:slashmnt` | | `mntgen` for `/mnt` |
+| `mntexport` | pid 17 | `srv:mntexport` | | `mntgen` for `/mnt/exportfs` |
+| `kbdfs` | pids 70–74 | `exec` | | posts nothing in `/srv` |
+| `usbd` | pids 82–83 | `exec` | | from `nusbrc` |
+| `kb` | pids 91–92 | `exec` | `usbd` | from `nusbrc` |
+| `cs` | pid 372 | `srv:cs` | | |
+| `dns` | pid 446 | `srv:dns` | `cs` | |
+| `timesync` | pid 450 | `exec` | `dns` | needs a name to resolve |
+| `realemu` | pids 456–457 | `exec` | | vga real-mode calls |
+| `webfs` | pid 500 | `exec` | `dns` | |
+| `webcookies` | pid 497 | `exec` | | |
+| `plumber` | pids 503–504 | see below | | |
+| `ipconfig-ra6` | pid 439/440 | `exec` | | the lingering RA listener |
+
+Only on a machine that is meant to accept logins:
+
+| service | from | ready | needs |
+|---|---|---|---|
+| `listen` | `cpurc` | `exec` | `cs` |
+| `keyfs` | `cpurc`, auth branch | `srv:keyfs` | |
+| `authsrv` | `cpurc`, auth branch | `dial:tcp!*!567` | `keyfs` |
+
+## Oneshots — `ready=exit`
+
+- `diskparts`
+- `swap` (only when `/dev/sd*/swap` exists)
+- `ip/ipconfig -h $sysname ether $ether` — the DHCP one, as opposed to the RA
+ listener above, which stays
+- setting `/dev/sysname`
+- `screenrc`
+
+## What simply disappears
+
+- **The `service=` branch itself.** `termrc` and `cpurc` are 80% identical: the
+ same device binds, the same `mntgen` lines, the same `factotum` mount, the
+ same `cs`/`dns`/`diskparts`/`swap`. The difference is which of two nearly
+ identical scripts runs.
+- **The role detection block.** `cpurc` decides whether this machine is an
+ authentication server by comparing `$sysname` against the `auth` attribute in
+ ndb, and starts `keyfs` plus a different `listen` if it matches. That is the
+ archetype logic, in one `if`. It becomes: is `keyfs` enabled or not.
+- **`serviced=` selection** — `/cfg/$sysname/service`, `/cfg/default/service`,
+ `/rc/bin/service`, in that order. Replaced by `/lib/svc`.
+- **The `.local` and `/cfg/$sysname` hooks** — `cpurc.local`, `termrc.local`,
+ `/cfg/$sysname/cpurc`, `/cfg/$sysname/cpustart`. Four hook points that exist
+ because a shell script has no other way to be extended. A directory of
+ service files does not need them.
+- **`rm -f /env/i`, `rm -f /env/disk`, `rm -f /env/ether /env/addrs /env/addr`**
+ — these clean up after `for` loops and backquote assignments. They exist
+ only because this is a shell script, and vanish with it.
+- **`NPROC`, `prompt`, `fn term%`** — shell configuration that has no business
+ in system startup. Belongs in `profile`.
+- **`dontkill`** — a list of process names protected from `kill`. Worth
+ revisiting rather than porting: with a supervisor that knows what it started,
+ protecting things by name is the wrong shape.
+
+## Two problems this turned up
+
+**`plumber`'s `/srv` name is dynamic.** It posts `/srv/plumb.glenda.502` —
+user and pid baked into the name. `ready=srv:name` cannot express that, and
+neither can `stop=write:...`. Options: allow a glob in `ready=srv:`, accept
+`ready=exec` and lose the liveness check, or treat it as a general escape and
+add `ready=file:pattern`. Unresolved, and it will not be the only such server.
+
+**`ipconfig` appears twice with different lifetimes.** One does DHCP and exits
+(a oneshot); the other listens for router advertisements and stays (a service).
+They are the same binary with different arguments, so they must be two service
+files with different names. Fine, but it means service name and program name
+cannot be assumed to match — which the current implementation already allows,
+since `svc=` and `exec=` are separate.
+
+## Also worth noting
+
+There is no `aux/listen` running on this machine, because it booted as
+`service=terminal`. Every service above marked "only on a machine meant to
+accept logins" is absent purely because of one word in `plan9.ini` — which is
+the whole argument, visible in `ps`.
diff --git a/svc/doc/todo.md b/svc/doc/todo.md
new file mode 100644
index 0000000..5598ae3
--- /dev/null
+++ b/svc/doc/todo.md
@@ -0,0 +1,101 @@
+# TODO
+
+State as of the end of the second session. Ordered by what unblocks what,
+not by size.
+
+## Do first: kill the serial dependency
+
+Everything else is cheaper once this is done. The serial shell wedged three
+times in one session and is lossy, non-interactive, and hostile to quoting.
+
+- [ ] `/adm/keys` and a user: `auth/keyfs`, then `auth/changeuser glenda`
+- [ ] service files for `keyfs`, `authsrv` (listener on 567), `listen` (rcpu
+ on 17019). This is literally `svc enable keyfs authsrv` from the man page
+- [ ] add a 567 hostfwd to `run.sh` next to the existing 17019 one
+- [ ] client: build conterm (github.com/0intro/conterm, text-only, drivable
+ over a pipe) or use the already-installed drawterm interactively
+
+`tcp17019` runs `tlssrv -a /bin/rc -c server`, so it authenticates through
+factotum and needs the auth server to validate. `rc/bin/service.auth/` already
+ships `tcp567`. `run.sh` already forwards 17019.
+
+## Unblocked, mechanical
+
+- [ ] drop `svc=`; the filename is the service name
+- [ ] `.ndb` extension on service files, stripped to get the name
+- [ ] migrate more services per `inventory.md` — `plumber`, `webfs`,
+ `webcookies`, `kbdfs`, `usbd`, `realemu`
+- [ ] doc drift: `/log` → `/sys/log` in `design.md` and `man/init`, with the
+ reason (root is mounted without create permission)
+- [ ] doc drift: ctl permissions are `0644`, not `0600` — reads must be open
+ or ctl cannot document itself
+- [ ] record the session's gotchas in `CLAUDE.md`: non-interactive rc dies on
+ a syntax error and takes the serial with it; `pkill -f` kills the calling
+ shell even with the bracket trick; rc treats double quotes as literal
+ characters, so `|` inside them becomes a pipe
+
+## Needs a decision before code
+
+- [ ] **`stop` does not work for detaching services.** `Ksrv` services have no
+ pid init can signal, so `stopsvc` is a no-op and a later `restart` fails
+ with "another instance is running". Proposed: for `Ksrv` with no `stop=`,
+ remove the `/srv` entry, which hangs up the channel and a well-behaved
+ server exits. Untested.
+- [ ] **Dynamic `/srv` names.** `plumber` posts `plumb.glenda.502`, `rio` posts
+ `rio.glenda.1483` — user and pid in the name. `ready=srv:` cannot express
+ it and it is a pattern, not an exception. Needs a glob, a new `ready=`
+ form, or accepting `ready=exec` and losing liveness.
+- [ ] **Namespace profiles.** Ship `/lib/ns/{net,disk,draw,full}`, require
+ every service to name one, no default. Blocked on the item below.
+- [ ] **The provider circularity.** A service that provides a namespace to
+ others (an `import` of a remote `/net`, say) must be able to mount *and*
+ must post to `/srv` so dependents can reach it — so it is simultaneously
+ the exception to "do not bind `#s`" and to freezing the namespace. Not
+ resolved. Profiles built before this is settled get built twice.
+
+## Unimplemented attributes and features
+
+Parsed and ignored today: `ns`, `user`, `stop`, `adopt`.
+
+- [ ] `ns=` inline lines and `nsfile=`, via `newns`
+- [ ] `user=` via the capability device — note `#¤` must still be in the
+ namespace at the moment privilege is dropped
+- [ ] `stop=` — `note:`, `write:file:word`, `exec:` forms
+- [ ] `adopt=yes` for services init can stop but never started (the root file
+ server, `factotum`, `paqfs`)
+- [ ] `halt`, `reboot`, `reset` ctl verbs and commands
+- [ ] `reexec`
+- [ ] rescue console when services will not come up
+- [ ] base namespace construction by init, replacing the device-bind loop in
+ termrc
+
+## Environment
+
+- [ ] **The bootargs prompt needs a manual Enter every boot.** Listed as
+ unresolved in `CLAUDE.md`; it now blocks unattended testing, which makes
+ it worth actually fixing.
+- [ ] sshfs has to be remounted by hand after every reboot — a candidate for
+ being a service itself, gated on a `sshkey` oneshot
+- [ ] `9front-base.qcow2` is stale relative to `9front.qcow2`; overlays made by
+ `newvm.sh` lack the supervised serial shell and the rio change
+
+## Verification debts
+
+Claims the design rests on that have not been tested:
+
+- [ ] does `RFNOMNT` survive `exec`? That is the last step before a service
+ runs, and the plausible place for a flag to be cleared
+- [ ] does binding a single `/srv` entry (`bind #s/cs /srv/cs`) work as cleanly
+ as binding the directory? The fine-grained capability story depends on it
+- [ ] does `import` have a flag to post to `/srv`, or is `srvfs` needed?
+- [ ] does `bootrc` honour `init=` in `plan9.ini`? That is the escape hatch if
+ we ever make init the boot program and get it wrong
+
+## Deferred deliberately
+
+- The filesystem hierarchy rework. Until then, do not churn `/lib/svc` →
+ `/lib/services`; every path in init is a flag, so it is a one-line change
+ whenever the hierarchy lands.
+- `timesync` reaching an external NTP server. It fails on DNS resolution in a
+ NAT'd VM and is not worth chasing; the dependency machinery around it is
+ already proven.