Skip to content

feat: CLI contract v2 names a refused credential or role - #583

Merged
wchwawa merged 1 commit into
mainfrom
feat/cli-contract-v2
Oct 26, 2026
Merged

wchwawa merged 1 commit into
mainfrom
feat/cli-contract-v2

Conversation

@wchwawa

@wchwawa wchwawa commented Oct 25, 2026

Copy link
Copy Markdown
Member

TLDR

The CLI contract moves to nokv.cli.contract.v2: a refused credential is
unauthenticated (exit 10) and a refused role or continuation is
permission_denied (exit 11), each with next_action: operator, instead of
both hiding in operator_required (exit 9). The Python SDK reports the same
names, the contract document says what an authenticating owner means for
identities, and the TLS gate expects the new classes. This is R8 of #566,
the last piece of the R1 to R4 plus R8 MVP.

Problem

Relates to #566.

Since RPC TLS (#575) and role authorization (#578), an owner refuses a
client for three different reasons: its certificate is not a current
credential, it holds no role for the request, or (since #582) it tries to
continue another principal's operation. The CLI reported all of them as
operator_required, the class for quarantined state and exhausted
resources, so a harness could not tell "register or renew my credential"
from "grant me a role" from "the owner needs a human". The contract
document still said that one root is bound to one Agent and that the
principal is not authenticated, which has been untrue since #578.

Consequences

An Agent harness branching on class had one branch for four situations
with different remedies, and operator_required lost its meaning as the
signal for state that only an operator can repair. A reader of the contract
was told the opposite of how a TLS owner behaves.

Example

A client whose certificate was revoked, before and after:

v1: {"class":"operator_required","next_action":"operator",…}   exit 9
v2: {"class":"unauthenticated","next_action":"operator",…}      exit 10

A reader that writes, or a principal renewing another principal's snapshot:

v2: {"class":"permission_denied","next_action":"operator",…}    exit 11

nokv version --json reports "cli_contract": "nokv.cli.contract.v2".

Solution

  • Two classes. ErrorClass::Unauthenticated and
    ErrorClass::PermissionDenied join the failure vocabulary with exit codes
    10 and 11 and next_action: operator; operator_required keeps 9 and
    narrows to durable or control state and TLS settings that do not fit the
    owner. A read reports them unchanged.
  • Mapping. The wire Unauthenticated is unauthenticated unless the
    owner marked it retryable (it cannot refresh its policy copy and asks for
    a retry), which is transient; the wire PermissionDenied is
    permission_denied. The handshake answers TlsRequired and
    Unauthenticated become a new TransportError::unauthenticated refusal;
    a TLS configuration or server-certificate failure stays
    operator_required. The Agent adapter's own code table carries both.
  • Python. The SDK's exceptions already carry error_class and
    next_action from the same vocabulary, so the new names flow through; its
    mapping table and surface test cover them.
  • Document. The exit table gains the two rows, the opening paragraph
    names what changed from v1, and the Scope section describes the
    authenticated principal: many Agents share a root, ids belong to the
    principal that claimed them, and a plaintext owner authenticates nobody.
  • Gate. The TLS gate expects unauthenticated for an unregistered,
    plaintext, or revoked client, permission_denied for a reader's write, a
    revoked role, and a snapshot renewed by another principal, and keeps
    operator_required for an owner-name mismatch.

Gain

A harness can branch on the remedy: renew the credential, ask for a role,
or call an operator. The contract document matches the owner's behavior
since #578, and the gates pin the new classes end to end.

Tradeoffs

  • A breaking contract. Scripts that compare class against
    operator_required or exit code 9 for a refused credential or role must
    update; the version string says so and the document names the change.
  • Python SDK over TLS. The SDK has no TLS yet ([FEATURE] Tenant identity, authentication, and RBAC with multi-agent access #566 R7), so it can
    report the two classes only once it does; the mapping is in place.
  • Retryable Unauthenticated is transient. The owner's own answer
    decides: a stale policy copy asks for a retry, a revoked credential does
    not.
  • No new exception types. Python keeps RuntimeError with the class
    attributes, as for every other class.

Validation

All runs used Rust 1.99.1 on macOS:

  • cargo fmt --all -- --check: clean.
  • cargo clippy --workspace --all-targets -- -D warnings and the four
    feature combinations CI lints: clean.
  • cargo test --locked --no-fail-fast --workspace: 1,348 passed, 0 failed,
    16 ignored. New tests: the wire codes and handshake refusals by class,
    the Agent adapter's table, the two exit codes and their next action; the
    published-table test and the Python surface test cover the document.
  • Live gates on the debug binary with moto: rpc_tls_gate.py 15 of 15
    with the new expectations, cli_contract_gate.py 6 of 6, and the direct
    Python SDK contract gate 6 of 6 on a maturin debug wheel.
  • Python: test_api_surface.py and the gate unit-test suites pass.

Not run:

  • Nothing over TLS from Python. The SDK cannot connect to a TLS owner
    yet, so the two classes were observed only through the CLI.

Size: 223 changed lines, under the 5,000-line review threshold.

The CLI contract moves to nokv.cli.contract.v2 with two classes for an
owner that authenticates its clients over RPC TLS: unauthenticated (exit
10) when the connection has no TLS the owner requires or its certificate
is not a current credential of the owner's tenant, and permission_denied
(exit 11) when the principal holds no role that allows the request or
continues an operation another principal began. Both hand the caller to
an operator. operator_required narrows to what only an operator can
change: durable or control state, or TLS settings that do not fit the
owner. An owner that cannot refresh its policy copy is transient, since
it asks for a retry.

The classes flow from the wire codes and the handshake answers through
the client, the Agent adapter, and the CLI, and the Python SDK reports
the same names on its exceptions. The contract document gains the two
rows, replaces its one-Agent-per-root scope with the authenticated
principal, and the TLS gate expects the new classes.

Relates to #566.

Signed-off-by: wchwawa <wch19961116@gmail.com>
@wchwawa
wchwawa requested a review from feichai0017 as a code owner October 25, 2026 01:57
@wchwawa
wchwawa merged commit 999e89c into main Oct 26, 2026
13 of 15 checks passed
@wchwawa
wchwawa deleted the feat/cli-contract-v2 branch October 26, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant