Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ contracts (kernel purity, headless annotator) are described there and enforced i

| Doc | Covers |
| --- | --- |
| [workspaces.md](workspaces.md) | The workspace on disk: layout, `init`/`open`, project-name uniqueness, and how services are composed |
| [workspaces.md](workspaces.md) | The workspace on disk: layout, `init`/`open`, which workspace a surface resolves to, project-name uniqueness, and how services are composed |
| [projects.md](projects.md) | The project lifecycle: the 1:1 dataset, renaming, and what deletion does and does not destroy |
| [sources.md](sources.md) | Where raw data comes from: the two registration methods, what a video source records from the probe, why decomposition parameters live on the source, and the idempotency rule and its named uniqueness gap |
| [ingest.md](ingest.md) | Turning a source into rows: content identity versus recorded origin, the two source paths, why the decode happens outside a transaction, the run's lifecycle and pollable progress, and the per-file report |
Expand All @@ -21,4 +21,4 @@ contracts (kernel purity, headless annotator) are described there and enforced i
| [persistence.md](persistence.md) | The metadata store: repositories, unit of work, table layout, migrations and `format_version` |
| [examples.md](examples.md) | The two runnable examples: the whole cycle in one pass, ingest on its own, and what each is built to demonstrate |
| [api.md](api.md) | The REST surface: the one error body, why clients branch on `code` and not on the status, what decides 404 / 409 / 422, what a 5xx does and does not tell you, and which codes are worth retrying |
| [auth.md](auth.md) | Who may call it: per-workspace API tokens, why only a digest is stored, why every refusal is one identical 401, immediate revocation, and how a protected route is built |
| [auth.md](auth.md) | Who may call it: per-workspace API tokens, why only a digest is stored, why every refusal is one identical 401, immediate revocation, the `visionset token` commands, and how a protected route is built |
60 changes: 51 additions & 9 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ scoped to one workspace, and holding a valid one means holding the whole workspa
permissions are deliberately not here.

```
visionset token create --name ci # prints the secret once — issue #26
visionset token create --name ci # prints the secret once, on stdout
visionset token list # names, created, revoked — never secrets
visionset token revoke ci # asks first, unless you pass --yes
```

```
Expand Down Expand Up @@ -63,6 +65,41 @@ Nothing caches a verdict. "Revoked, therefore refused" has to mean *now*, so the
the workspace on every call. That read is cheap: WAL readers never block a writer, and a
read-only unit of work takes no lock at all.

### At a terminal

`ConfirmationRequired` exists because the kernel has no terminal and no user; each surface asks in
its own idiom and passes the answer down. The CLI's idiom is a prompt, skipped by `--yes`:

```
$ visionset token create --name ci
Created token 'ci' in /srv/vision. ← stderr
vst_YnYwMfhwzqRqfg5VjaoHwvoUFb6bx42GVYeTaICVCwI ← stdout
This secret is shown once and cannot be recovered. Store it now. ← stderr

$ visionset token list
NAME CREATED REVOKED
ci 2026-07-28T01:43:47Z -

$ visionset token revoke ci
Revoke token 'ci'? Every client holding its secret stops working, and this cannot be undone. [y/N]:
```

**Stdout is data; stderr is everything a person reads.** The secret is the only thing on stdout, so
`TOKEN=$(visionset token create --name ci)` is exactly the secret and the warning survives the
redirection that most needs it. `token list` names its three columns one at a time rather than
dumping the model, so neither the secret nor its digest can reach the terminal by accident — a
digest is not a secret, but it verifies a guess offline.

Revoking is resolved **by name**, which is why token names are unique case-insensitively. Revoking
an already-revoked token exits 0, says when it died, and does not prompt: a retried command must be
safe, and asking somebody to re-confirm a thing already done invites a "yes" that means nothing.
A token that does not exist exits 1 with the kernel's own sentence.

Exit codes are the whole error contract at a terminal: **0** success, **1** any domain refusal as
one sentence on stderr, **2** a usage error Click raises itself. There is no per-error code — a
shell branches on zero versus non-zero, and a person reads the sentence. That is the deliberate
difference from the REST surface, where a client branches on `code` because it is a program.

## What a refusal looks like

A missing header, a non-bearer scheme, an empty credential, an unknown token and a revoked token
Expand All @@ -85,22 +122,27 @@ wrong thing.

## Which workspace the server serves

One, named by **`VISIONSET_WORKSPACE`**, defaulting to the process's working directory. It is
opened by the first request that needs it and kept for the life of the process — never at import
time, because `scripts/export_openapi.py` imports the application in a checkout that has no
One, resolved by the same rule the CLI uses — `kernel/services/workspace_service.py::
resolve_workspace_root`. A server started by import string has no argv of its own, so of the four
branches it can only reach two: **`VISIONSET_WORKSPACE`**, then the nearest workspace at or above
the working directory. The precedence table and the argument for why only that last case walks
upward live in [workspaces.md](workspaces.md#which-workspace-when-nobody-said).

It is opened by the first request that needs it and kept for the life of the process — never at
import time, because `scripts/export_openapi.py` imports the application in a checkout that has no
workspace.

> **Changed in #26.** A server started *below* a workspace with no `VISIONSET_WORKSPACE` set now
> serves that workspace, where it used to answer 500 `NOT_A_WORKSPACE`. That is the cost of one
> resolver instead of two, and the asymmetry that keeps it safe is that a *stated* root — the
> variable here, `--workspace` at the CLI — is never traded for its parent.

A server pointed at something that is not a workspace answers **500 `NOT_A_WORKSPACE`**, opaque
body plus an `incident_id`, with the path in the log only. That is a deployment fault, not a
client error. It arrives *instead of* a 401 even when no token was sent, because the workspace is
resolved before the credential is looked at — the ordering is what keeps authentication
overridable in tests.

> The full resolution rule — a `--workspace` flag, the environment variable, cwd detection, and a
> documented precedence shared with the CLI — belongs to issue #26.
> `server/dependencies.py::resolve_workspace_root` is the provisional half, reading the variable
> #26 will keep.

## For contributors

Build every non-public router with **`protected_router()`**:
Expand Down
42 changes: 41 additions & 1 deletion docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ directory is also non-empty, and "open it instead" is the useful message.
Emptiness is strict — a stray `.DS_Store` is enough to refuse. That matches the contract
literally and keeps `init` from ever writing into, say, a git repository root. Worth
revisiting when the `visionset init` CLI command lands, where a friendlier rule may earn
its keep.
its keep — that command is also the one that will have to decide whether `init` with no
path means "here" or means the resolver's answer, since the resolver deliberately walks
upward and `init` must not.

If anything fails midway, `init` removes what it created and nothing else: the whole
directory if `init` made it, otherwise just the database and `blobs/`. "Fails safely" means
Expand Down Expand Up @@ -135,6 +137,44 @@ on open would break opening a workspace on a read-only mount — a shared datase
read-only bind mount in a container — and would make `open` non-idempotent for no invariant
gain. Read `root`; treat `root_dir` as a possibly-stale hint.

## Which workspace, when nobody said

`init` and `open` are handed a path. Deciding *which* path is a separate question, and it has one
answer for every surface: `resolve_workspace_root(explicit=None)`, beside `DB_FILENAME` in
`kernel/services/workspace_service.py`. It lives there because it is the same fact read from the
other end — the database file is what marks a directory as a workspace, and this is what goes
looking for the mark. It cannot live in either caller: import-linter forbids `visionset.server`
importing `visionset.cli`, so the rule the two share has to sit above both.

Precedence, first match wins:

| | Source | Walks up? |
| --- | --- | --- |
| 1 | `explicit` — the CLI's `--workspace` / `-w` | no |
| 2 | `VISIONSET_WORKSPACE`, when set to something non-empty | no |
| 3 | the nearest directory at or above the working directory holding a `visionset.db` | **yes** |
| 4 | the working directory | — |

A server started by import string has no argv, so it reaches only 2, 3 and 4.

**Only case 3 walks, and that asymmetry is the whole rule.** A flag and an environment variable are
somebody *stating* which workspace. If the stated directory holds none, walking to its parent and
quietly minting a credential into whatever workspace lives up there is the worst thing this
function could do. Git draws the line in the same place — discovery walks up, `--git-dir` and
`GIT_DIR` do not — and for the same reason.

**Finding nothing is not an error here.** This names a directory; `open` owns "is this a
workspace?" and already raises `NotAWorkspace` naming the path it was given. A refusal here would
be two errors for one condition, and would make the function non-total for the server, which calls
it inside a lazily opened handle that expects exactly one failure mode.

**Nothing is normalized.** `_resolved` is the one place a path becomes canonical and it runs inside
`init`/`open`; expanding `~` twice is how two spellings of one workspace start looking like two
workspaces.

An empty `VISIONSET_WORKSPACE` falls through to case 3 rather than resolving to `Path("")`, because
a shell cannot tell `VISIONSET_WORKSPACE=` from an unset variable.

## `format_version` lives in one place

The database stamp in `_visionset_meta` is the sole authority. There is no sidecar marker
Expand Down
83 changes: 83 additions & 0 deletions src/visionset/cli/_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# usage: from visionset.cli._errors import domain_errors
"""What a kernel refusal looks like at a terminal: one sentence, and an exit code.

The CLI's half of the contract ``server/errors.py`` keeps over HTTP, and
deliberately much smaller. A REST client branches on a machine-readable ``code``
because it is a program; a shell branches on zero versus non-zero, and a person
reads the sentence. So there is **one** non-zero code for the whole
``VisionSetError`` family rather than a table mapping each error to its own — a
second public contract to keep in sync with ``ERROR_RULES`` for a distinction no
caller makes. If a command ever needs "not found" told apart from "refused" by
exit status, the table goes here, beside the constant.

Three exit codes, and no others:

===== =========================================================================
0 the command did what it said
1 a ``VisionSetError`` — one sentence on stderr, no traceback
2 a usage error, raised and formatted by Click itself
===== =========================================================================

**Nothing here prints to stdout.** Stdout is the command's *output* — the secret
from ``token create``, the rows from ``token list`` — so that redirecting it
captures data and nothing else.
"""

from __future__ import annotations

from collections.abc import Iterator
from contextlib import contextmanager
from typing import Final

import typer

from visionset.kernel import NotAWorkspace, VisionSetError
from visionset.kernel.services import WORKSPACE_ENV_VAR

EXIT_DOMAIN_ERROR: Final = 1
"""Every ``VisionSetError``. See the module docstring for why it is not a table."""

_HINTS: Final[dict[type[BaseException], str]] = {
# The kernel's sentence ends in "use WorkspaceService.init to create one",
# which is a Python API a person at a terminal has no way to call. Rewriting
# it in the kernel would bend a domain message toward one surface; a second
# line here is the surface adding its own remedy, which is what a surface is
# for.
NotAWorkspace: f"Point at one with --workspace, or set {WORKSPACE_ENV_VAR}.",
}
"""A remedy a *terminal* can act on, printed under the error's own sentence.

Walked by MRO, like ``server/errors.py`` walks ``ERROR_RULES``, so a subclass
inherits its nearest ancestor's hint. Sparse on purpose: most kernel messages
already carry their own remedy, and a hint that restates the message is noise.
"""


def _hint_for(exc: VisionSetError) -> str | None:
for cls in type(exc).__mro__:
hint = _HINTS.get(cls)
if hint is not None:
return hint
return None


@contextmanager
def domain_errors() -> Iterator[None]:
"""Turn any kernel refusal into a readable line and a non-zero exit.

``typer.Exit`` rather than ``sys.exit``: Click catches it and ``CliRunner``
records it as ``result.exit_code``, which is what makes the exit status
assertable from a test instead of only from a subprocess.

**Only ``VisionSetError`` is caught.** An ``OSError``, a ``KeyboardInterrupt``
or a bug is not a refusal the CLI understands, and folding one into
``Error: [Errno 2] ...`` would hide the traceback that identifies it.
"""
try:
yield
except VisionSetError as exc:
typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
hint = _hint_for(exc)
if hint is not None:
typer.echo(hint, err=True)
raise typer.Exit(code=EXIT_DOMAIN_ERROR) from exc
76 changes: 76 additions & 0 deletions src/visionset/cli/_workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# usage: from visionset.cli._workspace import WorkspaceOption, opened_workspace
"""Which workspace a command operates on, and its lifetime inside that command.

The **rule** lives in the kernel (``resolve_workspace_root``); this module is the
CLI's half of it — the flag that feeds the rule, and the ``with`` block that opens
and closes what it names. The rule cannot live here: import-linter forbids
``visionset.server`` importing ``visionset.cli``, and the server needs the same
answer.

``--workspace`` is declared **per command** rather than on the root callback, and
that is a Click fact rather than a preference. A group's parser stops at the first
non-option token, so an option on ``@app.callback()`` must *precede* the
subcommand: ``visionset --workspace X token create --name ci`` would work and
``visionset token create --name ci --workspace X`` would fail with "No such
option". Nobody types the first one. The alias below is how one flag is declared
once and still belongs to every command that needs it.
"""

from __future__ import annotations

from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Annotated

import typer

from visionset.cli._errors import domain_errors
from visionset.kernel.services import WORKSPACE_ENV_VAR, WorkspaceService, resolve_workspace_root

WorkspaceOption = Annotated[
Path | None,
typer.Option(
"--workspace",
"-w",
help=(
f"The workspace to operate on. Defaults to ${WORKSPACE_ENV_VAR}, then to "
"the nearest workspace at or above the working directory."
),
# No ``envvar=``. Click would read the variable itself and hand the
# command a path indistinguishable from this flag — and the flag and the
# variable are deliberately not the same case: neither walks upward, but
# only one of them is what the resolver's precedence calls "explicit".
# One function owns precedence; this is only how a path gets typed.
#
# No ``exists=True`` either. A Click path check exits 2 in Click's
# wording, where the useful answer is ``NotAWorkspace``'s sentence and
# its hint at exit 1.
),
]
"""``--workspace`` / ``-w``, for a command that needs an open workspace.

Module-level so that ``get_type_hints`` resolves it in the importing module's
globals under ``from __future__ import annotations``; an alias built inside a
function body would not resolve.
"""


@contextmanager
def opened_workspace(explicit: Path | None = None) -> Iterator[WorkspaceService]:
"""The workspace this command was pointed at, open for the length of the body.

Closes on the way out, including when the body raised. A process that exits
without checkpointing leaves ``visionset.db-wal`` beside the workspace for
the next reader to recover, and a CLI runs often enough for that to matter.

Domain errors from **both** the open and the body become one line on stderr
and exit 1. ``close`` runs first, so the message is printed against a
workspace that is already released.
"""
with domain_errors():
workspace = WorkspaceService.open(resolve_workspace_root(explicit))
try:
yield workspace
finally:
workspace.close()
22 changes: 1 addition & 21 deletions src/visionset/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
import typer

from visionset import __version__
from visionset.cli.tokens import token_app

app = typer.Typer(
name="visionset",
help="Robomous VisionSet — local-first dataset creation for computer vision.",
no_args_is_help=True,
)
token_app = typer.Typer(help="Manage API tokens.", no_args_is_help=True)
app.add_typer(token_app, name="token")


Expand Down Expand Up @@ -48,23 +48,3 @@ def ui() -> None:
def mcp() -> None:
"""Start the MCP server on stdio (stub)."""
typer.echo("server would start here")


@token_app.command("create")
def token_create(
name: Annotated[str, typer.Option("--name", help="Human-readable token name.")],
) -> None:
"""Issue an API token (stub — see issue #26).

Persistence landed with the kernel's ``TokenService``; wiring this command to
it needs workspace resolution, which issue #26 owns along with ``token list``
and ``token revoke``.

Until then this refuses rather than printing something. It used to echo a
plausible ``vst_...`` string that was never stored — harmless while nothing
could authenticate, and actively misleading now that real tokens exist and
that one would not be among them.
"""
typer.echo(f"Cannot issue token {name!r} yet: the CLI has no workspace to write it to.")
typer.echo("Token issuance lands in issue #26 (visionset token create/list/revoke).")
raise typer.Exit(code=1)
Loading
Loading