Skip to content

build: import the stack-* crates, node bindings and Go module - #1001

Draft
auxesis wants to merge 823 commits into
refactor/typescript-to-languagesfrom
build/import-stack-crates
Draft

auxesis wants to merge 823 commits into
refactor/typescript-to-languagesfrom
build/import-stack-crates

Conversation

@auxesis

@auxesis auxesis commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

This PR brings six Rust crates, three bindings and the Go module into this repository from cipherstash/cipherstash-suite, with 807 commits of their history. It then makes them build and test here. The crates are stack-auth, stack-profile, stack-kms, stack-encrypt, stack-encrypt-derive and stack-guest-abi. The bindings, which let JavaScript call the Rust code, are @cipherstash/auth, @cipherstash/profile and stack-auth-wasm. The Go module lands in languages/golang.

This is PR B of 6 in the stack crates import, which moves this code here from the private suite repository. The stack crates import plan in Linear lists every step, and Linear issue CIP-4274 tracks the work.

This PR is stacked on PR A, #1000. Its base branch is PR A's branch, so the diff shows only this PR's own changes. It stays a draft until the freeze on Friday 2 October 2026, Pacific time. The freeze is the window in which the six PRs merge in order: #1000, #1001, #1003, #1002, #1009, then #1010. The steps for that day are in §9.1 of the plan.

Check the import merge by its file list, and read the 15 later commits

The first commit, d0cea7cc, merges the suite's history into this repository. It adds 408 files, so check its shape rather than reading it:

  • git diff --name-status b3ffccd4 d0cea7cc prints 408 lines, and every one is an added file (A). No existing file changes.
  • git rev-list --count d0cea7cc^2 prints 807, the number of suite commits it brings in.
  • The merge holds no edits of its own. Every fix is a later commit.

Read the 15 commits after the merge line by line. You can skim three parts of them:

  • 2371f731 adds four Cargo.lock files, which hold 15,526 of its 16,410 added lines.
  • fa05f243 reformats 21 files and changes nothing else.
  • a981590a renames the Go module in 36 files, 53 lines. A script on the migration machine, stack-migration/go-rename.sh, generated it.

The import merge keeps the suite's history and changes no file

The merge brings in an export: a copy of the suite's history that keeps only the files that move. git filter-repo, a tool that rewrites history to keep chosen paths, made it from suite commit e059b8ed. The export's head is 82a85b4d, with 807 commits and 408 files, and its history starts on 11 February 2026.

The two histories share no commit, and the merge changes nothing inside the files. The suite's 13 workflows land in .github/imported-workflows/, where GitHub never runs them, and the next commit deletes them. PR C ports the CI.

Commit messages name suite PRs as cipherstash/cipherstash-suite#NNNN. That way, GitHub does not link them to this repository's PRs with the same numbers.

Each commit after the merge makes one change

  1. 359a6f18 cleans up the imported files. It deletes the imported workflows and stray files, and fixes links in the moved docs. It marks @cipherstash/profile private, because it was never published. It also points the Go guests' dependencies at crates.io, because the suite folders they named do not exist here. The guests are the Rust crates that the Go module runs as WebAssembly.
  2. 6bb5775d stops Biome, the formatter and linter, from changing two kinds of file. The first is the 15 files that @cipherstash/auth publishes. The release gate, scripts/release-gate.mjs, runs on every push to main before anything publishes to npm. From commit 6, it compares those 15 files byte for byte with version 0.44.0 on npm, so a reformat would make it fail. The second is generated files.
  3. fa05f243 reformats the imported bindings with this repository's Biome config. The suite used double quotes and semicolons, and this repository does not. The commit holds formatting changes and Biome's safe fixes only.
  4. d94d6a77 fixes two lint errors that the reformat brings to light in the auth binding's tests. The suite's linter was off for these files. A missing failure now fails the assertion, instead of throwing a TypeError.
  5. 2371f731 makes the repository root a Rust and Go workspace. It adds the root Cargo.toml and Cargo.lock, and lockfiles for the three crates that hold the fuzz tests. It refreshes the two Go guests' lockfiles, and those five crates each stay a separate Cargo workspace. It also adds entries for mise, pnpm and turbo, and splits the binding scripts so build and test never run cargo. mise is the tool that pins this repository's tool versions and runs its tasks.
  6. 8c86f4bf freezes @cipherstash/auth and its six platform packages in the release gate. Each platform package holds the native binary for one platform, such as darwin-arm64. A frozen package fails the gate if its version changes, or if its published files differ from npm's copy. The commit also adds Dependabot entries, and a temporary check, lint-no-auth-changeset, that fails if a pending changeset names an auth package. A changeset is a file in .changeset/ that says which packages a change releases.
  7. 542fa511 adds the new workspaces to the repository checks that list every workspace, such as the checks on Cargo publish settings and lockfile freshness.
  8. 1c52c8f6 describes the crates, bindings and Go module in AGENTS.md, SECURITY.md, CODEOWNERS and CONTRIBUTING.md.
  9. f7b00319 moves the six cargo test tools into mise.test.toml, at exact versions. mise passes the root config down to every subfolder, so the EQL and protect-ffi CI jobs built these tools too. They built them with the runner's Rust 1.92 and failed, because cargo-udeps 0.1.61 needs Rust 1.93. mise reads mise.test.toml only for test runs, so those jobs no longer get the tools.
  10. 8e8dd7ab makes the profile binding write its generated typings to native.d.ts, as the auth binding does. Before, its build overwrote the hand-written index.d.ts, which left the tree changed and the package's types wrong.
  11. 0dc76af1 builds the auth and profile bindings in tests.yml before the test run. Without the build, pnpm run test failed to load the profile binding. This step started in PR C and moved here, so PR B passes CI on its own.
  12. 5147dd00 gives cts-common and zerokms-protocol caret version requirements, such as ^0.43.0. A caret requirement accepts any later compatible version. The published stack-auth inherits these requirements, so an exact pin would stop the suite sharing one copy of cts-common with it. The lockfiles still hold the exact versions, so no resolved version changes.
  13. a981590a renames the Go module from github.com/cipherstash/cipherstash-suite/bindings/go to github.com/cipherstash/stack/languages/golang. The old path names a folder in a private repository, so go get could never fetch it. Nothing outside the module imported the old path, and no version of it was ever tagged.
  14. d3524b87 adds a test that go.mod declares the new path, and that no file outside docs/plans/ names the old one. A later export from the suite could bring the old path back, and this test would catch it.
  15. 1ad1055d fixes wasm:wasi-check, which could never fail in CI. The check searches cargo tree output for crates such as wasm-bindgen, which must never be in a build for WASI. WASI is the WebAssembly System Interface that the guests target. CI turns on coloured output, and the colour codes stopped the search matching. The check now asks for --color never, and fails when a deliberate break adds wasm-bindgen to stack-kms.

The shared suite crates come from crates.io

The imported crates depend on four crates that stay in the suite: cts-common 0.43.0, cllw-ore 0.5.0, recipher 0.3.1 and zerokms-protocol 0.12.31. The root workspace takes them from crates.io. Phase 0 of the plan released them from suite main, and their source matches suite main.

Publishing stays off until PR E

This PR brings @cipherstash/auth and its six platform packages here, but the release gate freezes them. A version bump of any of them makes the gate fail, so nothing publishes. PR E removes the freeze.

The checks pass locally and in CI

  • cargo metadata --locked passes in all 8 Cargo workspaces. It fails if a lockfile is out of date.
  • The full build passes.
  • nextest passes 759 of 759 tests, including the trybuild UI snapshots. nextest is the Rust test runner that CI uses, and it runs each test in its own process. A trybuild UI snapshot compiles code that must fail, and compares the compiler's messages with a saved copy.
  • cargo tree -d lists crates that appear in more than one version. It shows one cts-common and one set of vitaminc crates.
  • go:test and go:lint pass.
  • pnpm install --frozen-lockfile, code:check and test:scripts pass, with 1,020 script tests.
  • The release gate passes. It fails, as it should, on a test version bump of @cipherstash/auth.
  • CI: 16 checks pass and 11 are skipped.

Plain cargo test fails 3 tests, so CI uses nextest

Plain cargo test --workspace fails 3 stack-kms tests. A test helper sets environment variables while other tests run in parallel, so the tests race. nextest runs each test in its own process, so CI passes. PR C adds a test that stops any workflow running plain cargo test over the workspace.

A Developers team member must merge this PR

main requires signed commits. git filter-repo rewrote the 807 suite commits, so they carry no signature. A member of the GitHub Developers team must merge this PR, and bypass only the signature rule.

Merge it with a merge commit, which keeps every commit, and never with a squash or a rebase. A squash would replace the 807 suite commits with one, and lose the history that this PR exists to keep.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a

coderdan and others added 30 commits August 31, 2026 17:53
…ld is never handed the caller's context

Review of the context-parameter commit found the derive's where clause
and the docs disagreeing in both directions. A row whose fields all carry
literals left `__Ctx` unbounded, so `encrypt_into_with_context(&cipher,
tenant)` compiled against it and silently dropped `tenant` — while the
docs said the compiler turns that form away. And a `from` field with no
literal made the impl demand `SuppliedContext` regardless of the field's
type, so a row nesting another (all-literal) row lost the context-free
forms it had before, and the context the caller was then forced to pass
never reached a leaf. The same bound landed on `DecryptInto` for one-way
term fields that decryption never opens.

One rule replaces all of that: a `from` field is derived under its own
`context`, or under `()` if it has none — never under the caller's. Its
type decides whether `()` will do: a nested row accepts it, a leaf refuses
it at the field (the obligation is checked in the body, spanned at the
field type) until it is given a literal. A row therefore never passes the
caller's context anywhere and is implemented for `Ctx = ()` exactly, which
makes `encrypt_into` / `decrypt_from` the only forms that compile, as
documented. Handing every column of a row one shared context was the
cross-column transplant the per-field contexts exist to prevent, so
nothing legitimate is lost; `#[stash(row = ..)]` fills the literal in.

The derive's context bound is now `__Ctx: Clone` — the per-field bounds
already imply `EncryptContext` / `DecryptContext`, and the extra impl
lifetime went with it. `Record::derived()` replaces three inlined filters
and the by-field candidate list is computed once.

Also from the review:

- `EncryptFrom` / `DecryptInto` `on_unimplemented` notes said "if `{Ctx}`
  is `()` … use `encrypt_into_with_context`" unconditionally, which never
  fired for `()` (the `SuppliedContext` note wins) and gave false advice
  on a source-type mismatch. Both now state the rule; `SuppliedContext`'s
  note covers `from` fields and how a context type of your own opts in.
- The "extend with your own SEM type" recipe guarded with
  `context.as_bytes().is_empty()` on an encoded `PrfContext`, which is
  never true. `is_degenerate_aad` / `is_degenerate_prf_context` are
  public; the recipe and the `PrefixTerm` example use them.
- `DecryptFrom<S, C>`'s blanket impl had dropped its `S: DecryptInto`
  clause, so the trait held for every triple and meant nothing as a
  bound. It mirrors `EncryptInto` now: no trait parameters.
- Decrypt leaves bound `Ctx: SuppliedContext<'c>` alone; it implies
  `DecryptContext`.
- Stale spellings from the rename: `encrypt_into::<EqualityTerm>(..)`,
  two-parameter `EncryptFrom<P, _>` / `DecryptInto<P, _>`, and the design
  doc's `#[encrypted(source = ..)]` / "omit `source`".

Pinned by `tests/ui/row_with_context.rs` (`_with_context` against a row),
`tests/ui/from_leaf_without_context.rs` (a `from` leaf with no literal,
reported at the field) and `a_row_nests_in_a_row_without_a_context`.

Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr
…_context sugar

Review fixes on the context-parameter change:

- A derived `DecryptInto` generic over the caller's context bounds it by
  `DecryptContext` again. A term field's `DecryptField` accepts any
  context (it opens nothing), so field bounds alone let a record whose
  ciphertext field carries a literal accept — and silently discard — any
  `Clone` value as its decrypt context. Pinned by an expansion test and a
  `compile_fail` doctest on `DecryptContext`.
- `encrypt_into_with_context` / `decrypt_from_with_context` bound
  `Ctx: SuppliedContext`, so `()` is refused on the sugar and misusing a
  row now gets the guided E0277 on the decrypt side too, not a bare
  E0308.
- `SuppliedContext: DecryptContext` is declared, not hand-mirrored, so
  the documented implication holds by construction; the transitional
  `is_degenerate_*` predicates now say in rustdoc that they are deleted
  when vitaminc#291 lands.
- The empty-`context` derive error no longer advises a `from` field to
  drop the attribute — a dead end, since a `from` field is never handed
  the record's context — and the check runs after parsing so attribute
  order cannot change the advice.
- Derive internals: one `FieldContext` classification replaces the four
  parallel projections of literal/unit/caller; the impl scaffolding is
  shared between the two derives; encrypt-side field bounds are spanned
  at the field type as decrypt's already were; the stray raw derived-
  field filter uses `Record::derived`, computed once per expansion.
- The hand-written composite examples inherit the leaves' context policy
  through per-field bounds — the clauses the derive emits — instead of
  restating it, and RFC 0002 no longer claims a row "leaves the context
  unbounded" where the implementation is `()` alone.

Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW
The Naming section still recorded `#[derive(Encrypted)]` as the macro
name, and the implementation notes still called the derive unbuilt and
the row snippet "future". `stack-encrypt-derive` ships
`#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]` under `#[stash(..)]`,
each named after the trait it emits — which is also why the sketched
noun could not stand: it names neither trait, and one noun cannot cover
both directions.

Claude-Session: https://claude.ai/code/session_01VEKAfJiDDhSxEZRAVJQJPX
Review findings from cipherstash/cipherstash-suite#2163:

- The derived encrypt impl now bounds its caller context by
  `EncryptContext`, mirroring the decrypt side. Without it, a
  third-party leaf generic over its context let the raw
  `EncryptFrom::encrypt_from` accept — and silently discard — any
  `Clone` value as its context. Pinned by
  `a_caller_context_must_be_an_encrypt_context`.
- The `encrypt_into_with_context` sketch in the design doc now shows
  the `Ctx: SuppliedContext<'c>` bound the shipped signature has.
- The `seal_pending` doc no longer names `encrypt_into::<StackCipherText>`,
  a call that no longer compiles.
- The `SuppliedContext` doc no longer claims coverage of composites
  containing `()`, and the crate re-exports `IntoPrfContext` /
  `PrfContext` so a context newtype needs no direct `vitaminc-prf`
  dependency.

Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW
…s `from` and context

A row no longer needs an attribute on any field. `row = User` says the
record is a row of the struct `User`: every derived field is derived from
the plaintext field of its own name, under the context
`"<snake_case type>/<plaintext field>"` — `age` from `user.age` under
`"user/age"`, a tuple row's `.0` under `"user/0"`. Both halves name the
column, not the encrypted struct, so `#[stash(from = email_address)]` (the
override for a name that differs) is derived under `"user/email_address"`;
`#[stash(context = "..")]` is taken verbatim. Nothing is pluralised or
otherwise guessed. `row` is exclusive with `plaintext`, and must name a
struct directly.

Because the inferred context is the AAD of every stored ciphertext in the
column, renaming the plaintext type or a field is a data migration; the
docs say to pin the old literal with `context = ".."` first.

A field the plaintext does not have is reported by rustc at the field
(`tests/ui/row_field_missing.rs`); `row` + `plaintext` at the attribute
(`tests/ui/row_with_plaintext.rs`).

Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr
…nested` hands a field `()`

`row = User` now requires `context = "users"` beside it; each field is
derived under `"<context>/<field>"`. The prefix is part of the stored
data's identity — the AAD of every ciphertext in the row and the domain
of every term — so it is never inferred from the Rust type's name: two
plaintext types with the same name in different modules can no longer
silently share every column context (byte-identical index terms across
their tables, ciphertexts transplantable between them), and renaming a
struct can no longer silently change the AAD of every stored row.

`#[stash(nested)]` opts a row field out of the inferred context: it is
handed `()`, which a nested row accepts and a leaf refuses — the
()-handoff the docs promised now exists in row mode, not only under
`plaintext = ..`.

Also addressed from the same review:

- `supplied_aad` / `supplied_prf_context` are the one public choke
  point for validating-and-encoding a supplied context; the
  `is_degenerate_*` predicates return to crate-private, and the
  third-party-leaf recipe goes through the choke point, whose signature
  survives the vitaminc#291 migration.
- `DecryptField` carries a `#[diagnostic::on_unimplemented]` pointing a
  term-only bundle inside an auto-mode record at `#[stash(decrypt)]`.
- The deferred exactly-one-decryptable check for generic records is
  pinned by a `compile_fail` doctest on `Decryptable` (trybuild runs
  `cargo check`, which never evaluates post-monomorphization consts, so
  a ui test cannot reach it).
- The empty-container fail-fast loss and the caller context a
  literal-carrying record discards on decrypt are documented where they
  bite (the container impls, `DecryptContext`, the attributes guide).
- The `SuppliedContext` roster notes its coupling to vitaminc's
  `IntoAad` implementor list and the orphan-rule consequence.
- Both derives build field bounds through one shared
  `push_field_bounds`, and the generic-source vs listed-plaintexts
  emission fork collapses to one loop per derive.

Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW
Missed in bd3b61824: docs/target-directed-encryption.md and RFC 0002 §7
still described the row prefix as inferred from the snake-cased type
name.

Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW
…ke point

`is_degenerate_aad` went back to crate-private when `supplied_aad`
became the one public way to validate and encode a supplied context, so
the design doc's `StackCipherText` bridge no longer matched the impl it
quotes.

Claude-Session: https://claude.ai/code/session_01VEKAfJiDDhSxEZRAVJQJPX
Review finding on cipherstash/cipherstash-suite#2164: the row decrypt now runs before the
field-alone plan decrypt, so the counter reads exactly 1 where the
other counter assertions in the file are exact too, instead of the
`>= 1` that hid the total.

Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW
…h/claude/stack-encrypt-derive-row

feat(stack-encrypt-derive): `#[stash(row = User, context = "users")]` infers each field's `from` and context
…ASI without reqwest

Phase 1 of the stack-encrypt Go bindings (docs/stack-encrypt-go-bindings.md).
On wasm32-wasip1 reqwest 0.13.4 selects a native backend (tokio-full and the
aws-lc-sys TLS provider) that does not build for WASI, and it was the only
thing standing between the three stack crates and the target. HTTP is
host-provided under wazero, so it has to be out of the WASI build by
construction:

- `http` feature, default on, in all three crates:
  stack-encrypt/http -> stack-kms/http -> stack-auth/http -> dep:reqwest.
  stack-auth keeps the token model, `AuthStrategy`, `AuthStrategyFn` and
  `StaticTokenStrategy` unconditionally; every HTTP-speaking strategy, the
  refresh engine, device binding and `Token::refresh` sit behind the feature.
  stack-kms keeps `Client<C>`, key derivation and the key-source traits;
  `HttpConnection`, its options and `StackKmsBuilder` sit behind it.
  stack-encrypt keeps `StackCipher::builder().kms(..)`; `StackCipher::new()`
  and the from-environment `init` sit behind it. stack-kms and stack-encrypt
  take stack-auth as a path dep with `default-features = false` (a workspace
  dep's defaults cannot be turned off by a member).
- `StackKms<C, Conn = HttpConnection>` with `StackKms::connect(opts,
  credentials, client_key)`: the transport-injecting constructor a host with
  its own `ZeroKMSConnection` builds through, and the only one without
  `http`. `ZeroKMSConnection` gains `ensure_base_url` / `has_base_url` so
  endpoint discovery from the token's `services` claim works over any
  connection (previously inherent to `HttpConnection`).
- `Error::ConnectionInit` boxes the connection's own init error.
- `StackCipher::builder()` lives on `impl StackCipher<FromEnv>` so it
  resolves without a type annotation whether or not `http` is on.
- `wasm:wasi-check` now gates stack-auth, stack-kms and stack-encrypt
  (`--no-default-features`) alongside the core crates; the CI workflow's
  paths cover them.
- A unit test drives `StackKms` end to end over the in-memory
  `TestConnection`.

Verified: `mise run wasm:wasi-check` passes for all eight crates with no
reqwest/hyper/aws-lc-sys in any wasip1 tree; `mise run lint` clean; 451
tests pass with `--all-features` and with `--no-default-features`; doc tests
and rustdoc (`-D warnings`) pass.

BREAKING CHANGE: stack-auth's `RequestError` tuple payload is now
`Box<dyn std::error::Error + Send + Sync + 'static>` instead of
`reqwest::Error`. Construct it via `RequestError::from(reqwest_error)`
(or box the error yourself) instead of `RequestError(reqwest_error)`, and
recover the concrete error with `.0.downcast_ref::<reqwest::Error>()`
instead of using `.0` as a `reqwest::Error` directly. `source()` behaviour
is unchanged; the `Display` message is now "Request to the auth server
failed" (previously "HTTP request failed").

Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr
…h item-level http gates

Without the http feature the crate previously silenced dead-code analysis
entirely via #![cfg_attr(not(feature = "http"), allow(dead_code))]. Gate
the eleven affected helpers (URL massaging, clock sharing, refusal
classification, token setters, AccessKey secret access) individually with
#[cfg(feature = "http")] instead, so the compiler verifies the feature
partition in both directions: no-http code reaching an http helper fails
to compile, and newly dead code warns instead of being swallowed.

Test fallout handled so no coverage regresses in the no-http run:
- workspace_crn tests assign Token.region directly instead of the now
  http-only set_region, keeping workspace_crn covered without http
- test_refresh_debug_does_not_leak_tokens loses its http gate (it never
  needed HTTP) so the Debug secret-leak regression test runs in the
  no-default-features shape the WASI guest ships
- http-only test helpers (TestClock, crn_with_workspace,
  jwt_with_workspace, classify_issuance_failure_tests) are gated with
  their consumers

The one honest residual is AccessKey's inner field: parsing is
unconditional token-model API, but only the http-gated AccessKeyStrategy
consumes the secret, so that single field keeps a scoped
cfg_attr(not(http), allow(dead_code)).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD
Feature additivity (the two API-shape bugs):
- stack-auth: RequestError has one definition with an always-boxed payload;
  the http-gated From<reqwest::Error> does the boxing. Its public field's
  type no longer changes under feature unification, which would have broken
  a no-http host the moment anything else in its graph enabled http.
- stack-encrypt: Error::Config is unconditional with a boxed source (the
  http-gated From<StackKmsBuilderError> boxes), so the enum's variant set
  no longer tracks the feature.

Build config:
- stack-auth's workspace entry is now default-features = false (the same
  pattern as cllw-ore/cts-common); stack-kms and stack-encrypt use the
  workspace dep again instead of hand-written path+version pins, and the
  consumers that rely on the default transport re-enable it with
  features = ["http"] (cipherstash-client, cipherstash-cli, cts-web
  dev-dep, stack-auth node/wasm bindings). One version pin remains, at the
  root. Cargo.lock is unchanged.

Docs and doctests:
- cargo test --no-default-features now passes including doctests in all
  three crates: http-only examples (README via include_str, token_store,
  StackKmsBuilder quick-start, StackCipher::new) are doc-gated on the
  feature with short no-http fallbacks, and the no-http rustdoc's broken
  intra-doc links are fixed. RUSTDOCFLAGS=-D warnings is clean in both
  shapes; default-features doctest coverage is unchanged.

API cleanups:
- ZeroKMSConnection::ensure_base_url / has_base_url get default impls
  (no-op / true) with the first-value-wins contract documented, so
  transports that don't do endpoint discovery (TestConnection, and hosts
  that pin at init) no longer stub them; HttpConnection keeps its
  overrides.
- The unused From<ConnectionInitError> for Error impl is deleted;
  StackKms::connect is the one construction path for Error::ConnectionInit.
- StackCipherBuilder::new() (+ Default) is the canonical builder entry
  point; StackCipher::builder() stays as a thin alias on the phantom
  FromEnv impl for annotation-free inference.
- token.rs's scattered per-test http gates collapse into one gated
  refresh_tests module; make_token and the Debug secret-leak test stay
  ungated so they keep running without http.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD
…-web dev-dep

wasm:wasi-check only runs cargo check, so the no-default-features shape's
unit tests, doctests, and rustdoc had no gate — the 7 doctest failures and
21 broken intra-doc links fixed in the previous commit merged green. Add
wasm:no-http-test (per-crate cargo test + RUSTDOCFLAGS=-D warnings cargo
doc, default features off, one crate per invocation so dev-dep feature
unification can't switch http back on) and run it from test-wasi.yml,
whose path filters already cover the three crates.

cts-web's stack-auth dev-dep only uses StaticTokenStrategy, which is part
of the unconditional token model, so it doesn't need the http feature.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD
Keep HttpConnection failure Display to the status line — full body and
headers can carry sensitive response content into logs and are unbounded;
they remain available via Debug. Align RequestError's message with its
transport-agnostic shape: the payload is no longer necessarily an HTTP
error, so say 'request to the auth server failed' instead.

Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb
…x Phase 1 contract docs

Review follow-ups from cipherstash/cipherstash-suite#2158:

1. `cargo test -p stack-encrypt --no-default-features` still resolved
   reqwest: the stack-kms dev-dependency didn't set
   `default-features = false`, so dev-dep feature unification pulled
   stack-kms/http -> stack-auth/http -> reqwest into the graph
   `wasm:no-http-test` claimed was HTTP-free. Disable defaults on the
   dev-dep (the tests only need `test-support` for FakeDataKeySource;
   none are HTTP-dependent). Verified:
   `cargo tree --no-default-features -e normal,dev,build -i reqwest`
   matches nothing, and the full no-default-features test/doctest run
   passes.

2. The plan doc named StaticTokenStrategy as part of the unconditional
   no-http surface, but it is (correctly) gated behind
   `cfg(any(test, feature = "test-utils"))` and has no production
   users. Revise the Phase 1 contract to name AuthStrategyFn (and the
   guest's HostTokenStrategy) as the supported production path;
   StaticTokenStrategy stays a test double.

Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb
… manifests

Review follow-up on cipherstash/cipherstash-suite#2158: two manifest comments still described
StaticTokenStrategy as part of the unconditional no-`http` surface,
contradicting the corrected plan and the actual
`cfg(any(test, feature = "test-utils"))` gate. Name AuthStrategyFn as
the production path and state where the test double lives.

Comments only; no dependency or feature change.

Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb
The variant had {0} in the message and #[source] on the same field, so
chain printers (anyhow {:#}, tracing) showed the inner error twice —
against this file's own convention that Display stays static and the
source chain carries the detail. Flagged by Toby on cipherstash/cipherstash-suite#2158.

Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW
The Go/wazero binding needs byte formats both languages agree on before the
guest is written. This freezes the two commitments stack-encrypt makes:

SealedValue leaf: to_bytes/from_bytes with the canonical layout
`version(1) ‖ iv(16) ‖ tag_len(u16 LE) ‖ tag ‖ local_ciphertext`. The
version byte is bound into every leaf's AAD through a new labelled
derivation — PAE("stack-encrypt/leaf", version, derived_aad, tag), replacing
the unlabelled (aad, tag) tuple — mirroring how vitaminc binds its inner
wire version, so bytes relabelled with a future version byte fail
authentication instead of parsing under the wrong rules. The derivation
bytes are pinned by a unit test; decode failures surface as the new
LeafBytesError.

Index terms: every term type now exposes its frozen encoding.
EqualityTerm is the 32 PRF bytes as-is and OreTerm/OpeTerm are the raw CLLW
ciphertext bytes — byte-identical to what the EQL layer hex-encodes into
hm/oc/op, so rows written through a binding compare against rows the
Rust/EQL path wrote. MatchTerm encodes its sorted positions as
little-endian u16s (EQL sends bf as a JSON integer array, so the
byte-string form is this crate's own). cllw-ore's variable-width
ciphertext types gain length-validating TryFrom<&[u8]> for the decode path
(the direction the existing From<Vec<u8>> FIXME asks for).

tests/frozen_bytes.rs carries the golden vectors (fixed hex the Go decoder
tests against) plus structural-rejection and real-leaf round-trip coverage;
tests/term_bytes.rs continues to pin the derivations these encodings wrap.

Part of CIP-3553 (stack-encrypt Go bindings), phase 2 of
docs/plans/stack-encrypt-go-bindings.md.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD
…te layout

Expand SealedValue's frozen-encoding rustdoc with an offset table for the
v1 envelope and a diagram of the two nested framings (this crate's
envelope around vitaminc's LocalCipherText), spelling out where each
version byte lives and which AAD derivation binds it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD
…e formats

- cllw-ore variable-width types: one private length predicate per type,
  applied by both `from_bytes(Vec<u8>) -> Result` and `TryFrom<&[u8]>`.
  The unchecked path is now `from_bytes_unchecked` (replacing the FIXME'd
  `From<Vec<u8>>`); it stays `pub` and `FromHex` stays permissive because
  cipherstash-client's ste_vec terms carry a tagged bit stream (65/66
  bytes) that the byte-aligned rule would reject. Callers renamed.
- `SealedValue` is valid-by-construction: `from_parts` and serde
  deserialisation reject tags longer than the u16 length field, so
  `to_bytes()` is infallible. `TryFrom<&[u8]>` rustdoc no longer claims a
  generic-codec justification.
- Document that the labelled, versioned `leaf_aad` cannot open leaves
  sealed under the phase-1 AAD (plain AEAD failure, no version signal).
- Reword the "Byte encodings" docs: same shape as v1 / cipherstash-client
  terms, values are not comparable across the two.

Claude-Session: https://claude.ai/code/session_01AhxMmV52dSYwjAT8KENHRV
… byte APIs

- `MatchTerm::from_positions`/`from_bytes` reject positions outside the
  filter size fixed by `O: MatchConfig`, so a mis-decoded position list
  fails loudly instead of producing a term that never matches.
- Replace the stringly `TermError::MalformedTermBytes(&str)` with a
  `TermBytesError` enum (`PartialEq`), mirroring `LeafBytesError`; CLLW
  decode failures report the offending length rather than the
  deliberately opaque `cllw_ore::Error`.
- Every term type now exposes `to_bytes()` and `TryFrom<&[u8]>`;
  `as_bytes()` only where a contiguous buffer exists. Plan doc states the
  per-type surface instead of "on every term type".
- Reframe the match term's LE-u16 byte string as the frozen FFI transport
  encoding; the stored and queried contract is the position list.
- Scope the `SealedValue` byte-format commitment to ciphertext and link
  the index-term encodings.

Claude-Session: https://claude.ai/code/session_01AhxMmV52dSYwjAT8KENHRV
- Move the sealed-leaf AAD breaking change out of `SealedValue`'s rustdoc
  and into a new packages/stack-encrypt/CHANGELOG.md. Release history does
  not belong on the type.

- Drop the cross-reference from `SealedValue`'s public docs to the
  `cipher` module docs. `mod cipher` is private, so its module-level
  `//!` docs render nowhere in `cargo doc` output except the source
  listing — the pointer sent readers to documentation the HTML does not
  contain. The leaf-AAD derivation it referred to is already stated inline.

- Write `LocalCipherText` and `Aes256Cipher` as code spans rather than
  intra-doc links. Both are private imports of vitaminc types, so rustdoc
  resolves them only when dependency docs are built; under `--no-deps`
  (what CI and `wasm:no-http-test` run) the links silently degrade to
  literal `[Name]` text, with no warning. Code spans render the same
  either way.

- Mark `LeafBytesError`, `TermBytesError` and `TermError`
  `#[non_exhaustive]` so the versioned decoders can gain variants without
  a source break.

Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb
The seal path constructed SealedValue directly from the DataKeyWithTag a
DataKeySource returned, without the tag_fits_length_field check every
other construction site applies. A custom source returning a tag longer
than the u16 length field would (in release builds) produce a leaf whose
to_bytes saturates the length field but appends the whole tag, so
from_bytes no longer inverts the encoding.

seal_leaf now validates the generated tag before building the leaf, and
a seal-path boundary test drives encrypt through a DataKeySource that
inflates its tags past u16::MAX, asserting the seal fails rather than
mis-encodes.

Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW
The cipher module was private with its items re-exported at the crate
root, so the 68 lines of module-level internals documentation (batching,
AAD derivation, wire format) never appeared in cargo doc output — the
crate docs pointed readers at src/cipher.rs source instead. The module
is now pub (matching sem and target) and the crate docs link to it.

The rustdoc lints this surfaces (a link to the private leaf_aad, two
redundant explicit StackKms link targets) were fixed in the previous
commit; cargo doc is warning-free with and without default features.

Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW
…h/claude/stack-encrypt-wasi-phase2

feat(stack-encrypt): freeze the byte formats the crate owns (WASI phase 2)
…raits

Two additive API changes the WASI guest (next commit) needs:

- PendingStackCipherText::into_pending — turn a pending tree into a
  Pending request carrier without settling it, so several independently
  built trees (e.g. one per record field, decoded from FFI values that
  are not Clone) merge with Pending::zip/all and seal in one batched
  generate_keys call. seal() is now expressed through it; same sealing
  path either way.
- sem re-exports CllwOreEncrypt/CllwOpeEncrypt: they already appear in
  the module's public bounds (ore_term, OreTerm, ...), so a caller
  writing a generic wrapper over the term APIs has to be able to name
  them without depending on cllw-ore directly.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD
…se 3)

The wasm32-wasip1 guest at bindings/go/stackencrypt/guest (a detached
workspace, like the fuzz crates), per phase 3 of
docs/plans/stack-encrypt-go-bindings.md. Control stays in Rust: request
assembly, key derivation, batching and AAD/PRF context binding run
unmodified inside the guest; the host provides exactly two imports.

- Exports (vitaminc guest ABI conventions: buffer registry with zeroizing
  dealloc, packed-u64 results, hostile-input validation, no handle-id
  reuse): se_alloc/se_dealloc, se_cipher_init/se_cipher_free,
  se_encrypt/se_decrypt (+_element), se_encrypt_record/se_decrypt_record,
  se_term. Status codes 1-4 match vitaminc's; 5-11 map the ZeroKMS
  request outcomes and term failures so the Go caller can tell a bad
  token from a tampered ciphertext.
- Host imports (module cipherstash_transport): transport_send —
  cipherstash/cipherstash-suite#2099's import generalised to (method, url, headers, body) with
  'name: value' line headers — and token_get (phase-1 auth: the host
  owns minting and refresh). WasiHostConnection implements
  stack_kms::ZeroKMSConnection over it, with the endpoint pinned from
  the init config or discovered from the token's services claim;
  HostTokenStrategy implements stack_auth::AuthStrategy over token_get.
- Values cross in the vitaminc FFI codec (one codec, shared with the
  vitaminc guest); ciphertext-tree leaves are the frozen phase-2
  SealedValue byte encoding, so a leaf lifted out of a tree is exactly
  what a database column holds.
- Records: a plan {field -> {context, outputs: [c|eq|match|ore|ope]}}
  drives per-field ciphertexts and locally derived terms; all rows of a
  batch seal in one generate_keys via the new
  PendingStackCipherText::into_pending. Terms ride the result tree as
  passthrough bytes nodes.
- Native tests (26) run the same ops the ABI drives against
  FakeDataKeySource: codec round trips, native-decryptable leaves,
  term bytes equal to the native sem derivations, a counting key source
  pinning the one-call batching, and status mapping for hostile inputs.
  The release .wasm's import surface is exactly WASI +
  cipherstash_transport.
- mise tasks: wasm:guest:build, wasm:guest:test.

Extracting the shared ABI modules into a common vitaminc crate is a
follow-up in the vitaminc repository; the registry/session modules here
are copies with pointers back. bridge.go and the integration harness
land with the Go module (phases 4-5).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD
…nsports

`HttpConnection` and the WASI guest each carried their own copy of the
same table: 2xx must be JSON and deserialize, and 404/401/403/409 map to
specific `ViturRequestErrorKind`s so callers can tell a bad token from a
missing keyset without parsing strings. Two copies of a protocol contract
drift.

Move it to `connection::classify`, outside the `http` feature gate, so a
guest built without `http` reaches the same verdicts as the default
transport. `HttpConnection` now reads the response once and delegates;
`BaseUrlUnresolved`, `FailureResponse` and `UnexpectedContentType` are
public so a host bringing its own transport can return the same errors.

`Display` on the two response errors stays the concise form landed in
f3c87fbcc for cipherstash/cipherstash-suite#2158 — status and expectation only. Body and headers are
unbounded, attacker-influenced text and these types' `Display` reaches
logs; both remain available through `Debug`.

Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb
`ClientKey::from_hex_v1` is strict lowercase hex, but the encodings a
user actually holds are not: `secretkey.json` serialises standard padded
base64, and hex pasted from elsewhere may be upper case. Front-ends that
take key material from an untyped boundary — an environment variable, a
config file, the WASI guest's FFI config object — were rejecting valid
keys for their encoding alone.

Add `from_encoded_v1`, the lenient counterpart, matching what
`SecretKey::from_hex` and `EnvKeyProvider` already accept. Decoding stays
constant-time (`base16ct` / `base64ct`) and the intermediate bytes are
wiped on every path.

Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb
claude and others added 4 commits September 29, 2026 15:42
The struct-tag source's search for facts tags inside embedded structs
keeps the structs on its path, so a recursive embedding (type Node struct
{ *Node; ... }) is searched once rather than forever; a struct's own
self-embedding is not searched at all, since its fields are the struct's
own, already read.

Plan.Validate refuses a nil type with the error PlanFromTags gives,
instead of dereferencing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJc73YNcTeGaLDhP2P3LNM
…facts tags

All() with no matchers matched every field, so a rule built from a slice
that came back empty decided every field below it and could leave a
classified field out of the plan with no error. Any() with none never
fired. Both now panic where the rule is written, as a nil matcher does.

Also pin the fail-closed refusal of a facts tag reached through an
embedded pointer, which no test covered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YjmHJEM12fRq8XKwg7eaH
The embedded *list and *node fields exist only to make the types
recursive; nothing reads them, so golangci-lint's unused check fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YjmHJEM12fRq8XKwg7eaH
@changeset-bot

changeset-bot Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1ad1055

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

The ORE and OPE string encodings run cllw-ore's orderize_string first:
it decomposes each character canonically and drops anything that is
not alphanumeric, whitespace or ASCII punctuation. The Go property test
compared random Unicode strings by their raw UTF-8 bytes, so it failed
whenever collation changed a string. For example, a string of
private-use characters collates to "" and orders before any string with
a letter in it. The fixed cases had the same problem: "\x7f" collates
to "" too. Suite CI never ran this live test, because it needs
credentials, so stack's CI port was the first to run it.

Generate the random strings from characters that collation leaves
unchanged, including multi-byte letters with no decomposition. Move
"\x7f" out of the byte-order cases, and check that pairs differing only
in dropped or decomposed characters give equal terms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
auxesis added a commit that referenced this pull request Oct 2, 2026
mise merges config down the directory tree, so every tool in the root
mise.toml is also part of the toolset in packages/eql and in
languages/typescript/packages/protect-ffi. Their CI jobs run
`mise install` (and `mise run`) in those folders, so each one now built
cargo-nextest, cargo-llvm-cov, cargo-crap, cargo-mutants, cargo-fuzz and
cargo-udeps from source, none of which they use.

mise's cargo backend compiles a `cargo:` tool with the runner's default
rustc (1.92 on the Ubuntu images), not the root's pinned 1.94.1. So a
`latest` that raises its minimum Rust fails the install: cargo-udeps
0.1.61 needs cargo@0.96, which needs rustc 1.93. That broke the
protect-ffi integration suite on #1001 and the new udeps job on #1003.

The cargo tools move to mise.test.toml, which mise loads only for the
`test` environment (`mise x --env test`, or MISE_ENV=test). Nested
workspaces no longer inherit them. Each is pinned to an exact version
that builds on the runners' rustc; cargo-udeps goes back to 0.1.60.
The tasks that call them (crap:*, mutants:*, fuzz:*, and nextest in
wasm:guest:test and wasm:auth-guest:test) now run them through
`mise x --env test --`, as test:doc and the crap coverage step already
did, so `mise run <task>` keeps working locally without MISE_ENV.

Rust, Go, golangci-lint and wasm-pack stay in the root mise.toml. They
are prebuilt downloads, and plan section 3.5 has protect-ffi inherit
the Rust pin. That section's intent holds: the repository pins one
version of each cargo tool, reached through mise. The pins now sit in
the test environment rather than the default one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
auxesis and others added 16 commits October 2, 2026 17:50
…stack-crates

Import the stack-* crates, the node bindings and the Go module from
cipherstash-suite with their history (plan §7.2). The export is suite
main e059b8ed5a5c8bd6c2f13ec621de8efbc4e2f433 run through git
filter-repo; its head is 82a85b4.

This merge is left untouched. Every fix is a separate commit on top.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Plan §7.3, the first commit on top of the untouched import merge.

- Delete .github/imported-workflows/. The 13 suite workflows travelled for
  history only; PR C ports what they did.
- Delete packages/stack-auth/package-lock.json, an empty stray npm lock.
- Delete the nested biome.json files in the auth and profile bindings, so
  the root Biome config covers them.
- Mark @cipherstash/profile and its six platform packages private. It was
  never published, and without the flag the release gate and changesets
  would treat 0.35.0 as a JS package to publish with no binaries.
- Point the Go guest crates' cts-common, zerokms-protocol and recipher
  path dependencies at the Phase 0 crates.io releases (=0.43.0, =0.12.31,
  =0.3.1). Those paths do not exist in stack. The guests' Cargo.lock
  files are refreshed in the workspace commit, once the root workspace
  they reach through the stack-* path dependencies exists.
- Make the imported docs pass `lint:package-paths`, which fails on the
  merge with seven references to crates that stayed in the suite:
  - docs/fuzzing.md: trim to the set's six targets, dropping the three
    cts-common ones. The suite keeps its side of the shared file.
  - docs/auth-strategy-handover.md and docs/wasm-analysis.md: the export
    moved them from the root into docs/, so their relative links gain
    `../` or lose `docs/`.
  - Name suite crates such as cts-common, cllw-ore and cipherstash-client
    and the vitaminc crates without a path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Plan §7.3, before the reformat, so `code:fix` leaves these files alone.

- target/ and **/target: Cargo build output at the new root workspace.
- The auth binding's wasm/, built by build:wasm.
- Every file @cipherstash/auth publishes from the tree: index.js,
  stack-auth-node.js, the four .mjs modules, the seven .d.ts files,
  README.md and LICENSE (plan §13.6). The release gate freezes the
  wrapper by comparing these bytes with @cipherstash/auth@0.44.0 on npm.
  The suite formatted them with double quotes and semicolons, so without
  the ignores the reformat rewrites ten of them and the gate refuses the
  tree. native.d.ts and index.d.ts are also generated by napi.
- The profile binding's index.d.ts, which napi generates.
- languages/golang/, which has no JavaScript Biome should own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Plan §7.3: `pnpm run code:fix` after the Biome ignores, in its own commit.
The suite formatted these files with a nested Biome config (double quotes,
semicolons, 80 columns); the root config uses single quotes and no
semicolons. Formatting only, plus Biome's safe fixes. The files the
frozen @cipherstash/auth publishes are ignored, so they keep the bytes
that are on npm.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
The root Biome config lints the imported tests, which the suite's nested
config did not (its linter was off). `pnpm run code:check` then failed on
two `lint/correctness/noUnsafeOptionalChaining` errors: a cast of
`failure?.error` followed by a plain `.help`. Carry the `undefined` into
the cast and chain the property access, so a missing failure fails the
assertion instead of throwing a TypeError.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Plan §7.4, the workspace commit. The freeze and Dependabot follow in
their own commit.

Cargo:
- Root Cargo.toml: resolver 2; the six stack-* crates and the three node
  binding crates as members; EQL, protect-ffi, the three fuzz crates and
  the two Go guests excluded, each its own workspace.
- [workspace.package] with repository = cipherstash/stack, and version
  0.35.0 for the three node binding crates that use version.workspace.
- [workspace.dependencies]: the Phase 0 crates.io releases of the suite
  crates, pinned exactly (cts-common =0.43.0, cllw-ore =0.5.0,
  recipher =0.3.1, zerokms-protocol =0.12.31), the external crates with
  the suite's feature lists, and vitaminc* at 0.5.0. stack-auth and
  stack-profile are also listed, by path: members take them with
  `workspace = true`.
- [profile.release] and [profile.dev] incremental, from the suite.
- Cargo.lock seeded from the suite lock at f161a447f. The four suite
  crates were path packages there, so `cargo update -p` cannot name
  them; `cargo metadata` re-resolved only what changed. It added the four
  registry crates and cipherstash-config 0.42.3, which zerokms-protocol
  pulls, and changed nothing else.
- Cargo.lock for all three fuzz crates, seeded from the root lock and
  pruned by `cargo metadata`; the suite gitignored them. The two guest
  locks are refreshed for the registry dependencies of the cleanup
  commit. `cargo metadata --locked` passes in all six workspaces.
- .cargo/config.toml with the wasm32 getrandom rustflags, and
  .config/nextest.toml with profile.ci.
- .gitignore: the fuzz crates' artifacts, coverage and grown corpus,
  mutants.out/, and the Go guests' built .wasm and .sha256 files.

mise: root mise.toml (rust 1.94.1 with the wasm targets, the cargo tools,
go 1.26 and golangci-lint for go:lint, wasm-pack for build:wasm, the five
tasks.toml includes, and the wasm:* and go:* tasks repathed to
languages/golang) and mise.test.toml. The node binding integration tasks
now run in their new folders, copy from ../../../../target/debug, drop
their `npm install` (which fails on the workspace:* peers; the root
`pnpm install` covers it) and run `pnpm exec vitest run`.

JavaScript:
- pnpm-workspace.yaml lists the auth and profile platform folders. The
  bindings themselves are already selected by the
  languages/typescript/packages/* glob.
- @cipherstash/auth: platform peers at workspace:*, and each platform
  package at 0.44.0, the version on npm; the suite's publish workflow
  rewrote both at publish. Scripts split so `build` and `test` never run
  cargo: build:native, build:debug, test (vitest and Biome) and
  test:cargo. build:wasm repathed to the stack-auth-wasm folder. The
  npx Biome 2.3.4 format scripts, tied to the deleted nested config, go.
- @cipherstash/profile and @cipherstash/stack-auth-wasm split the same
  way; profile's platform optionalDependencies move to workspace:*.
- turbo.json declares @cipherstash/auth#build:native outputs.
- pnpm-lock.yaml gains the new importers and their dependencies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Plan §7.4 (Freeze, Gate change, Platform packages, Dependabot) and §13.6.
The seven @cipherstash/auth packages now live here but keep publishing
from cipherstash-suite until the arming PR (PR E).

Release gate:
- release-gate.mjs learns two more artefact shapes. A `files` entry
  lists tracked files: the gate hashes each with sha256, from disk and
  from the `npm pack` tarball it already fetches, and a mismatch names
  the file. A listed file missing on either side throws. A
  `noTreeBytes` entry declares, with a reason, that the tarball holds
  nothing the tree has; check C skips it. EQL's `field` entry is
  unchanged. Node's built-in crypto only, so the gate still needs
  nothing installed.
- @cipherstash/auth and its six platform packages join
  FROZEN_PUBLISHERS and FROZEN_ARTEFACT_DIGESTS: the wrapper with the 15
  tracked files it publishes, the platforms with noTreeBytes. Check A
  still blocks a version npm does not carry. Tests in
  release-gate.test.mjs.
- lint-no-auth-changeset.mjs, modelled on the retired protect-ffi guard,
  with its self-test, a lint:auth-changeset script and a tests.yml step.
  It can only refuse: no-parked-changesets forbids the old .md.deferred
  escape hatch.
- frozen-publisher-docs covers the auth freeze, so AGENTS.md and
  SECURITY.md describe it here, beside the map.

Dependabot: a cargo entry for the root, the three fuzz crates and the
two Go guests, each of which has a Cargo.lock, and gomod for
/languages/golang. The cargo entry ignores cts-common,
zerokms-protocol, recipher, cllw-ore and vitaminc*, which move in
lockstep with the suite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Plan §7.6, the registrations that belong to PR B.

- cargo-publish-opt-out: the root workspace, with stack-auth and
  stack-profile publishable, and the three fuzz crates and two Go guests
  with nothing publishable. Their `[workspace]` has no members, so the
  test reads a single-package workspace as one member, the root.
- cargo-lock-freshness discovers locks by itself, and now asserts it
  sees the root lock and the five detached ones. It reads the version of
  a `version.workspace = true` crate from the workspace root, and expects
  eql-bindings in the two locks that build it rather than in every lock.
- workflow-mise-setup: the root now has a mise config, so the comment and
  failure message stop saying it has none. The check still requires an
  explicit working_directory on every mise-action step, so an EQL job
  that forgets packages/eql still fails instead of reading the root
  config.
- lint-typecheck-scope: the auth and profile platform folders as roots.
  The auth and profile packages themselves are already found through
  languages/typescript/packages.

workflow-paths-filter-parity needs nothing here: PR B adds no workflow.
eql-suite-ci and crates-ci wait for PR C, and lint-no-workflow-caching
for the publish workflows of PR D.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Plan §7.8, the meta commit.

- AGENTS.md: the layout says packages/ holds Rust crates only, with
  bullets for the six stack-* crates, the node bindings and
  languages/golang; "two Cargo workspaces" becomes three; a new "Working
  on the Rust crates" section carries the suite's Fuzzing, Miri and
  Mutation testing notes, repathed. The CI those notes mention arrives
  with the CI port. The crate paths are spelled out, because
  lint:package-paths reads a `packages/stack-*` glob as packages/stack.
- SECURITY.md: rows for @cipherstash/auth and its six platform packages,
  and a paragraph for the stack-auth and stack-profile crates and the Go
  module.
- docs/agents/issue-tracker.md: the imported paths are tracked here, not
  in cipherstash-suite.
- CODEOWNERS: packages/stack-*, languages/golang and the three node
  binding folders.
- CONTRIBUTING.md: the auth packages' freeze and future fixed group, and
  the crates.io line for stack-auth and stack-profile.

No changeset: nothing published changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
mise merges config down the directory tree, so every tool in the root
mise.toml is also part of the toolset in packages/eql and in
languages/typescript/packages/protect-ffi. Their CI jobs run
`mise install` (and `mise run`) in those folders, so each one now built
cargo-nextest, cargo-llvm-cov, cargo-crap, cargo-mutants, cargo-fuzz and
cargo-udeps from source, none of which they use.

mise's cargo backend compiles a `cargo:` tool with the runner's default
rustc (1.92 on the Ubuntu images), not the root's pinned 1.94.1. So a
`latest` that raises its minimum Rust fails the install: cargo-udeps
0.1.61 needs cargo@0.96, which needs rustc 1.93. That broke the
protect-ffi integration suite on #1001 and the new udeps job on #1003.

The cargo tools move to mise.test.toml, which mise loads only for the
`test` environment (`mise x --env test`, or MISE_ENV=test). Nested
workspaces no longer inherit them. Each is pinned to an exact version
that builds on the runners' rustc; cargo-udeps goes back to 0.1.60.
The tasks that call them (crap:*, mutants:*, fuzz:*, and nextest in
wasm:guest:test and wasm:auth-guest:test) now run them through
`mise x --env test --`, as test:doc and the crap coverage step already
did, so `mise run <task>` keeps working locally without MISE_ENV.

Rust, Go, golangci-lint and wasm-pack stay in the root mise.toml. They
are prebuilt downloads, and plan section 3.5 has protect-ffi inherit
the Rust pin. That section's intent holds: the repository pins one
version of each cargo tool, reached through mise. The pins now sit in
the test environment rather than the default one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
@cipherstash/profile's build:debug and build:native ran `napi build`
with no `--dts`, so napi wrote its generated typings over the
hand-written index.d.ts. Every job that builds the binding (tests.yml
run-tests and tests-crates.yml, from the CI port) would leave the tree
dirty and the package's types wrong.

Both now pass `--dts native.d.ts`, as @cipherstash/auth does. The
generated file is committed so a drift guard can diff it, and Biome
leaves it alone like auth's. index.d.ts does not import it, and the
package is private, so nothing it ships changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
`pnpm run test` now reaches the @cipherstash/auth and
@cipherstash/profile vitest suites, which load the napi module. Their
`test` scripts do not build it, and nothing else in run-tests does, so
profile-store.test.ts failed with "Failed to load native binding for
linux-x64" on this PR (Run Tests, Node 22 and 24).

run-tests now builds both bindings with `build:debug` after the
protect-ffi binding, before the test steps. The previous commit has
`build:debug` write its typings to the committed native.d.ts, so the
build leaves the tree clean. This step and that fix came from the CI
port (#1003); they land here because this PR merges first and has to
pass on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
The root [workspace.dependencies] pinned both exactly, and stack-auth
inherits the requirement when it is packaged, so stack-auth 0.43.0 would
require cts-common =0.43.0 and zerokms-protocol =0.12.31. The suite then
could not unify registry stack-auth with a later compatible cts-common
patch: its [patch.crates-io] entry would go unused, and a second
cts-common would break the Crn, Region and WorkspaceId types that
stack-auth exposes. That defeats the cts-common release decision in plan
section 13.7.

Use caret requirements for the two crates. Cargo.lock still holds the
exact versions, so this changes no resolved version: every lock passes
cargo metadata --locked unchanged. recipher and cllw-ore stay exact,
because only unpublished crates use them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Rename the module from github.com/cipherstash/cipherstash-suite/bindings/go
to github.com/cipherstash/stack/languages/golang, as plan section 3.4
decides. The old path names a private repository whose bindings/go folder
the suite removal deletes, so a go get of it could never resolve.

Nothing outside the module imports the old path, and no version of it was
ever tagged or fetchable through the Go proxy. The change is generated by
stack-migration/go-rename.sh (go mod edit -module, plus a text replace in
the module's .go and .md files): 36 files, 53 lines. go.sum is unchanged,
and go vet, go build and go test pass under the new path. At the freeze,
re-run the script rather than replaying this commit, because the cutover
re-export can bring the old path back in new files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
go vet catches an import of the old suite module path, but not a stale
go get line or pkg.go.dev link in a README. Check that go.mod declares
the stack path, and that no tracked file outside docs/plans/ names the
suite path, so the cutover re-export cannot bring it back unnoticed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
wasm:wasi-check greps cargo tree output for crates that must never link
into a WASI guest (wasm-bindgen, web-sys, js-sys, reqwest, hyper,
aws-lc-sys). CI sets CARGO_TERM_COLOR=always, so cargo wraps each line's
tree prefix in ANSI colour codes, and the grep, which anchors on that
prefix, can never match. A deliberate-break run on 2 October 2026 added
wasm-bindgen to stack-kms, and the CI step still printed "all WASI
crates compile with no JS-host or native-HTTP deps". Only the cargo
check half of the step was working.

Ask cargo tree for --color never. With CARGO_TERM_COLOR=always, the gate
now fails on that break and passes on the clean tree. A script test
checks every cargo tree call in the task asks for uncoloured output, and
records why with a coloured sample the patterns cannot match.

cipherstash-suite has the same task and the same CI setting, so its gate
is dead too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

4 participants