Skip to content

feat(xtest): typed SDK adapter protocol and a shim-backed implementation - #599

Draft
dmihalcik-virtru wants to merge 5 commits into
mainfrom
DSPX-4791-otdf-adapter-protocol
Draft

dmihalcik-virtru wants to merge 5 commits into
mainfrom
DSPX-4791-otdf-adapter-protocol

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Sep 15, 2026

Copy link
Copy Markdown
Member

Step A1 of replacing the three cli.sh shims with a typed Python adapter
layer. Behaviour-identical by construction — this lands the abstraction
with a single implementation that still shells out to cli.sh. No shell is
deleted.

Design: spec/DSPX-4791.md.

Why

xtest reaches every SDK through a hand-written bash shim. Three of them, 846
lines, of which 388 — 46% — are a supports case statement written out three
times: 46 named feature arms answering 20 distinct features, each one a
hand-rolled awk semver comparison, a help | grep probe, or a constant.

Rewriting the same version comparison 46 times is where the mistakes are.
sdk/go/cli.sh:83 compares $2 >= 2 under a comment on the line above that
says "Schema version 4.3.0 introduced hexless". The gate admits 4.2.

And the migration is already half-done: tdfs._uncached_supports
(tdfs.py:894) opens with a Python match carrying nine hardcoded answers
that override the shell before falling through to it. One concept, two
languages.

xtest is consumed by four repos that pin it at mainopentdf/platform,
java-sdk, web-sdk, otdfctl — each driving its own client, and none of
them can add a cell to the matrix without a PR here, because sdk_type is a
closed Literal in the suite's own source.

What landed

New otdf-adapter/ package, sibling of otdf-sdk-mgr and otdf-local and
built the same way (uv_build, src/ layout, own uv.lock and AGENTS.md).

Module Contents
protocol.py SdkAdapter Protocol, frozen EncryptRequest/DecryptRequest, SdkVersion. Imports nothing else in the package.
subprocess_cli.py SubprocessCliAdapterthe only implementation. Same argv, same XT_WITH_* env, same cli.sh.
gates.py MinVersion, HelpContains, Always, Delegate, Unprobeable, one evaluator. Tables empty.
registry.py otdf.adapters entry-point discovery.
descriptor.py Optional adapter.json, falling back to the cli.sh layout.

tdfs.SDK delegates to it while keeping its whole public surface:
encrypt_command/decrypt_command keep their keyword signatures and
(argv, env) return (fixtures/bench.py:363,374 unpacks them), the
constructor keeps its exact FileNotFoundError message
(conftest.py:362,373 catches it), and the nine-arm match is untouched.

Three decisions worth a second look:

  • supports() takes a container. It cannot change the answer yet — the
    shim has nowhere to put it. It is in the signature so that adding a
    per-container gate later is a change to gates.py, not to every caller a
    second time.
  • GATES is {}, deliberately. supports() consults the table and falls
    back to the shim when a feature has no entry, so an empty table is exactly
    today's behaviour. That is what lets the 46 arms be transcribed one row at a
    time, each reviewable against the bash it replaces, rather than in a flag
    day.
  • ztdf-ecwrap is split at the boundary into container="ztdf" plus
    ecwrap=True, so nothing downstream has to know that one of the container
    names is not a container.

Evidence that it is behaviour-identical

The rewritten test_sdk_commands.py is corroboration, not proof — it was
written after the change. The actual evidence is that the unmodified
pre-change test_sdk_commands.py (17 tests, 23 assertions) passes against the
refactored tdfs.py
. That file cannot have been written to match the new
implementation.

The rewritten suite reproduces every one of those assertions with only the
call shape changed — including TestDeterminism::test_builders_are_pure — and
grows to 25. It still asserts literal argv and literal env against a stub
cli.sh in tmp_path, not against a mocked adapter; asserting that
tdfs.SDK returns what SubprocessCliAdapter returns would be a tautology.

Ran locally:

  • test_sdk_commands.py — 25 passed
  • pre-change test_sdk_commands.py against the new tdfs.py — 17 passed
  • full xtest offline set (9 files, the exact CI command) — 216 passed
  • otdf-adapter own suite — 60 passed
  • ruff check, ruff format --check, pyright — clean in both packages

And on CI, the part that needs a platform: all 12 xct cells green — two
platform versions (main, v0.26.0) x three SDKs x two versions each.

The criterion was never "green", it was no change in pass/skip counts: a
skip that silently became a different skip would still be a behaviour change.
All 36 pytest invocations (12 cells x legacy / standard / attribute-based)
report counts identical to the last main-based run of this workflow
(34851123997):

Cell legacy standard attribute-based
main, go@main 7 145 / 1 163 / 40
main, go@v0.37.0 7 145 / 1 163 / 40
main, java@main 7 145 / 1 152 / 51
main, java@v0.18.0 7 145 / 1 152 / 51
main, js@main 7 141 / 5 116 / 87
main, js@v0.4.0 7 117 / 29 71 / 132
v0.26.0, go@main 7 145 / 1 160 / 43
v0.26.0, go@v0.37.0 7 74 / 72 90 / 113
v0.26.0, java@main 7 145 / 1 149 / 54
v0.26.0, java@v0.18.0 7 74 / 72 83 / 120
v0.26.0, js@main 7 141 / 5 116 / 87
v0.26.0, js@v0.4.0 7 60 / 86 40 / 163

Not verified: that none of the four repos pinning opentdf/tests@main needs a
change. Nothing here removes or renames anything they call, but that is an
argument rather than a run.

Deliberately deferred

  • Populating the gate tables. Transcribing 46 case arms is its own
    reviewable change; this PR lands the vocabulary so it has something to be
    reviewed against.
  • Native adapters. No go.py/java.py/js.py driving otdfctl,
    java -jar or npx directly.
  • Deleting any cli.sh. Not one line of shell is removed.
  • Opening the Literal types or touching the src/xtest/ layout.

One bug found and not fixed here

tdfs.py:834 writes XT_WITH_KAS_ALLOWLIST. All three shims read
XT_WITH_KAS_ALLOW_LIST (go/cli.sh:247-248, java/cli.sh:237-238,
js/cli.sh:294-295). One underscore, and the kasallowlist= option has
therefore never reached any SDK; its only caller is the unit test, which
asserts the Python-side spelling and passes vacuously.

It is preserved verbatim and recorded in KNOWN_SHIM_MISMATCH. Fixing a real
defect under cover of a refactor would give the first live run two independent
reasons to differ from the last one. It wants its own PR, where the matrix
result is the evidence.

CI

Adds an otdf-adapter lane to check.yml, mirroring the two sibling lanes.
It has no path dependencies, so it holds the strong form throughout:
uv sync --frozen --no-build and uv run --frozen --no-build.

The xtest lanes cannot, in exactly one step each. xtest depends on
../otdf-adapter as an editable path dependency, and --no-build refuses any
distribution without a wheel, which an editable install has none of by design.
So the three uv sync steps that install xtest drop it:

File Job Install step
check.yml scriptcheck uv sync --frozen --extra dev
xtest.yml MEASURE / bench uv sync --locked
xtest.yml RUN / zip64 uv sync --locked

Every step downstream of those keeps --no-build. They gain --no-sync,
which pins them to exactly the environment the install produced, and
--no-build then costs nothing because there is nothing left to install. The
resolution guarantee the old --no-build carried moves up to the sync, where
--frozen / --locked also catch a pyproject.toml edited without a re-lock.

The two xtest.yml sites matter more than they look: bench and zip64 are
nightly and workflow_dispatch only, so they are skipped on every PR. Left
alone they would have failed on the next nightly rather than here.

Known red checks

  • SonarCloud githubactions:S8541 on the three uv sync lines above,
    "omitting --no-build can lead to the execution of setup scripts". That is
    the trade described above and it has no fix short of dropping the editable
    path dependency: uv sync --frozen --extra dev --no-build fails with
    Distribution otdf-adapter==0.1.0 @ editable+../otdf-adapter can't be installed because it is marked as --no-build but has no binary distribution. What the rule guards against is a setup script in a package
    from this repo, built from this repo's lockfile. Each site is annotated in
    the workflow. Everything else in the diff, including the whole
    otdf-adapter lane, does use --no-build.
  • xct cells failing at "Prepare java cli" — if you see these on a
    re-run, they are not from this PR. The java SDK's maven build shells out to
    buf, which intermittently returns resource_exhausted: too many requests
    when several branches build concurrently. It happened on an earlier push
    here and cleared on the next one; the current run is 12/12 green.

otdf-adapter protocol
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4791-otdf-adapter-protocol branch from 46fc125 to eb6de95 Compare September 15, 2026 15:18
@github-actions

Copy link
Copy Markdown

X-Test Failure Report

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4791-otdf-adapter-protocol branch from eb6de95 to fcb332d Compare September 15, 2026 15:20
@dmihalcik-virtru dmihalcik-virtru changed the title feat(otdf-adapter): typed SDK adapter protocol and a shim-backed implementation feat(xtest): typed SDK adapter protocol and a shim-backed implementation Sep 15, 2026
@github-actions

Copy link
Copy Markdown

X-Test Failure Report

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4791-otdf-adapter-protocol branch from fcb332d to 617dbd8 Compare September 15, 2026 15:23
@github-actions

Copy link
Copy Markdown

X-Test Failure Report

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4791-otdf-adapter-protocol branch from 617dbd8 to da6d654 Compare September 15, 2026 15:35
@github-actions

Copy link
Copy Markdown

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4791-otdf-adapter-protocol branch from da6d654 to bb542cf Compare September 15, 2026 15:42
@github-actions

Copy link
Copy Markdown

X-Test Failure Report

The scaffold was a one-paragraph dump of the ticket description under seven
empty template headings. Restructured into the standard shape and every
file:line citation checked against the file it names.

Corrections to the counts the scaffold carried over:

- the supports case statements are 388 lines, not ~360, and they are 46
  percent of the 846 lines of shell rather than a minority of it;
- there are 46 named feature arms across the three shims answering 20
  distinct features, plus three catch-alls;
- the Python match in _uncached_supports has nine named arms and a case _,
  not ten hardcoded answers;
- test_sdk_commands.py had 17 tests and 23 assertions, not 18 cases.

Also records a latent bug found while reading: tdfs.py:834 writes
XT_WITH_KAS_ALLOWLIST and all three shims read XT_WITH_KAS_ALLOW_LIST, so
the kasallowlist= option has never reached an SDK.
New package, sibling of otdf-sdk-mgr and otdf-local and built the same way
(uv_build, src/ layout, its own uv.lock and AGENTS.md).

  protocol.py       SdkAdapter Protocol, frozen EncryptRequest/DecryptRequest,
                    SdkVersion. Imports nothing else in the package, so a
                    consumer's adapter can depend on the protocol without
                    depending on the subprocess machinery.
  subprocess_cli.py SubprocessCliAdapter, the only implementation. Builds the
                    same argv and the same XT_WITH_* env as xtest does today
                    and execs the existing cli.sh.
  gates.py          MinVersion, HelpContains, Always, Delegate, Unprobeable,
                    and one evaluator. The tables are empty on purpose.
  registry.py       otdf.adapters entry-point discovery.
  descriptor.py     optional adapter.json, falling back to the cli.sh layout.

Three things worth knowing:

supports() takes a container. cli.sh supports <feature> cannot express a
per-container capability, so today the suite answers per-SDK and re-derives
the rest at the call site. The argument cannot change the answer yet; it is
in the signature so that adding a per-container gate later is a change to
gates.py rather than to every caller a second time.

GATES is {} and supports() falls back to the shim when a feature has no
entry, so an empty table is exactly today's behaviour. That is what lets the
46 case arms be transcribed one row at a time instead of in a flag day.

Delegate and Unprobeable exist because the shims had no way to say either.
A capability with no CLI surface is written as a bare exit 1, which the
suite reports with the same message as a build that is simply too old.

60 tests, none of which need an SDK or a platform: they write a stub cli.sh
into tmp_path. Gates being inert data is what makes that possible, and it is
the only way a transcription of fifty capability rules gets reviewed.
Behaviour-identical by construction. tdfs.SDK keeps its entire public
surface and delegates the argv and env construction it used to do inline.

What is preserved deliberately, because callers depend on it:

- encrypt_command/decrypt_command keep their keyword signatures and their
  (argv, env) two-tuple return -- fixtures/bench.py:363,374 unpacks them;
- the constructor still raises FileNotFoundError with the same message --
  conftest.py:362,373 catches it to report an SDK that was named but never
  installed;
- the nine-arm match in _uncached_supports is untouched and still runs
  before the adapter is consulted. Folding it into the gate tables is the
  last step of the migration, not this one;
- XT_WITH_KAS_ALLOWLIST keeps its misspelling. All three shims read
  XT_WITH_KAS_ALLOW_LIST, so that option has never reached an SDK; fixing
  it here would give the first live run two reasons to differ from the
  last one. Recorded in KNOWN_SHIM_MISMATCH and left for its own change.

ztdf-ecwrap is split at the adapter boundary into container="ztdf" plus
ecwrap=True, so nothing downstream has to know that one of the container
names is not a container.

test_sdk_commands.py is rewritten against the new API with every existing
assertion reproduced and only the call shape changed, 17 tests to 25. The
added cases cover the ecwrap split, target mode surviving it, and tdfs.SDK
delegating byte-identically to the adapter. It still asserts literal argv
and literal env against a stub cli.sh in tmp_path, and TestDeterminism
still pins the builders as pure.

The evidence that this is behaviour-identical is not that suite, which was
written after the change. It is that the unmodified pre-change
test_sdk_commands.py -- 17 tests, 23 assertions -- passes against the
refactored tdfs.py unaltered.

No shell is deleted.
New otdf-adapter lane in check.yml, mirroring the two existing sibling
lanes. It has no path dependencies, so it holds the strong form throughout:
uv sync --frozen --no-build and uv run --frozen --no-build.

The xtest lanes cannot, in exactly one step each. xtest depends on
../otdf-adapter as an editable path dependency, and --no-build refuses any
distribution without a wheel, which an editable install has none of by
design. So the three `uv sync` steps that install xtest drop it:

  check.yml  Install dependencies       --frozen --extra dev
  xtest.yml  MEASURE / bench            --locked
  xtest.yml  RUN / zip64                --locked

Every step downstream of those keeps it. They gain --no-sync, which pins
them to exactly the environment the install produced, and --no-build then
costs nothing because there is nothing left to install. The resolution
guarantee the old --no-build carried moves up to the sync, where --frozen
and --locked also catch a pyproject.toml edited without a re-lock.

The two xtest.yml sites matter more than they look: bench and zip64 are
nightly and workflow_dispatch only, so they are skipped on every PR. Left
alone they would have failed on the next nightly, not here.
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4791-otdf-adapter-protocol branch from bb542cf to 411d494 Compare September 15, 2026 16:10
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

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