Metadata-Version: 2.4
Name: deckname-client
Version: 1.0.0rc1
Summary: Claude Code hook client for Deckname, the German DSGVO text anonymizer (HTTP only).
License-Expression: LicenseRef-Proprietary
License-File: LICENSE
Keywords: anonymization,claude-code,dsgvo,gdpr,german,hook,pii
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: German
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.11
Requires-Dist: desktop-notifier>=6.2
Requires-Dist: pydantic-settings>=2.5
Requires-Dist: questionary>=2.0
Requires-Dist: watchdog>=6.0
Description-Content-Type: text/markdown

# deckname-client

The workstation client for **Deckname**, a German DSGVO-compliant text anonymizer for
Claude Code. It installs the Claude Code hook that replaces personal data in what a session
reads with placeholders before it reaches the model, plus the commands that set a project up,
sign the workstation in, check that protection works and start Claude Code.

The client talks to a Deckname server over HTTP only; detection runs on the server. Your
organisation's Deckname administrator gives you the server's address. Python 3.11 or newer and
Claude Code 2.1.121 or newer are required.

## Installation

Install it as a tool, isolated from other Python packages, with one of:

```
pipx install deckname-client
uv tool install deckname-client
```

or into an existing environment:

```
pip install deckname-client
```

Without internet access, install the wheel file your administrator provides (it is attached to
each release): `pipx install deckname_client-<version>-py3-none-any.whl`. Its four dependencies
must then come from an internal package index or as wheel files too.

Check the installation:

```
deckname --version
deckname --help
```

Upgrade with `pipx upgrade deckname-client`, `uv tool upgrade deckname-client` or
`pip install --upgrade deckname-client`.

## First steps

In the project folder Claude Code should work in, run `deckname` with no arguments: it sets the
project up (which files and tools are protected, which server is used), signs the workstation
in, checks the protection and starts Claude Code. Step by step, the same is:

```
deckname setup      # declare what is protected, choose the server
deckname login      # sign this workstation in
deckname selftest   # show that this machine anonymizes
deckname launch     # start Claude Code behind the preflight
```

This page is the reference: one section per command, with the exit codes and stderr markers a
rollout script can branch on. Every command's own `--help` carries its description.

## What is installed

The console script is `deckname`; the server's is `deckname-server`. The two names are
disjoint on purpose, so a machine with both installed shadows neither. A `deckname-client`
compatibility command (pointed at the identical entry point as `deckname`) also stays
installed, so a workstation set up under that older name keeps working until it re-applies its
wiring — see "Upgrading a workstation set up under the old `deckname-client` command" below.
On Windows, `deckname-syncw` runs the group sync service without a console window.

The client ships **none** of the detection stack (no Presidio, no spaCy model, no FastMCP).
Its four runtime dependencies: `pydantic-settings` (the `DECKNAME_HOOK_*` settings model),
`questionary` (the project-setup wizard's terminal UI, imported by that wizard alone),
`desktop-notifier` (the group sync service's system notifications) and `watchdog` (the group
sync service's folder watch) — the hook's per-call path stays stdlib + `pydantic-settings`.

The declaration file is `deckname-protected-sources.toml` (it was `bpm-protected-sources.toml`
before). A workspace that has not renamed the file on disk is
still read — with one deprecation warning naming the rename — so nothing loses coverage; if
both files exist, the current name wins and a warning says so rather than choosing silently.
Nothing in this package ever writes the retired name, and neither `setup` nor
`declare apply` removes it: `setup` writing `[paths]` migrates the retired file's other
sections onto the current name, so nothing it declared is lost, but it leaves the retired file
itself in place — a workspace that had only the legacy file trips the "both exist" warning
afterward instead of falling silent. `declare apply` never touches this file at all; it writes
`.claude/settings.json`, not the declaration TOML (see "`declare` — generate the hook wiring
instead of hand-editing it" below). Renaming or deleting the retired file by hand is the only
way to silence the warning.

Configuration is read from `DECKNAME_HOOK_*` environment variables (plus the mirrored
`DECKNAME_MAX_INPUT_CHARS` ceiling), or from a `.env` beside the workspace. The legacy `BPM_*`
prefix is not read.

## Claude Code 2.1.121 or newer — a hard prerequisite

This hook replaces a tool result by returning `updatedToolOutput`, and Claude Code honours
that for **built-in** tools only from **2.1.121** onward. On an older host the hook is
invoked, answers correctly, and the host discards the replacement: the declared file is read
and its original content reaches the model. Nothing about that is loud — the hook does not
error, so an older workstation looks configured and protects nothing.

`deckname selftest` checks this first, ahead of every other row, and reports RED below the
floor and inconclusive when no `claude` can be found to ask. `claude --version` answers it
directly; upgrade Claude Code if the number is lower.

## The bare invocation — set up, verify, launch

Called with no arguments at all, `deckname` works on the current directory: it
checks whether a `deckname-protected-sources.toml` and the matching hook wiring in
`.claude/settings.json` are here, fills in what is missing with `setup` and
`declare apply`, verifies the result, and then starts Claude Code behind the usual
preflight. An already-configured directory writes nothing and goes straight to the
preflight and the handover.

## Upgrading a workstation set up under the old `deckname-client` command

The client's console script used to be `deckname-client`; a workstation set up under
that name carries `deckname-client hook --event ...` inside `.claude/settings.json`. Today
`deckname` is the client's primary console script, but `deckname-client`
stays installed too, as a compatibility shim pointed at the identical entry point — so that
retired command keeps resolving and the hook keeps covering content even before the wiring
is regenerated.

**`deckname launch` still refuses loudly rather than hand a session to `claude` while the
wiring is stale** — the shim is not something to depend on long-term, so it blocks, names
the file, and points at the fix, instead of silently letting a workstation drift further from
the current wiring. The one thing an operator must do by hand to remove the dependence on the
shim:

```
deckname declare apply
```

That rewrites `.claude/settings.json`'s hook commands to `deckname hook` and reports the
rename on stderr, headed by `[deckname:declare-migrated]`. Nothing else needs a manual step:
`deckname-protected-sources.toml`, the `DECKNAME_HOOK_*` environment and the `.mcp.json`
registration are unaffected by this particular rename.

## `declare` — generate the hook wiring instead of hand-editing it

`deckname-protected-sources.toml` in your workspace root declares what the hook protects.
`declare apply` writes the `hooks` arrays and the `DECKNAME_HOOK_*` keys of
`.claude/settings.json` from it; `declare check` reports drift and writes nothing.
`DECKNAME_HOOK_AUTH_TOKEN` is never written — it stays in `.env`.

The token itself is the bearer token your Deckname server was configured with: put it in a
`.env` beside the workspace as `DECKNAME_HOOK_AUTH_TOKEN=<token>`, and verify it with
`deckname selftest`: the
canary check makes the same authenticated `POST /api/anonymize` the hook makes, so a credential
the server refuses surfaces there as a canary that could not be asked. `setup` prints this file
as the place to read that, so it is the answer to "where does the value go".

```
deckname declare apply --workspace .
deckname declare check
```

### `declare check` — exit codes and stderr markers (a stable contract)

These values are a **published consumer contract**: CI branches on the code and alert
routing greps the marker, so they are stable once shipped.

| Exit | Meaning | stderr marker | Remedy |
|---|---|---|---|
| `0` | the generated region matches the declaration | *(silent)* | — |
| `1` | the generated region has drifted | `[deckname:declare-drift]` | re-run `declare apply` |
| `3` | `deckname-protected-sources.toml` does not parse | `[deckname:declare-source-malformed]` | fix the TOML by hand — `declare apply` cannot run until it parses |
| `4` | the deployment's **published client policy** cannot be applied | `[deckname:client-policy-malformed]` | fix the published policy on the server; nothing was written locally |
| `5` | a hook this client does not own claims a tool deckname's own hook claims | `[deckname:declare-hook-conflict]` | change that hook's matcher so it no longer selects those tools, or remove it; nothing was written locally |

`2` is left to argparse's usage error, so a mis-invoked command can never be mistaken for a
malformed source. A workspace with no `deckname-protected-sources.toml` at all declares nothing
and exits `0` silently. A successful `declare apply` that renamed a legacy shape says so with
`[deckname:declare-migrated]`, which heads no exit code of its own.

`[deckname:declare-shell-uncovered]` heads no exit code either, and reports a hole rather than
a fault: the declaration protects files but leaves `[bash]` false, so whatever a shell command
prints out of those files (`cat`, `grep`, `type`) reaches the model unredacted. `Bash` carries
no path field — its `tool_input` is `{command, …}` and names no file — so it is covered at tool
level or not at all, and a workspace that declares `kuendigung.md` while leaving the shell
uncovered is one `cat kuendigung.md` away from delivering it in full. Leaving it uncovered is a
legitimate choice; leaving it uncovered *silently* was not. Both `declare check` and
`declare apply` report it, and neither changes its exit code because of it.

The rows above are held equal to the constants in `deckname_client/declare.py`
(`EXIT_CODE_MARKERS`) by the test suite, in both directions.

### The published client policy is fetched, not enforced

When the declaration names a `[server] url` (or `DECKNAME_HOOK_SERVER_URL` is configured),
both `declare apply` and `declare check` fetch the deployment's published `[mcp]` / `[web]`
floor from `GET /api/client-policy` and merge it into the workspace declaration before
generating. **This buys provability, not enforcement**: the send/don't-send decision is
client-side.

If that fetch fails, the run proceeds **local-only** with an empty policy — no cache, no hard
failure, and its exit status decided by the local work alone — and one warning is written to
stderr, headed by `[deckname:client-policy-unavailable]` and naming its cause
(`unreachable` or `auth-failed`). Take it seriously: the declaration that run generates may be
**below the published floor**, and beyond that one line nothing else will say so.

### The hook re-reads the published policy on a short cache

The **hook** reads the same `GET /api/client-policy` on its own, on every `mcp`/`web` tool
result, so a glob an administrator publishes takes effect on a workstation where nobody ran
`declare apply`. Because the hook is spawned once per tool call, that answer is cached on
disk for `DECKNAME_HOOK_POLICY_CACHE_SECONDS` (**default 60s**): a burst of tool calls costs one request, and a
rule published now is in force within a minute.

The window is bounded in time and nothing else. An entry is used only while it is strictly
younger than the lifetime, an entry dated in the future is discarded, and a **failed** fetch
is never cached — so the cache can never be the reason a published rule is missed for longer
than its lifetime. Set the variable to `0` to disable it and fetch on every call. The cached
file lives beside the client's other per-user state (`%LOCALAPPDATA%\deckname` /
`$XDG_CACHE_HOME/deckname`; `bpm-anonymizer` before that — a workstation
carrying only the retired directory has it adopted automatically on the next hook run, so
nothing is lost) and holds the two published glob lists and the review mode word only — no
token, and no URL (the deployment it belongs to is recorded as a digest).

### The git hook is a convenience; CI is the gate

`scripts/git-hooks/pre-commit` in the server repository runs `declare check` before a
commit and passes its stderr through unchanged. It runs **only for developers who install
it themselves**: the repository ships no installer and configures no `core.hooksPath`, so
cloning does not wire it up. Install it by hand if you want it:

```
git config core.hooksPath scripts/git-hooks
```

The **enforcing** gate is the CI invocation of `deckname declare check`; the hook
merely moves the same answer earlier. A missing client makes the hook exit `0` rather than
block the commit.

### `declare scan` — files that often hold secrets, offered as protected sources

`deckname declare scan [--workspace ORDNER] [--yes] [--json]` looks for the files an ecosystem
conventionally keeps its credentials in and offers the ones the declaration does not protect yet
as `[paths]` entries. It reads **names only** and prints **paths only**, never a file's content.
Two files are opened without returning a byte of them: a `config.json` counts as Docker's only
when it carries `auths`, and a `*.csproj` is read for its `<UserSecretsId>`.

| Group (`--json` `ecosystem`) | Files |
|---|---|
| Umgebungsdateien (`env`) | `.env`, `.env.*` — not `.env.example`, `.env.sample`, `.env.template` |
| .NET (`dotnet`) | `appsettings.json`, `appsettings.*.json`; the user secrets a project's `<UserSecretsId>` names (`%APPDATA%\Microsoft\UserSecrets\<id>\secrets.json`, `~/.microsoft/usersecrets/<id>/secrets.json` elsewhere), declared by their absolute path |
| Spring (`spring`) | `application*.properties`, `application*.yml`, `application*.yaml` |
| Azure Functions (`azure_functions`) | `local.settings.json` |
| Schlüssel und Zertifikate (`keys`) | `*.pem`, `*.key`, `*.pfx`, `*.p12`, `*.jks`, `*.keystore`, `id_rsa*`, `id_dsa*`, `id_ecdsa*`, `id_ed25519*` (not their `.pub`) |
| Paket-Registries (`registries`) | `.npmrc`, `.pypirc` |
| Terraform (`terraform`) | `*.tfvars`, `*.tfvars.json`, `terraform.tfstate*` |
| Zugangsdaten (`credentials`) | `.netrc`, `_netrc`, `.aws/credentials` inside the project |
| Docker (`docker`) | a `config.json` with `auths` |
| Kubernetes (`kubernetes`) | `kubeconfig`, `kubeconfig.*`, `*.kubeconfig`, `.kube/config` |

**Which files are looked at.** In a git checkout, `git ls-files` gives the tracked files, the
untracked ones that are not ignored, and the ignored ones with every ignored folder collapsed
into one entry: `.env` is usually ignored, and it is the file this scan exists for. An ignored
folder is walked unless it is one of `.git`, `node_modules`, `.venv`, `venv`, `dist`, `build`,
`bin`, `obj`, `target`. Outside git the same folders are skipped by a walk of at most 12 levels;
either way the scan stops after 50,000 names (`truncated` in `--json`). A name carrying a glob
character or a comma is never offered, since the declaration would read it as a pattern.

On a terminal the list is shown grouped and one question asks whether to add all of them
(„Als geschützte Quellen eintragen?“). `--yes` adds them without asking; without a terminal and
without `--yes` the list is printed and nothing is written. `--json` prints one object
(`workspace`, `found`, `unprotected`, `added`, `truncated`, `exit`) and never asks; it adds only
with `--yes`. Adding goes through setup's own writer (union in, nothing removed) and then runs
`declare apply`. Exit codes: `0` nothing found, the list shown, or everything added and wired;
`1` nothing added — the question answered „Nein“ or cancelled, or the write failed
(`[deckname:declare-scan-error]`); `3` the declaration does not parse
(`[deckname:declare-source-malformed]`); after the files were added, `declare apply`'s own codes.

## `setup` — author the declaration on a workstation's first run

`deckname setup [WORKSPACE]` writes a workstation's `deckname-protected-sources.toml`
**before Claude Code is ever started**, with no model in the loop. It scans the workspace
for directory and file **names only** (never contents) and shows them as a tree: every folder
that holds a file of a supported type, with how many and which types, and every supported file
at the top level. Nothing is guessed or pre-ticked — the first question asks which of these to
protect (and is skipped when there is nothing to show), and — through the same one writer
`declare` reads back — setup writes what was chosen. *Außerdem schützen? → Ordner oder Datei …*
takes a folder anywhere on the machine; when it holds a supported file, its own tree opens the
same way, the whole folder first. The supported types live in one table,
`deckname_client.source_types.SOURCE_TYPES`; adding a type (Excel, once the server parses it)
is one row there, and both the tree and `prepare` pick it up. It is one row on the `deckname` console
script (there is **no** `deckname-setup` binary), beside `hook`, `declare`, `selftest` and
`launch`.

`WORKSPACE` defaults to the **current working directory** when omitted; pass a path to operate
on a different one. Without `--yes`, setup opens the SAME guided, one-question-at-a-time
conversation bare `deckname` opens on a fresh project — on the rail where the terminal can host
it, as plain `[J/n]`-style lines where it cannot (Git Bash / mintty). With `--yes` (equivalently
`--non-interactive`) it guesses nothing either: it keeps the sources the declaration already
names (an MDM rollout ships the file), adds none, and never reads stdin.

**Two more questions after the wiring (terminal only).** When the project holds files that
often carry secrets and the declaration does not protect them yet, setup lists them grouped by
ecosystem and asks whether to add them — the same scan and writer as
[`declare scan`](#declare-scan--files-that-often-hold-secrets-offered-as-protected-sources),
asked only when something was found. Then, when this computer holds a `deckname login` for the
project's server and its release has `sync`, setup lists your Akten and asks „Eine Akte auf
diesem Computer synchronisieren?“: „Akte als Arbeitskopie synchronisieren“ (pick the Akte, then
the same flow as `deckname sync start "<Akte>"`), „Ordner als Quelle synchronisieren“ (name the
folder, then a new Akte named after it or an empty one you write in, then the same flow as
`deckname sync start --quelle <Ordner> [<Akte>]`) or „Später“ (the default). Setup closes its
own rail first and hands the answer to `sync start`, whose exit code stays its own; setup's code
is unchanged. Without a login or `sync`, setup says the one line „Akten auf diesem Computer
synchronisieren: `deckname sync start`.“ instead. `--yes` asks neither question: it prints that
line, and, when the scan finds unprotected files, one line naming their count and
`deckname declare scan`. Nothing is added on its behalf.

`--server <url>` writes the anonymizer's address into `[server] url` — the one thing setup
authors outside `[paths]`. On a terminal the wizard asks that question itself, so `--server`
is for the scripted path and is **refused** (nothing written) when it would open the wizard;
pass it with `--yes`. The address is validated by the declaration reader before anything is
scanned or written, so a value `declare apply` would later refuse never lands on disk.

**Omitting the flag does not mean `[server]` is left unauthored.** This paragraph said so
until the refusal-surface work re-read the code, and it was wrong: *every* setup path writes
`[server] url`. The value is `--server`'s where one was given, else the address already
declared here — preserved byte for byte, not rewritten — else the client's own shipped
default `http://127.0.0.1:8000`. So a workspace setup has touched always names a server,
which is why the run reports the two cases differently: *"… eingetragen"* when it wrote the
key, *"… bereits … eingetragen, unverändert übernommen"* when it left an existing one alone.

**And a successful setup applies the wiring itself.** After writing the declaration it runs
`declare apply`'s own generator — the same function, never a second writer of the same file —
so `.claude/settings.json` is in place when setup returns and one command reaches a launchable
workspace. `declare apply` stays its own command for **re-wiring**: after a hand edit to the
declaration, and as the remedy for exit `5` below, where the declaration is already correct
and only the wiring step failed.

**On Windows, the interactive path needs Windows Terminal or PowerShell.** In Git Bash /
mintty `isatty()` reports False, so this command cannot tell it has a terminal: it takes the
scripted branch, the wizard is never opened, and the server question is never asked. Run
`python -c "import sys; print(sys.stdin.isatty())"` to settle which shell you are in before
reading a missing prompt as a defect. An interactive run whose stdin is not a terminal and
which was not given `--yes` writes nothing and says so, under
`[deckname:setup-needs-a-tty]` — the `1` row below.

```
deckname setup                    # interactive, over the current directory
deckname setup --yes ./workspace  # scripted (MDM / CI), a given workspace
deckname setup --yes --server https://anonymizer.example.de   # ... naming the service
```

### `setup` — exit codes and stderr markers (a stable contract)

An MDM rollout scripts this command and branches on its status, so these values are a
**published consumer contract**: stable once shipped. `3` reuses `declare check`'s meaning —
an unparseable `deckname-protected-sources.toml` — because one operator reads both.

| Exit | Meaning | stderr marker | Remedy |
|---|---|---|---|
| `0` | setup completed (a declaration was written, or a re-run confirmed it already correct) | *(the empty-scan note only)* | — |
| `1` | the run ended without writing (declined or aborted; an interactive run on a non-terminal stdin without `--yes`; a `--server` address the declaration reader refuses; or `--server` where the wizard would open) | `[deckname:setup-needs-a-tty]` on the non-terminal case, `[deckname:setup-server-unusable]` on **both** `--server` cases — the refused address and the contradiction with the wizard, `[deckname:setup-error]` on a write that failed | re-run, or pass `--yes` for an unattended run |
| `3` | an existing `deckname-protected-sources.toml` does not parse | `[deckname:setup-source-malformed]` | fix the TOML by hand; nothing was written |
| `4` | the workspace path is unusable (does not exist, or is a file) | `[deckname:setup-workspace-unusable]` | pass an existing workspace directory |
| `5` | the declaration **was** written and the wiring step that follows it refused (an unresolvable policy floor, or a `.claude/settings.json` the filesystem would not let the generator write) | `[deckname:setup-wiring-failed]` | fix the cause named above the marker, then run `deckname declare apply` — do **not** re-run `setup`, the declaration is already correct |

**Where a finding is said.** On stderr — the stream the "is a person reading?" question is
asked of. When stderr is a terminal each finding is ONE status line there, after the result
block — `▲  1 Dokument vom Server abgelehnt` for a refusal (its whole sentence under `-v`; no
pointer at `deckname prepare -v`, which would hand every document over again), a `▲` for a
pattern that matched nothing, a `✖` for a server that could not be reached or a declaration
that does not parse — and no marker is printed. The result block itself stays on stdout, so
`deckname prepare > bericht.txt` at a terminal still shows every finding there. The marker is
written when prepare's stderr is **not** a terminal (every CI and MDM run, and any caller that
passes its own report sink), exactly as the table says; the exit code is the same either way.

`2` is left to argparse's usage error, so a mis-invoked command can never be mistaken for a
real outcome. A `--yes` run whose scan finds nothing writes an **empty `[paths]`** and still
exits `0`, saying so on stderr — a successful exit is not evidence the workspace is covered.

`5` is the one code that does **not** mean "run `setup` again": the declaration is on disk and
correct, and what is missing is the file `declare apply` writes. `1` means the opposite — the
run wrote nothing — so a rollout script must branch on the two separately.

The rows above are held equal to the constants in `deckname_client/setup_cli.py`
(`SETUP_EXIT_CODE_MARKERS`) by the test suite, in both
directions, so this table cannot fall behind the contract again.

**That equality is a membership check, and membership was not enough.** It asks whether a
marker is listed against a code, never whether the run that reaches that code actually
prints it — and one refusal printed none at all: `--server` passed where the wizard would
open exited `1` with no marker on stderr, so a rollout script greping for one read the run
as an unexplained abort. The same module now also DRIVES each sub-case above through the
real entry point and holds the pair it produces, and it refuses any published marker that
no driven sub-case emits, which is the direction that would have caught it.

## `selftest` — show that this machine anonymizes

`deckname selftest` runs the checks on this workstation and prints the result, with
`--json` for an MDM or CI run that branches on it. It proves the local half only — it says
nothing about a published policy; use `declare check` for that.

```
deckname selftest
deckname selftest --json
```

## `launch` — preflight, then Claude Code

`deckname launch` runs the preflight, and either blocks on a red result (naming the
check that failed) or hands over to `claude` with every argument forwarded unchanged. While
the checks run, one line spins on a terminal (`Schutz wird geprüft …`); a green launch then
prints **one status line** — `✔  Schutz aktiv  3 Orte · localhost:8765 · 11 Prüfungen
bestanden`: the declared places, the service's host and port, and the checks that passed —
each warning as one `▲` line with a single `Mehr dazu: deckname launch -v` pointer (bare
`deckname` points at `deckname -v`, its own long form), and
`Claude Code startet …`. A status line with a warning under it leaves the count out
(`✔  Schutz aktiv  3 Orte · localhost:8765`). Off a terminal there is no animation and no
escape sequence, just those lines. A blocked launch prints four lines — `✖  Start
abgebrochen: <was den Start verhindert hat>`, `Behebung: <der eine Schritt>`, `Geprüft:
<was geprüft wurde> · Mehr dazu: deckname launch -v` and, dim, `Ohne diese Prüfung: claude
direkt starten.` (left out only when `claude` itself was not found) — then any warning in the
same one-line form, and nothing else; it exits `2`. With a warning after the block the pointer moves under
the warning, said once. Strg+C while the checks run prints `Abgebrochen. Nichts gespeichert.`
and exits `1` — the one line Strg+C prints at every question and wait in this client; after a
first run's setup was saved it reads `Abgebrochen. Die Einrichtung ist gespeichert.` instead.

`-v` (or `--verbose`) **right after `launch`** is the launcher's, like `--json`: it prints the
status line, every warning's full sentence, one blank line, the six-column log of the operations
the preflight covered, and then the hand-over line — bare `deckname -v` does the same. On a
blocked launch `-v` prints the long refusal instead of the three lines: the whole reason, the
check's own detail and the way round it (running `claude` directly). The two flags form a
leading run, each at most once and in any order; after any other argument, or after `--`,
`-v` and `--json` are Claude Code's and are passed through unchanged (`launch -p x -v` asks
Claude Code for its version, `launch -- --json` hands it `--json`). `--json` writes the log as
one object per line instead of the grid, on every launch, with no spinner, status line or
hand-over line.

```
deckname launch
deckname launch -v
deckname launch --json
deckname launch -- --resume
```

No line, in any form, carries a covered surface form or a full path — the status line
included, which is computed from the checks' results and this workstation's own configured
address, never from anything the self-test printed. `claude` can still be called directly at
any time; the protection does not hang on this launcher.

## `prepare` — hand declared documents to the async review queue, and say where they went

**Asked about, by release stage.** A deployment's release stage (`DECKNAME_RELEASE`)
decides whether it accepts documents ahead of the read, through the feature `prepare`; V1 —
stage 1 — does, and so does every later stage (stage 2 adds the group sync, stage 3
four-eyes review). `prepare`
first asks the server (`GET /api/me/config`, its `release` object) once the workspace has
something declared; on a stage without `prepare` it says „In dieser Version nicht
verfügbar." and exits `2`, and setup's and the first run's closing submit offer is not asked
at all (and not even asked of the server when the workspace has nothing to offer). Only a
readable answer is a stage: a server that cannot be reached, refuses the credential, fails
(`5xx`), answers any other unexpected status, or sends something that is not a JSON object (a
proxy's error page, an SSO login page) is reported as unreachable (`5`), never as "not
available"; a readable JSON answer without a stage, or without `prepare` among its features
(a server from before the feature existed), offers nothing.

`deckname prepare` reads the workspace's own `deckname-protected-sources.toml`, matches it
against the files on disk through the same matcher the hook itself uses, and hands the
matching documents to the deployment's already-live asynchronous upload queue for review
(`mode: async`, `destination: review`) — one `POST` per document. It never anonymizes
locally.

Name one or more paths to submit only those; a pattern such as `docs/**` is matched by the
declaration's own glob engine, and a directory name means everything under it. With no path
at all every declared document is submitted — except on a terminal, where one question is
asked instead (*Alle N / Einzeln auswählen … / Später*) so the choice is made in front of you;
Strg+C there prints `Abgebrochen. Nichts gespeichert.`, submits nothing and exits `1`. **A pattern that matches no declared
document submits nothing at all** and exits `6`: a partially-submitted run after a typo is
how a person comes to believe four documents are queued when three are.

Unless `--no-wait` says otherwise, the run then follows its own jobs to a terminal state,
prints one review address per parked document, and opens the review page once. That
follow-through is the answer to a measured defect: a document queued by `prepare` used to
park a review that nothing announced and nothing could have. On a terminal the wait is one
spinning line; Strg+C there ends the wait and nothing else — the documents are handed over and
their jobs keep running on the server — so the run says `Abgebrochen. Die Dokumente sind
übergeben.` and exits `1`.

What it prints is a short block on the rail: `✔  12 übergeben · 3 mit unbekannter Endung
übersprungen`, then after the wait `✔  Zur Prüfung bereit` with one link per review behind a
`│` (the browser tab the run opens is not announced: nothing can learn that it opened), and a
refusal
as `▲  1 Dokument vom Server abgelehnt.` The service's `Ziel:` address and the sentence about
reaching it from elsewhere are `-v`'s. Asked on a terminal, the question reads `12 deklarierte
Dokumente zur Prüfung einreichen?`.

**What preparing buys, and the one condition on it.** A document prepared and approved is
the document the next real read serves: `prepare` sends the declaration digest this
workstation has in force, the server files the approval under the same cache namespace the
hook composes when Claude reads that file, and the edits you made while reviewing are what
the read gets. The condition is that both halves match — the same workstation identity, and
the same *applied* declaration. Edit `deckname-protected-sources.toml` without running
`declare apply`, or prepare on one machine and read on another, and the read parks its own
review again, which is the privacy rule working rather than a fault.

```
deckname prepare
deckname prepare CLAUDE.md
deckname prepare "docs/**" kunden
deckname prepare --no-wait
deckname prepare --workspace ./workspace
```

### `prepare` — exit codes and stderr markers (a published contract)

These values are a **published consumer contract**, in the same style as `declare check`'s
table above: CI branches on the code and alert routing greps the marker, so they are stable
once shipped. The bearer token this verb sends is the same `DECKNAME_HOOK_AUTH_TOKEN` that `declare`/`selftest` already read — see
"`declare` — generate the hook wiring instead of hand-editing it" above for where that value
goes.

| Exit | Meaning | stderr marker | Remedy |
|---|---|---|---|
| `0` | every matching document was handed over (or the workspace declares nothing at all) | *(silent)* | — |
| `1` | at least one document was refused by the server, after every other document was attempted — **or** Strg+C ended the run: at its question (nothing was submitted) or during its wait (the documents are handed over); a cancel writes no marker | `[deckname:prepare-refused]` | inspect the refused document, then re-run `prepare`; after a Strg+C, run `prepare` again when you want it |
| `2` | the deployment's release stage does not include `prepare` (`DECKNAME_RELEASE`; every shipped stage does, V1 included — a server from before the feature existed does not): „In dieser Version nicht verfügbar." — **nothing was offered or submitted**. argparse's usage error keeps the same number, without a marker | `[deckname:prepare-not-in-release]` | none on this workstation — the deployment's operator updates the server or raises the stage |
| `3` | `deckname-protected-sources.toml` does not parse — the identical reader and condition `declare check` reports | `[deckname:declare-source-malformed]` | fix the TOML by hand |
| `5` | the server could not be reached, or refused the credential — or answered the release question with an error, an unexpected status or an unreadable page | `[deckname:prepare-server-unavailable]` | check `hook_server_url` and `DECKNAME_HOOK_AUTH_TOKEN`, then re-run `prepare` |
| `6` | a named path or pattern matched no declared document; **nothing was submitted** | `[deckname:prepare-selection-unmatched]` | check the spelling against `deckname-protected-sources.toml`, or run `prepare` with no path |
| `7` | every document was accepted and at least one job produced no review (failed, cancelled, expired, or still running when the wait ended) | `[deckname:prepare-not-parked]` | look at the review page; a job still running needs no action, a failed one needs the document re-submitted |

**Where a finding is said.** On stderr — the stream the "is a person reading?" question is
asked of. When stderr is a terminal each finding is ONE status line there, after the result
block — `▲  1 Dokument vom Server abgelehnt` for a refusal (its whole sentence under `-v`; no
pointer at `deckname prepare -v`, which would hand every document over again), a `▲` for a
pattern that matched nothing, a `✖` for a server that could not be reached or a declaration
that does not parse — and no marker is printed. The result block itself stays on stdout, so
`deckname prepare > bericht.txt` at a terminal still shows every finding there. The marker is
written when prepare's stderr is **not** a terminal (every CI and MDM run, and any caller that
passes its own report sink), exactly as the table says; the exit code is the same either way.

`2` is also what a release stage without `prepare` answers: there the verb answers
as the absent verb it is, and the marker is what tells
that refusal from a mis-invoked command. `4` is deliberately skipped: it already means "the published client policy
cannot be applied" in `declare check`'s table above, and a number that means two things in
this client is worse than a gap. A document refused by the server (`1`) and the server
itself being unreachable or refusing the credential (`5`) are never conflated — the second
is never counted as one more refused document. Nor are a refusal (`1`) and a job that did
not park (`7`): the upload seam this verb consumes makes "a refusal is never a failed job" a
rule of the contract, so a document the door took and then lost is never reported as a
document the door turned away. `7` is unreachable under `--no-wait`, which never learns any
job's outcome.

## `sync` — a group's documents as a folder on this computer

**One source, many working copies.** Every
group (German *Akte*) has at most one **source**: a Git repository, a OneDrive or SharePoint
folder, or a folder on a computer. Every member may keep **working copies** of it on their
computers, several per group and computer. A change in a working copy goes through the
detection into the group and from there to the source and every other copy; a change at the
source goes into the group and to every copy. Each folder holds the group's **folder tree**:
every document at `<Ordner>/<Name>`, every folder of the group made there (an empty one too).
A document with an original is its real file (Word, PDF, Markdown …), one without is its covered
Markdown under a frontmatter naming the document and its fassung (`deckname_doc`,
`deckname_version`). Set up entirely in the terminal, it asks only what is missing. `deckname
setup` offers the same two ways at its end (see `setup` above):

```
deckname sync                                       # the status, or (nothing set up) your groups
deckname sync start "Musterprojekt Nordwind"         # a working copy, asks for the folder
deckname sync start "Musterprojekt Nordwind" "D:/Prüfung/Nordwind"  # a further copy, asks nothing
deckname sync start --quelle "D:/Projekte/Nordwind"  # this folder becomes a new group's source
deckname sync start --quelle "D:/Vertrieb" "Vertrieb Nord"  # the source of an empty group
deckname sync start --quelle "D:/Projekte/Nordwind Süd" --vorgang 7KQM  # started from the portal
deckname sync status [--devices] [-v]               # this computer; --devices: your others too
deckname sync pause [<folder|group>] / resume [<folder|group>]  # without one: every folder here
deckname sync resume "Musterprojekt Nordwind" --allow-deletions  # confirms held deletions
deckname sync stop "D:/Prüfung/Nordwind" [--yes] [--wipe]        # ends this one folder
deckname sync set --review-open notify_only         # immediately | after_2_min | notify_only
```

**A working copy** (`start "<group>" [<folder>]`) lies in the folder you name, which must be
empty or new, by default in `~/deckname/<group>` (`C:\Users\<name>\deckname\<group>` on
Windows). `start` without a folder for a group that syncs here already says „Läuft bereits“; a
further folder is a further copy (a device of its own on the server, its own lock). Two sync
folders never lie inside one another. The output names the group, its source and the folders
made („6 Ordner angelegt · wie in der Quelle“).

**A source on this computer** (`start --quelle <folder> [<group>]`) is a folder with files
already. Without a group the client makes the group, named after the folder (the person becomes
a member who writes); with a group it must be an empty group you write in (a group with
documents gets its source from an admin in the portal). The folder is read with its subfolders
(„Ordner gelesen“ with the count per folder); `Thumbs.db`, Office's `~$…` files and executables
are left out („Übersprungen“). The client sends the list of files with their SHA-256 (the
manifest): a file that is a document already is recorded, one where a document lies with other
bytes is sent without a shared fassung (the replaced fassung's author gets an overlap, nothing
wins silently), a new file goes up into the import's queue on the server („412 Dateien
übertragen · Erkennen und Abdecken laufen auf dem Server“). No file of the folder is changed. A group with a source answers a
second one with exit `6`, the source's kind, place and owner, and the command for a working copy
instead („Als Arbeitskopie: deckname sync start …“). The same folder keeps one folder id here,
also after a stop, so the server knows it again.

**Limits of a new source.** Before it connects a folder as a source, the client asks the server
how large one Akte's source may be (`GET /api/sync/limits`: files, bytes, the largest single
file; the admin sets them under Einstellungen › Uploads & Anfragen). A file
over the file limit is left out like a system file („werden übersprungen“). A folder with more
files or bytes than allowed is refused with exit `7` and nothing is registered:

```
✖  Nicht gestartet: Die Quelle ist zu groß.
   64.200 Dateien und 26,1 GB. Erlaubt sind 50.000 Dateien und 20 GB je Akte.
   Bitte einen Unterordner wählen, zum Beispiel: deckname sync start --quelle "E:\Archiv\2026"
```

**Large imports** (up to about 50,000 files). The folder is read in one walk: each file is
hashed as the walk reaches it, in chunks, so memory stays small however many and however large
the files are, and a terminal counts along („Ordner wird gelesen …  12.400 Dateien · 3 GB“).
The manifest goes in pages of 2,000 files (`POST …/manifest?page=`, `last` on the final page,
each file with its size; „Dateien werden gemeldet …  8.000 von 30.000“); when the server lost
the import between two pages (`409 manifest_restart`) the client starts again from page 1. The
new files then go up four at a time („Dateien werden übertragen …  120 von 30.000“), each
answered on its own as before (a refusal, a file too large, the deletion guard and the folder's
lock are unchanged), and `state.json` is saved every 100 answers. Each upload of a source answers
`review` without a review (`queued`): the file waits in the import's queue, and the client keeps
it as sent until the group's listing names its document. The command ends once the files are
sent; the import continues on the server:

```
│  Der Erstimport läuft auf dem Server im Hintergrund weiter. Fortschritt im Portal: Akte
│  „Nordwind“, Tab Synchronisierung.
│  Claude Code liest die Dokumente erst nach der Freigabe im Portal.
```

The server detects each file in the background. A document with findings waits for its
Prüfperson in the Prüfplatz, one without findings for a release („Ohne Funde freigeben“); before
that, nothing of it reaches Claude Code, search or the MCP tools.

**Pause and cancel from the portal.** The Akte's Prüfperson (or an admin) can pause the import
(„Anhalten“), resume it („Fortsetzen“) or cancel it („Abbrechen“) in the portal. While it is
paused the server answers the source's manifest and uploads with `409 import_paused`: the client
stops sending, keeps what it holds, and tries again on its next pass; once the server takes the
files again the rest goes up. A manifest cut off by a pause is kept beside `state.json` (names
and digests, never a byte of a file) and sent again from page 1. After a cancel
(`409 import_cancelled`) nothing more is sent until the person chooses „Erneut starten“ in the
portal and runs `deckname sync start --quelle "<Ordner>"` again: the folder is read once more and
every file goes up anew („Erstimport neu gestartet“, `--json`: `"result": "restarted"`). `status`
shows it under the source („Erstimport  Pausiert. …“ / „Abgebrochen. …“), `--json` as
`import_state` (`paused`, `cancelled` or `null`).

**Started from the portal** (`--vorgang <Code>`). The portal's page „Quelle synchronisieren“,
step „Ort“ for a folder on a Workstation, shows the command with a four-character code for
that page (valid while the page is open, at most 30 Min.). The client reads the folder and
reports it to the page („Ordner gelesen · 9 Ordner · 412 Dateien · 180 MB · 23 werden
übersprungen“: the counts and up to 50 skipped files with their reason), then waits for the page
(„Weiter im Portal: Name der Akte festlegen.“). It waits on a server stream, not on a timer. When
the page names the Akte („Akte anlegen und synchronisieren“, a new one or an empty one you write
in), the client connects the folder as that Akte's source as above and ends with „Fertig.
Fortschritt im Portal.“ When the page is closed first, the client says „Der Vorgang wurde im
Portal beendet. Es wurde nichts geändert.“ and exits `1`. An unknown, foreign or expired code is
exit `6` („Der Vorgang … ist beendet oder abgelaufen.“). `--vorgang` needs `--quelle` and no group.

**Folders both ways.** A folder made, renamed, moved or removed in a sync folder is reported to
the group (`POST …/folders`); a file moved or renamed there (the same bytes under a new name in
one pass) is reported as a move (`POST …/moves`), so its fassungen and reviews stay with it; a
folder renamed with its files is one rename. What the group changes comes back the same way: a
new folder is made, a renamed one renamed with its files (a moved document is moved here, not
fetched again), a removed one goes, what is left in it into the local trash.

**The one-time move.** A folder set up before the folder tree (`<Ordner>/deckname/<group>/` with
`OneDrive/`, `Git/` and `Lokal/` below it) moves on its first pass after the update: the folder
registers again with a folder id, each document's file moves to where the group has it now, a
file not sent yet moves up out of its source folder, and the old device is signed off. The same
name twice with other bytes keeps both, the second named `… (aus Git)`. `status` says so once
(„Ordner umgezogen“). The folder itself stays where it is.

Every subcommand takes `--json`: exactly one object on stdout, a failure included
(`{"error", "exit", …}`), and no question is ever asked. Without a terminal no subcommand asks
anything either; `start` then takes `~/deckname/<group>`, and `stop` needs `--yes`. The home folder,
not *Documents*: Windows' Known Folder Move puts *Documents*, *Desktop* and *Pictures* into
OneDrive (macOS can put them into iCloud), which would carry the originals into a cloud in clear
text. A folder you name inside OneDrive (also `%OneDrive%`, `%OneDriveCommercial%`,
`%OneDriveConsumer%`), Dropbox, Google Drive or iCloud Drive is taken, with the warning „Ordner
liegt in <Anbieter>“ (`--json`: `"cloud"`). A folder set up before keeps its place. The server is `--server`, the workspace's own when run inside one, or the
one this computer holds a `deckname login` for. `start` also takes a `deckname://sync/<group_id>`
link (the web's „Im Client öffnen") in place of the group's name.

**With read access every copy is read-only**: a file changed or deleted on this computer is put
back on the next pass, with a system notification „<Titel> · Änderung zurückgesetzt". A document
deleted in the group is moved into `<group folder>/.deckname-trash/` — the client carries no
system-trash library, and a move inside the group's folder keeps the rule that the sync never
writes anywhere else. A pass removes what lies there longer than 7 days.

**With write access the files go back.** A saved file is sent 5 seconds after its last
change, only when it differs from the file the client wrote (`PUT` with the fassung it was
written from). The server merges it with what came in meanwhile; without a new finding it is the
next fassung at once and the merged file is written back. With a new finding it waits in your
own Prüfplatz: the file stays as you saved it until the review is closed (approved: the new
fassung is written; rejected: your change is discarded and the current fassung written). A new
`.md` file becomes a new document (`POST`), a deleted file deletes the document from the group
(`DELETE`, the group's trash keeps it 30 days). Offline the changes wait and go on the next
contact. A review or an overlap shows a system notification („Risikoliste · neue Stellen",
„Risikoliste · 1 Konflikt", action „Jetzt prüfen") and opens the Prüfplatz in the browser
per `deckname sync set --review-open`: `immediately`, `after_2_min` (the default: once the file
was not changed for 2 minutes) or `notify_only`; once per review or overlap, one page when both
concern one document. An expired login pauses the sync („Synchronisierung angehalten", action
„Anmelden"). Notifications use `desktop-notifier`; where the desktop offers none, nothing is
shown and the sync goes on.

**Protected from Claude Code.** The folder holds the originals in clear text, so `start` (and
`deckname setup`) writes the hook into your user-level Claude Code settings
(`<CLAUDE_CONFIG_DIR or ~/.claude>/settings.json`, each command `deckname hook --user …`, every
other entry kept), whatever a project declares. In every project the hook then:

- serves a `Read` of a synced file as its covered fassung, and anonymizes `Grep`/`Glob` results
  whose search reaches a synced folder (no `path`: the session's folder);
- denies `Write`, `Edit`, `MultiEdit`, `NotebookEdit` and shell writes into a synced folder;
- denies a shell or `Monitor` command that names a synced folder (any spelling: quotes, `~`,
  `$VAR`, `%VAR%`, `$env:VAR`, a wildcard, a folder above it searched recursively, or run from
  inside it) unless it only lists names;
- blocks an `@` mention of a synced file before the prompt is sent;
- keeps a content index of each synced folder, so a copy of an original anywhere else is found
  by its text and anonymized.

**An Akte's source on this computer is read through the Akte** (read redirect, 17.10.2026). A
file can lie in an Akte's source outside every sync folder: a workstation folder that is the
source, a Git checkout of the source repository (the same remote in any spelling — `https`,
`ssh`, `git@host:`, with or without `.git` — on the source's branch, below its folder), or a
OneDrive/SharePoint folder Microsoft's client syncs here (found by the client's registry entries
`HKCU\Software\SyncEngines\Providers\OneDrive\*`, `MountPoint` and `UrlNamespace`, against the
source's web address). The hook asks the server where the person's Akten have their sources
(`GET /api/sync/source-places`, kept for a minute in `sync/source-places.json`) and then:

- answers a `Read` there with the Akte's released, covered version of that document — the same
  text a working copy is served (`POST /api/sync/source-places/{group_id}/document` by the path
  relative to the source's folder); no detection or review starts. A file the Akte has not
  released is refused with „Diese Datei gehört zur Akte „…“ und ist dort noch nicht
  freigegeben.“, one it does not hold with „… ist in der Akte nicht vorhanden.“
  (`[deckname:akte-source-read]`);
- refuses a write there (`[deckname:akte-source-write]`) and a shell command naming it unless it
  only lists names (`[deckname:akte-source-shell]`), handles a `Grep`/`Glob` reaching it as a read
  of a sync folder, and blocks an `@` mention of a file there — the working copy's rules;
- refuses every tool the server's own mirror of a Git source (`<db folder>/repos/<Akte>.git`)
  when the server runs on this computer (`[deckname:akte-source-mirror]`).

A Git checkout elsewhere on the disk is recognised per path (its `.git`), so a recursive search
from a folder above it is only caught when the session's folder lies in it. A detached `HEAD`
counts as the source's branch.

A regex escape (`\|`, `\(`, `\*`) or a quoted root pattern (`grep -n "/*"`) is no path: a bare
`/` or `\` counts only as a whole word, and an escaped or quoted wildcard at a file system's root
is not expanded (unless a nested shell, `bash -c` or `eval`, takes the string).

It also refuses `credentials.json`, `sync/secrets.json` and a `.env` holding
`DECKNAME_HOOK_AUTH_TOKEN` to every tool, and withholds any tool output carrying one of their
values. A project wired by `declare apply` answers its own events; the user-level call steps
aside there. `start` says „Ordner geschützt“ only after it found the hook in place and the folder
among the protected ones; otherwise „Ordner nicht geschützt“ with the settings file it could not
write (`--json`: `"protected"`).

**`status`** lists per group its source and every folder here: „Quelle“ (with „diese
Workstation“ for a source on this computer) and each „Kopie“ with its state in the portal's
words („Synchronisiert“, „Eingriff nötig“, …); under the first copy the group's folders, each
with its documents and state („Verträge\2026\  4  1 Eingriff nötig“, „leer“), a further copy
„Ordner wie oben“. `--json` gives one row per folder with `role`, `key`, `folder_id`, `source`,
`folders` and `attention`.

**Ending the sync deletes the clear text, on request.** `stop` takes the folder (or the group
when it syncs here in one folder only). For a working copy it asks „Beenden und Dateien
löschen“ or „Beenden, Dateien behalten“; without a terminal `--wipe` deletes the copy's folder
(the trash included) and without it the files stay. A source asks „<Ordner> ist die Quelle von
„<Akte>“. Trennen?“: the group keeps its documents and has no source afterwards, the working
copies stay, and the source's files are never deleted (not with `--wipe`, not after a sign-off
elsewhere); they stay protected while the folder exists. `deckname logout` asks the same for the groups
of that server (`--wipe` without a terminal). Either way they are no longer synced: each one is
signed off on the server. Kept files stay protected as above while the folder exists
(„Arbeitskopien bleiben auf diesem Rechner, werden aber nicht mehr synchronisiert.“). A computer
signed off elsewhere (`410`) or a membership that ended (`404`, and the server no longer lists
the group) loses the group's folder at once, with the notification „Akte auf dieser
Workstation entfernt“.

**Held instead of deleting.** A pass sends no deletion when the group's folder is missing (moved,
renamed, an unplugged drive), or when more than 20 files, or from 3 files on more than half of
them, are missing at once. The group is paused, shows „Eingriff nötig“ in `status` with the reason
(`--json`: `"hold"`) and a notification. `deckname sync resume "<group>"` goes on once the folder
is back; `--allow-deletions` confirms the deletions. A file whose bytes reappear under another
name in the same pass is a rename and never counts toward this; in the folder tree it goes to the
server as a move (a folder of the layout before still sends a deletion and a new document).

**Working copies from the portal (Workstation).** A sign-in in the browser (`deckname login`,
not `--token`) and `deckname setup` (once the server offers the sync) make this computer the
person's Workstation on that server: it is registered with its host name, system and client
version (`POST /api/sync/workstations`), remembered in `sync/workstation.json` and the service
is started with its autostart. In the portal the person then picks the Workstation and a folder
(„Auf Workstation synchronisieren“, the folder typed as text, `~` for the home folder) and the
service sets the working copy up by itself: it takes the job at its start, on its Workstation's
stream (`GET /api/sync/workstations/{id}/events`) or when the stream comes back after an
outage, never on a timer. A Workstation that is switched off gets the job when it is back; a
job waits at most 7 days. The folder must be a full path, outside a cloud client's folder
(OneDrive, also `%OneDrive%`, Dropbox, Google Drive, iCloud Drive: refused, not only warned) and
outside every folder synced here; then it is exactly `sync start "<Akte>" "<Ordner>"`: made when
missing, protected for the hook, registered and filled, its count reported to the portal while
the first Run goes. Otherwise the job fails with a closed reason (`folder_invalid`,
`cloud_onedrive`, `folder_not_writable`, `folder_not_empty`, `folder_used`, …) and nothing is set
up. The portal also pauses, resumes and ends a working copy (ending deletes its files unless the
person keeps them, which then stay protected) and signs the Workstation out: every working copy
of that server ends the same way, a source stays (it is the person's own folder), and this
computer forgets the Workstation until the next `deckname login`. Such a copy is an ordinary
working copy here: `status`, `pause`, `resume` and `stop` treat it like any other. Nothing is
ever asked: the service answers the portal, never the terminal. `deckname logout` signs the
Workstation of that server out on the server first (`POST /api/sync/workstations/{id}/sign-out`,
its waiting jobs are cancelled and it leaves „Meine Workstations“ at once), forgets it here and
removes the service's autostart when nothing else keeps it (no folder synced, no Workstation of
another server); the service then ends by itself. When the server does not answer, the logout
still completes here and says that the portal entry stays: remove it there with „Ohne Löschen
entfernen“. A revoked credential (logout, or „Entfernen“ of the token in the portal) also
removes the Workstation that registered with it. The next `deckname login` registers this
computer again, whatever signed it out, and withdraws a sign-out that still waited for it; a
service that merely starts again does not bring a signed-out Workstation back.

A service that runs already picks up a new sign-in at once: it watches `sync/workstation.json`,
follows the new Workstation's stream and looks at its jobs. A sign-in also replaces a service that
runs other code than the installed client (an update, or an edited editable install): the
service writes its version and a stamp of the package's files into `sync/service.json`, and
`deckname login` asks an outdated one to end (`sync/service.stop`), ends it after 10 seconds if it
does not, and starts the current one. A setup job counts the documents the server holds no
original of (admitted before the originals' key, `covered_only`): they are not fetched, the job
reports them as `skipped` (inside its done/total), the portal says „N Dokumente ohne Original,
nicht geholt.“ and `sync status` shows them in the row „Nicht geholt“.

**One server, one spelling.** `http://localhost:<port>`, `http://127.0.0.1:<port>` and
`http://[::1]:<port>` reach the same server, so the client keys them as one:
`credentials.json`, `sync/state.json` and `sync/workstation.json` store every loopback host as
`127.0.0.1` with its port. Files written before are merged when read (the newest credential of a
deployment wins). Signed in or not is kept per server, so a stale copy of one spelling never flips
the state of the other, and „Synchronisierung angehalten“ comes once per server, when it goes from
signed in to signed out. Its action „Anmelden“ opens `deckname login` in a console window of its
own.

**The background service.** One per user and computer for all folders, `deckname sync run`
(started by the autostart, never by hand), one instance. It hears its folders' changes from the
system and the server's from each folder's stream, and ends once no group is left, unless this
computer is a Workstation: then it keeps running for the portal's jobs, and the last `stop`
keeps the service and its autostart.

| System | Start now / autostart | Removed by the last `stop` |
|---|---|---|
| Windows | the venv's `python.exe -m deckname_client.cli sync run`, started with `CREATE_NO_WINDOW` (a console without a window); value `deckname-sync` under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`: the base install's `pythonw.exe -I …\deckname_client\sync\windows_start.py` with that command line | the value is deleted |
| macOS | `~/Library/LaunchAgents/deckname.sync.plist` (`RunAtLoad`, restarted only after a failure), `launchctl bootstrap gui/<uid>` | `launchctl bootout`, the file is deleted |
| Linux with `systemd --user` | `~/.config/systemd/user/deckname-sync.service` (`Restart=on-failure`), `systemctl --user enable --now` | `disable --now`, the file is deleted |
| Linux without it | a detached process; `~/.config/autostart/deckname-sync.desktop` | the file is deleted |

`--no-autostart` starts only the process, until you sign out of the system. No admin rights are
needed.

On Windows no console window opens, at the start and at logon. The venv's `pythonw.exe` and the
`deckname-syncw.exe` gui script are not used for it: in a `uv` venv `pythonw.exe` is the same
console launcher as `python.exe`, and started without a console it opened a black window whose
closing ended the service. The `Run` value cannot pass process flags, so it names the base
install's real `pythonw.exe` (no console) with `windows_start.py`, which starts the service with
`CREATE_NO_WINDOW`. Every sign-in writes the value again, so an older one moves over. The link `deckname://sync/<group_id>` is registered on the first `start` on Windows
(`HKCU\Software\Classes\deckname`) and Linux (a `.desktop` handler for
`x-scheme-handler/deckname`); macOS has none (the web offers „Befehl kopieren" there).

**What stays on this computer**, beside `credentials.json` in the client's per-user state
directory and never in a project: `sync/state.json` (per synced folder the group, the server,
the device id, the folder, its role and folder id, the group's folders, the cursor, per document
its fassung, hash and path, what waits in review, and your open reviews and overlaps with their
links — never a token; and the folder id of every folder that ever synced here), `sync/sync.log` (one JSON line per file
written, removed, put back or sent, in the launch log's six columns: a file name, never a path or
any text; `status -v` shows the last 20), `sync/service.log` (the service's own Log, one line per
start with pid, version and servers, per contact, job taken or ended with its counts, stop and
error; servers, job kinds and counts, never a document's text; rotated past 256 KB into
`service.log.1`), `sync/service.json` (the running service's pid, start and code) and the locks.
On Windows the folder is `%LOCALAPPDATA%\deckname-client\sync`.

### `deckname sync` — exit codes and stderr markers (a published contract)

Like `prepare`'s table: CI branches on the code, alert routing greps the marker. On a terminal
a failure is a status block on stderr („Nicht gestartet: … / Behebung: … / Geprüft: …") instead
of the marker; the code is the same. `--json` names the `error` instead.

| Exit | `error` | Meaning | stderr marker |
|---|---|---|---|
| `0` | — | done, also „läuft bereits" | *(silent)* |
| `1` | `confirm_required` | cancelled (Strg+C or „Abbrechen", without a marker), or `stop` without `--yes` and no terminal to ask | `[deckname:sync-confirm-required]` |
| `2` | `not_in_release` | the server's release has no `sync`: „In dieser Version nicht verfügbar." — argparse's usage error keeps the same number, without a marker | `[deckname:sync-not-in-release]` |
| `4` | `not_signed_in` | no `deckname login` for the server (or it was refused), or several and no `--server` | `[deckname:sync-not-signed-in]` |
| `5` | `server_unavailable` | the server could not be reached, or answered unreadably | `[deckname:sync-server-unavailable]` |
| `6` | `group_unknown` | no such group among yours — the same answer for a foreign and a missing one; for `--quelle` also a group that has a source (`reason: source_exists`), is not empty, where you only read, or a new group's name that is taken; for `--vorgang` a code that is unknown, not yours, closed or expired (`reason: vorgang_gone`), or `--vorgang` without `--quelle`; for `stop` a group synced in several folders here (name the folder) | `[deckname:sync-group-unknown]` |
| `7` | `too_large` | `--quelle`: the folder holds more files or bytes than one Akte's source may (the server's limits); nothing is registered | `[deckname:sync-too-large]` |
| `8` | `folder_unusable` | the folder is not writable, holds files that are not this group's, lies inside or around another sync folder, or (for `--quelle`) is missing, a drive or the home folder, or the source of another group already | `[deckname:sync-folder-unusable]` |
| `9` | `service_failed` | the background service could not be set up (`detail`: the system's message); nothing stays registered | `[deckname:sync-service-failed]` |

`3` stays free (`login` uses it). `7` once meant "local sync switched off", which the group sync
no longer has; since „Quelle zuerst“ (16.10.2026) it is `too_large`.

## `login` / `logout` — a workstation stops being nobody

`deckname login` signs THIS workstation in to a deployment. It opens a sign-in session on
the server and the browser at the portal page `/login/cli/<session>`, which names this machine
(hostname, OS, how long ago). There the person signs in if needed, presses „Anmelden“ and gets a
short code. The terminal asks „Code aus dem Portal eingeben:“, redeems the code and prints
„Angemeldet als <Name> auf <Rechner>.“. The code is bound to a verifier only this process holds
(PKCE-like), works once, expires after five minutes and allows five attempts. The credential is
stored outside every workspace (per-user, per-machine state, never `.env` and never anything a
checkout commits) and always acts as role user, never admin. Once signed in,
`deckname login --status` names who and where, without a new sign-in; `deckname logout`
signs this computer's Workstation out and revokes the credential on the SERVER first, then
removes it locally (synced folders are no longer synced, see `sync` above). A sign-in in the browser
also makes this computer the person's Workstation on that deployment and starts the sync
service, so the portal can set up working copies here (see `sync` above); `--token` does not.

```
deckname login                    # opens the portal page, asks for the code it shows
deckname login --status           # names the current account and deployment
deckname login --code             # no local browser: prints the address to open elsewhere, then asks for the code
deckname login --token <value>    # a credential already issued elsewhere (headless / CI)
deckname login --deployment <url> # target a deployment other than hook_server_url
deckname logout
```

This credential is **separate from** `DECKNAME_HOOK_AUTH_TOKEN`: that one is the
workstation's own static bearer for the `hook` command's `/api/anonymize` calls (see
"`declare` — generate the hook wiring instead of hand-editing it" above), shared by
everyone who checks out the same project. The `login` credential says who is signing in,
not which machine may call the anonymizer, and one workstation may hold one for each of
several deployments at once without either overwriting the other.

### Behaviour change: a gated deployment now refuses an unauthenticated hook read

`manual`/`four-eyes` deployments used to park every hook-sourced review under the
deployment's own implicit identity, so a workstation that had never run `deckname login`
still got a protected file reviewed and delivered — under an owner label no real person
could ever hold, and one that a four-eyes deployment could never exclude as its own
approver. **That stops on upgrade.** A `document_source` read (the `hook` command's
protected-file path) now carries this workstation's own `login` credential as an additive
header, and a deployment that gates reads (`manual`/`four-eyes`) refuses to park a review
it cannot attribute to a real, logged-in person.

What that means in practice: on a gated deployment, a workstation that has never run
`deckname login` gets every protected read withheld — `decision: "block"`, plus an
operator-facing message naming the fix on the terminal. **Run `deckname login` on that
workstation** and the read is restored on the next attempt; nothing else about the
declaration or the hook wiring changes. An `auto` deployment is unaffected either way —
nothing there was ever gated, and this change touches only the gate.

### Behaviour change: a stale `.claude/settings.json` withholds instead of leaking

`declare apply` derives the host `timeout` it writes into `.claude/settings.json` from the
review mode the deployment PUBLISHED at the moment it ran — 8 s under `auto`, 305 s under
`manual`, because a parked read waits on a person. Nothing re-runs `declare apply` when
that published word later changes, and a deployment upgrading to the current server picks
up `manual` as its default.

So a workstation that applied against an `auto` deployment carries a host timeout far too
short for a review wait. Left alone, the hook would engage the gate, wait its 300 s client
deadline, be killed by the host at 8 s with empty stdout — and Claude Code would deliver
the ORIGINAL, un-redacted file content to the model. **That is now detected and refused.**
`declare apply` records the deadline it derived
(`DECKNAME_HOOK_HOST_REVIEW_TIMEOUT_SECONDS` in the generated `env` block); the hook
compares it against the wait it is about to start and, when it does not cover it — or when
the key is absent, i.e. a settings file generated before this change — withholds the read
with `decision: "block"` and an operator-facing message.

What that means in practice: after upgrading a deployment to a gating review mode, **run
`deckname declare apply` once on each workstation**. `deckname declare check` reports the
drift beforehand without changing anything. An `auto` deployment is unaffected: no review
engages, so the check never runs.

### Behaviour change: on `four-eyes`, the person who caused a hook review can no longer approve it alone

A hook-sourced review is now parked under the logged-in workstation's own account
instead of the deployment's shared implicit identity — this is what the previous section's
attribution fix buys, beyond the block/restore behaviour itself. On a `four-eyes`
deployment the existing two-distinct-approvers rule follows that identity: **the person
whose workstation caused a protected read is excluded from casting the second, releasing
approval on that same review.** Previously the owner label was an implicit identity no
real approver could ever match, so that exclusion never applied to a hook-sourced review
and the requester could countersign their own read. It now behaves exactly like a review
opened through the browser or an MCP client — the requester may still cast the FIRST of
the two required approvals (the ordinary elicit flow), but a colleague's distinct
approval is required to release it.

### Behaviour change: MCP-parked and egress reviews get a personal owner too

The two sections above cover the hook's own `document_source` read. The same rule now closes
the gap on the two doors that were left behind: an `anonymize` call that gates, `submit_for_review`
called over MCP, and the PreToolUse egress vet that parks the cleartext-exception review for a
call the hook denied outbound.

**On upgrade:** when this workstation is signed in (`deckname login`) for the deployment AND the
session was started through `deckname launch`, a review parked through either MCP tool, or
through a denied outbound call, is now owned by the logged-in person — the same way a
hook-sourced review already is — and shows up in that person's own „Meine Prüfungen" list
instead of an implicit identity nobody can claim. As on the hook path, this changes ownership
only: which config applies, who may call which tool, and the response shape of every call are
all unchanged (`## Not this` in the milestone: identity is for attribution here, never for
policy or authorization).

**Two conditions, and what happens short of them.** A workstation with no `deckname login`
credential for the deployment, or a session started with `claude` directly rather than through
`deckname launch`, is a supported state, not a failure: the review still parks, exactly as it
always has, under the workstation's own implicit identity — not every MCP or egress review
becomes personally owned on upgrade, only the ones a logged-in, launcher-started session parks.
`deckname selftest` reports which case this workstation is in and names `deckname login` when
that is what is missing.

**On a `four-eyes` deployment, the person whose outbound call was denied can no longer cast the
releasing approval of their own cleartext exception.** Before this milestone the owner of that
review was an implicit identity no real approver could ever match, so the requester's own
approval alone could release their own exception. It now behaves like every other
personally-owned review: the requester may still cast the first approval, but a colleague's
distinct approval is required to release it.

## Group documents in Claude Code — the `group-documents` skill

The server's MCP entry in `.mcp.json` brings four tools for the documents of the groups the
signed-in person belongs to. Every one of them returns covered (anonymized) text only, and a
group the caller holds no role in is refused exactly like an unknown one:

| Tool | Returns |
| --- | --- |
| `list_groups` | the caller's groups, with role and document count |
| `list_group_documents` | one group's documents, newest first: covered titles, no text (at most 200, `truncated` beyond) |
| `search_group_documents` | matching covered passages in one group (`mode: "lexical"` or `"hybrid"`) |
| `get_group_document` | one document's full covered text |

Next to that entry, `declare apply` — and so `deckname setup` and the bare `deckname` run,
which call it — writes `.claude/skills/group-documents/SKILL.md`: a fixed English text that
tells the model when to look in the group documents (team documents, templates, contracts,
policies), to search with a few distinctive content words and retry with synonyms (the search
is lexical unless the answer says `hybrid`; under `hybrid` it also matches by meaning, so a
short phrase of what the user wants may follow), to cite title and group, never to guess what a
placeholder such as `<PERSON_1>` stands for, and to take the membership refusal at its word.
It is the same text in every workspace: no group name, title, path or account is written into
it. The file exists while the registration carries a credential (a `deckname login` credential
or a static `DECKNAME_HOOK_AUTH_TOKEN`), since the group tools refuse every other caller; the
next `declare apply` after the last credential is gone removes it. Like `protected-sources`, it
is advice to the model, not a barrier — the server decides who reads what.

## Group sync in Claude Code — the `group-sync` skill

Right after `group-documents`, `declare apply` (and so `deckname setup` and the bare `deckname`
run) writes `.claude/skills/group-sync/SKILL.md`: a fixed English text that tells the model how
to use `deckname sync` on the user's behalf — always with `--json`, since its shell is no
terminal; `status` whenever the user asks about the sync; `start`, `pause`, `resume` and `stop`
only when the user asked for exactly that, with the folder the user named; never `deckname sync
run` (the service's own) and never `deckname login` (the user confirms that in a browser); and
that it cannot approve a review. It explains every `state` and every exit code with its `error`
(the table above). It is the same text in every workspace: no group name, folder, device or
account is written into it — `deckname sync status --json` answers those live.

The file follows the registration and the server's release, asked once per run: it is written
while the registration carries a credential (as for `group-documents`) and the server names
`sync` among its release features; removed (with its folder, once empty) when the server
answers without `sync` or the credential is gone; and left exactly as it is when the server does
not answer. A workspace with no credential and no such file asks nothing. `deckname sync` itself
writes no skill — it is not run per workspace. Since 2026-10-15 it explains sources and working
copies, `start --quelle` and `stop` by folder, and that a folder changed in a sync folder
changes the group too.
