From 51a1d30e83b9b04e066894149cbc113715b89cbf Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Tue, 4 Aug 2026 14:28:00 +0200 Subject: [PATCH 1/4] docs: true up CLAUDE.md with the cryptify workspace merge Root CLAUDE.md still described a four-crate workspace with cryptify as a separate repo, and mentioned cryptify zero times, though ba380a1 (#277) merged it as a fifth member on 2026-07-31. Found while reconciling the #247 map tree (#281). What was stale or missing: - Workspace members, the per-crate CI invocations (cryptify has no features of its own, so --all-targets), and the check-context rename from Test workspace (core) to Test workspace (pg-core) that came with keying the matrices on the crate directory. - The standing sqlx<->rusqlite coupling the merge deliberately bought: one links = "sqlite3" claimant per graph, optional deps in the graph whether enabled or not, so sqlx 0.9 + rusqlite 0.39 is the only intersecting pair and every rusqlite bump waits on sqlx's ceiling. - api-diff.yml is now a two-spec matrix, and its single fail-on and include-checks literals are load-bearing: both pg-pkg/tests/api_gate.rs and cryptify's in-crate api_gate_tests grep for exactly one of each. - Two Docker builds, cryptify's built from the repo root, both on rust:1.96.1-slim-trixie. - cryptify's release-plz entry, and the hand-placed cryptify-v0.1.27 floor tag that stops release-plz changelogging the whole import. - cryptify/CLAUDE.md exists, so root is no longer the single home. - cryptify keeps a renamed rand08 dev-dependency because pg-core's Sealer/TestSetup are still on rand 0.8 while the crate runs 0.10. Two facts the reconciliation itself established: - pg-core 0.6.2 is the first published release off the abandoned irma 0.2.1 and reqwest 0.11 (#263), which is why pg-compat's =0.6.1 pin resolves a tree unlike the workspace's -- and that pin plus COMPATIBILITY.md's crates.io reader row are now a release behind, with no gate reading them (#268). - A history-preserving import silently closes issues here: the cryptify re-attach carried 18 closing keywords resolved against this repo's numbering and closed live issue #146. Includes the audit command and the timeline query that distinguishes a keyword close from a hand one. --- CLAUDE.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d51a2f83..7d855b55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,12 @@ # Agent notes -Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the single home for durable repo knowledge; dobby updates it in the same PR whenever it learns something worth keeping. +Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the home for durable knowledge about the workspace as a whole; dobby updates it in the same PR whenever it learns something worth keeping. There is a second one: `cryptify/CLAUDE.md`, which came in with that crate and holds its service-specific notes (config, upload-state lifetime, the token chain, CORS, metrics, its integration harness). Read both before touching cryptify, and put cryptify-only knowledge there rather than here. ## Workspace & CI -- Workspace members: `pg-core` (lib), `pg-ffi` (C ABI), `pg-pkg` (PKG service), `pg-cli`. `pg-wasm` is a sibling crate the root `Cargo.toml` lists under `exclude`, so it is not part of the workspace and is built separately with wasm-pack (see Release & configuration). Sub-crates share workspace files. Build the workspace from repo root with `cargo build`. A bare `cargo test --workspace` FAILS to compile: `pg-core`'s tests are gated behind its `test` feature (also `rust`/`stream`), so the item is configured out and imports like `crate::test::TestSetup` don't resolve. CI (`.github/workflows/build.yml`) runs tests per crate: `cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream` for core, `--all-features` for `pkg`/`cli`/`ffi`. None of these cover `pg-wasm`. `pg-core` uses CGWKV + MKEM for multi-recipient encryption (production feature set `["cgwkv", "mkem"]`). -- `pg-compat` is a second excluded sibling crate (root `Cargo.toml` `exclude`), and the exclusion is load-bearing: it depends on `pg-core` from **crates.io** (`=0.6.1`), not on `../pg-core`, so it can open bytes sealed by this tree with published readers. It has its own `Cargo.lock` (run it with `--locked`). Its input comes from `cargo run -p pg-core --features stream --example seal-samples -- `, a deterministic sealer whose output layout is documented in `pg-compat/README.md`. CI wires the two together: `wire-compat-rust` in `build.yml` seals with HEAD and opens with published pg-core on any PR touching the wire surface (pg-core/pg-wasm/pg-compat/pg-compat-js trees, the ROOT `Cargo.lock`/`Cargo.toml` — pg-core resolves from the root lockfile — and build.yml itself); `pg-compat-lint` covers the crate's fmt/clippy, which the per-crate matrices don't. +- Workspace members: `pg-core` (lib), `pg-ffi` (C ABI), `pg-pkg` (PKG service), `pg-cli`, `cryptify` (file-transfer service, merged in 2026-07-31). `pg-wasm` is a sibling crate the root `Cargo.toml` lists under `exclude`, so it is not part of the workspace and is built separately with wasm-pack (see Release & configuration). Sub-crates share workspace files. Build the workspace from repo root with `cargo build`. A bare `cargo test --workspace` FAILS to compile: `pg-core`'s tests are gated behind its `test` feature (also `rust`/`stream`), so the item is configured out and imports like `crate::test::TestSetup` don't resolve. CI (`.github/workflows/build.yml`) runs tests per crate: `cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream` for core, `--all-targets` for `cryptify` (it declares no features of its own, so that is the equivalent invocation), `--all-features` for `pkg`/`cli`/`ffi`. None of these cover `pg-wasm`. The `test`/`format`/`clippy` matrices key on the crate **directory** rather than a `pg-` suffix, because `cryptify` doesn't carry that prefix — so the check contexts are `Test workspace (pg-core)`, not `Test workspace (core)`. Nothing required points at them today (only the two `Wire compat` contexts are required), but a ruleset added later must use the current names. `pg-core` uses CGWKV + MKEM for multi-recipient encryption (production feature set `["cgwkv", "mkem"]`). +- `cryptify` was a separate repo until `ba380a1` (#277) merged it as a workspace member, history-preserving, the deliberate yes decided in #255/#285. What that bought: one lockfile, which closes a real drift class — the two trees previously built `pg-core`'s container serializer against two different `bincode-next` versions with nothing comparing them. What it costs is a **standing dependency coupling, and it does not go away**: `pg-pkg` reaches `libsqlite3-sys` through `sqlx`, `cryptify` reaches it through `rusqlite`, which sets `links = "sqlite3"`. Only one package per resolve graph may claim a given `links` value, and optional dependencies sit in the graph whether their feature is enabled or not — so `sqlx-sqlite` is locked even though `pg-pkg` enables only `postgres`. The newest intersecting pair is **sqlx 0.9 + rusqlite 0.39** (`libsqlite3-sys 0.37` in the root lockfile), so every future `rusqlite` bump waits on sqlx's `libsqlite3-sys` ceiling. Don't bump either half alone; check the intersection first. +- `pg-compat` is a second excluded sibling crate (root `Cargo.toml` `exclude`), and the exclusion is load-bearing: it depends on `pg-core` from **crates.io** (`=0.6.1`), not on `../pg-core`, so it can open bytes sealed by this tree with published readers. It has its own `Cargo.lock` (run it with `--locked`). Its input comes from `cargo run -p pg-core --features stream --example seal-samples -- `, a deterministic sealer whose output layout is documented in `pg-compat/README.md`. CI wires the two together: `wire-compat-rust` in `build.yml` seals with HEAD and opens with published pg-core on any PR touching the wire surface (pg-core/pg-wasm/pg-compat/pg-compat-js trees, the ROOT `Cargo.lock`/`Cargo.toml` — pg-core resolves from the root lockfile — and build.yml itself); `pg-compat-lint` covers the crate's fmt/clippy, which the per-crate matrices don't. The `=0.6.1` pin is currently one release behind: `pg-core` 0.6.2 published 2026-07-30 and neither the pin nor `COMPATIBILITY.md`'s `crates.io` reader row moved, because nothing reads those rows — the npm rows cannot drift this way, since `manifest.test.mjs` machine-reads them. Tracked in #268; move the row and the pin together. - `pg-compat-js` is the Node half of the same gate: a private npm package (no build step, no lint/format tooling in the repo) that opens the sealer's output with the published npm readers, installed side by side through `npm:` aliases (`pg-wasm-0-6-1`, `pg-js-2-3-3`, `pg-js-1-11-0`) because several versions of one package have to coexist. Run it locally with `npm --prefix pg-compat-js ci` then `PG_COMPAT_ARTIFACTS= npm --prefix pg-compat-js test`; in CI the `wire-compat-js` job in `build.yml` downloads the artifact `wire-compat-rust` sealed and runs the same suite, gated on `needs.wire-compat-rust.outputs.sealed` rather than a second copy of the path filter. `node --test test/` does NOT work (Node reads the bare directory as a module path and dies with `Cannot find module .../test`) — the `test` script passes the glob `"test/*.test.mjs"` instead. Its reader list is not free to edit on its own: `test/manifest.test.mjs` parses the fenced `Reader list` block in root `COMPATIBILITY.md` (` ` rows) and compares the `npm` rows against `readers()`, so that block is machine-read and reformatting it into prose or a table breaks the test. It also compares each declared `package` and `version` against the installed alias's own `package.json`, read off disk because neither published package exports `package.json` (`import('/package.json')` is `ERR_PACKAGE_PATH_NOT_EXPORTED`). - JS-reader gotchas the Node gate ran into, all still true of `@e4a/pg-wasm` 0.6.1 and `@e4a/pg-js` 1.11.0/2.3.3: (a) `@e4a/pg-wasm`'s default (bundler) entry does `import * as wasm from "./index_bg.wasm"`, which plain Node cannot resolve — import `@e4a/pg-wasm/web` and pass the module bytes to its default export, resolving them via `new URL('index_bg.wasm', import.meta.resolve('@e4a/pg-wasm/web'))` since the `.wasm` file is not in the package's `exports`; (b) `Unsealer.unseal()`/`StreamUnsealer.unseal()` *consume* the unsealer (wasm-bindgen `__destroy_into_raw`), so a tidy `free()` afterwards is a double free reported as `null pointer passed to rust`, which reads exactly like a corrupt container; (c) `pg-js` is stream-mode only in both directions — `toBytes()` seals with `sealStream` and its decrypt path only ever builds a `StreamUnsealer`, so a memory-mode container fails with `mode is not supported: InMemory { size: N }`; (d) `pg-js` 1.x discards what `StreamUnsealer.unseal()` returns and reports `public_identity()` instead, so it never surfaces the private signing policy of a `*-privsig` container and its `sender.raw` is the bare header policy rather than 2.x's `{public, private?}`; (e) `pg-js`'s decrypt path needs a PKG at `pkgUrl` for exactly two GETs — `/v2/sign/parameters` for the verifying key and `/v2/irma/key/` (bearer token) for the USK — which is why the gate can drive it offline from the artifact's own `vk.json`/`usk-*.json`. - Appending a field at the *end* of `Header` really is additive: the header is a length-prefixed region and `bincode` ignores trailing bytes, so published `pg-core` 0.6.1 still opens it. A field inserted anywhere else, a changed field type, or a reorder shifts every following byte and the containers stop opening, but *not* with a decode error: 0.6.1 reads a garbage length prefix, attempts a ~20 GiB allocation, and the process aborts (SIGABRT). Expect `reader died on signal 6 ... memory allocation of N bytes failed`, not a message naming the header. This is also why `pg-compat` opens each case in a child process (its `pg-compat-case` binary): an abort is not a panic, `catch_unwind` cannot contain it, and in one process the first broken case would take the run down before the others were tried. Don't reason about "additive" from the struct alone; run the compat gate. @@ -13,16 +14,17 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the s - `Run wasm tests in browsers` flakes, and the error names the wrong culprit. `Error: missing field 'chunk'` is `wasm-bindgen-test-runner` failing to parse a truncated webdriver reply; the cause is the line above it, `[SEVERE]: Timed out receiving message from renderer: 30.000`. Read the driver stderr before suspecting the test. The matrix is fail-fast, so one browser timing out reports the other two as failures when they were cancelled: check each job's own conclusion, not the summary. Seen on the same sha passing at 07:45 and failing at 07:48 (runs 30432815599 and 30432995207 on #269, a docs-only commit). Re-run rather than debug, and note that `dobby-coder` cannot: `POST /actions/runs/{id}/rerun-failed-jobs` is 403 for the App, so a maintainer has to click it, or a fresh push has to supersede the run. - `scripts/semver-checks.sh` runs `cargo-semver-checks` over the two surfaces external consumers build against: `pg-core` against its crates.io release, and `pg-wasm` against `origin/main` (it has no crates.io release; the npm package is versioned from `pg-core`). The `semver-checks` job in `build.yml` calls it on any PR touching `pg-core`, `pg-wasm`, the root manifest or the script itself; run it yourself too before pushing such a change, since the job needs a wasm32 toolchain and a pinned cargo-semver-checks download and is therefore not the fastest feedback. Four things it encodes. (1) `pg-core` needs `--only-explicit-features --features test,rust,stream`, the same set the test and clippy matrices use: cargo-semver-checks otherwise enables everything that doesn't look unstable, which pulls in `web` and hits its `compile_error!`. (2) `pg-core`'s `web,stream` surface is deliberately not checked. `Unsealer` has two `unseal` methods there on different instantiations (owned `self` in `client/web/mod.rs`, `&mut self` in `client/web/stream.rs`) and cargo-semver-checks 0.49 pairs them by name alone, so it reports `method_receiver_mut_ref_became_owned` against byte-identical source; `rust,stream` is clean because both receivers are owned there. (3) Any wasm32 run needs `RUSTFLAGS=--cap-lints=warn`, because the `--cap-lints allow` cargo-semver-checks sets silences the "dropping unsupported crate type" warnings cargo reads back when probing rustc, and cargo then dies with "output of --print=file-names missing". (4) `cargo-semver-checks` splits its non-zero exits: `100` is a semver violation, `101` is the tool or the build failing (unresolvable baseline rev, missing rustup target, registry fetch failure, compile error in the crate). Never treat "non-zero" as "breaking change" here, because the advice a semver gate prints is "declare the break", and on this repo that means a `!` in the PR title and a spurious major release of `pg-core`. `scripts/semver-checks-test.sh` pins that mapping; it stubs `cargo`, so it runs in well under a second and needs neither cargo-semver-checks nor a wasm32 toolchain. Run it after touching the gate. - release-plz owns the version numbers, so the PR making a breaking change cannot bump the crate to match (bumping `pg-core` alone doesn't even resolve: `pg-cli` requires `^0.6.1`). What the semver gate accepts as the declaration is the conventional-commit `!` in the PR title, and only that; CI turns it into `SEMVER_RELEASE_TYPE=major`, which the script passes as `--release-type major`. A `BREAKING CHANGE:` footer in the PR body is not accepted and must not be: this repo's `squash_merge_commit_message` is `COMMIT_MESSAGES`, so the body never reaches the squashed commit, and release-plz reading a bare `fix(pg-core):` subject would cut a patch release of a break the gate had already waved through. Two consequences of the merge settings worth knowing when you declare a break. `squash_merge_commit_title` is `COMMIT_OR_PR_TITLE`, which is the PR title on a multi-commit PR but the commit's subject when the PR has exactly one commit — so on a single-commit PR put the `!` in the commit subject too, or the gate goes green off the PR title while release-plz cuts a patch. And `--release-type major` doesn't merely permit a bigger bump: every lint exists to demand a bump the declaration already grants, so all of them skip and the run checks nothing (`0 checks: 0 pass, 253 skip`) on both surfaces at once. A green gate on a `!` PR verified nothing; a `!` added for a pg-wasm break also passes any unrelated pg-core break in the same PR. -- The Docker build (`Dockerfile`, `FROM rust:-slim`) pins an older or different Rust than the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the Dockerfile's current pin, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the Docker image. -- `pg-pkg/api-description.yaml` is the pinned v2 HTTP contract and is additive-only (see `COMPATIBILITY.md`). Its breaking-change gate is `.github/workflows/api-diff.yml`, job `API breaking changes (oasdiff)`, which runs on every PR; `pg-pkg/tests/api_gate.rs` is its executable spec. That test is the executable record of what the gate stops; it mutates the spec 19 ways and asserts each verdict. The verdict test skips when `oasdiff` is not on `PATH`, which is the case in CI; the other two run everywhere, one checking that every mutation still finds its anchor and one reading `fail-on`/`include-checks`/the pinned action ref back out of the workflow YAML, so editing the step and the constants apart fails the suite instead of quietly weakening the gate. The action ref is pinned because it is what selects the engine version (`v0.1.10` is `FROM tufin/oasdiff:v1.26.1`), so bumping the action silently re-measures every verdict. Those anchors are multi-line raw strings matched against the spec read off disk, which is why root `.gitattributes` marks `pg-pkg/api-description.yaml -text`: rustc normalises CRLF inside raw strings but `read_to_string` does not, so without it a Windows checkout fails `every_mutation_still_applies` on a clean tree. Run it (and read it) before touching the gate's settings: `go install github.com/oasdiff/oasdiff@v1.26.1 && cargo test --manifest-path pg-pkg/Cargo.toml --all-features --test api_gate`. +- There are **two** Docker builds, `Dockerfile` (pg-pkg) and `cryptify/Dockerfile`, each with its own image name and independent version output in `delivery.yml`. cryptify's builds with the **repo root** as context (`file: cryptify/Dockerfile`), because the crate is a workspace member and needs the root manifest and lockfile; it publishes to the same `ghcr.io//cryptify` name the old repo did, so nothing downstream repoints. Both pin the same Rust today (`FROM rust:1.96.1-slim-trixie`), which is older or otherwise different from the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail a Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the current pins, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the image. +- `pg-pkg/api-description.yaml` is the pinned v2 HTTP contract and is additive-only (see `COMPATIBILITY.md`). Its breaking-change gate is `.github/workflows/api-diff.yml`, which runs on every PR as a **two-spec matrix** — `pg-pkg/api-description.yaml` (versioned `/v2` routes) and `cryptify/api-description.yaml` (unversioned) — so the check names carry the spec path. The matrix is not cosmetic: `pg-pkg/tests/api_gate.rs` and cryptify's in-crate `mod api_gate_tests` both grep this file for exactly **one** `fail-on:` and one `include-checks:` line, so two separate oasdiff steps would give two of each and fail both suites. Vary only the spec paths; moving `fail-on` into the matrix removes the literal both tests look for. `pg-pkg/tests/api_gate.rs` is the executable record of what the gate stops on the pg-pkg side: it mutates the spec 19 ways and asserts each verdict. The verdict test skips when `oasdiff` is not on `PATH`, which is the case in CI; the other two run everywhere, one checking that every mutation still finds its anchor and one reading `fail-on`/`include-checks`/the pinned action ref back out of the workflow YAML, so editing the step and the constants apart fails the suite instead of quietly weakening the gate. The action ref is pinned because it is what selects the engine version (`v0.1.10` is `FROM tufin/oasdiff:v1.26.1`), so bumping the action silently re-measures every verdict. Those anchors are multi-line raw strings matched against the spec read off disk, which is why root `.gitattributes` marks `pg-pkg/api-description.yaml -text`: rustc normalises CRLF inside raw strings but `read_to_string` does not, so without it a Windows checkout fails `every_mutation_still_applies` on a clean tree. Run it (and read it) before touching the gate's settings: `go install github.com/oasdiff/oasdiff@v1.26.1 && cargo test --manifest-path pg-pkg/Cargo.toml --all-features --test api_gate`. - The oasdiff gate's settings are **not** self-evident and `--fail-on ERR` alone fails open. `fail-on: WARN` is deliberate: oasdiff rates removing or renaming an *optional* response property, and removing a request parameter, as WARN, and this spec marks only `status` as `required`, so at ERR the gate silently passed a removed `key` (the IBE user secret key `/v2/request/key` exists to return), a renamed `proofStatus` and a dropped `timestamp` parameter. Two more, a changed non-success status (401 to 403) and a dropped response enum value, rate ERR but are **opt-in**, so they only run when named in `include-checks`. Of the 31 WARN checks, `response-property-enum-value-added` is the only one that fires on something `COMPATIBILITY.md` does not already forbid, which is why that document now names a new response enum value as non-additive too. Reproduce a verdict with the exact flags the action's entrypoint builds (`oasdiff/oasdiff-action/breaking@v0.1.10` is `FROM tufin/oasdiff:v1.26.1`, so the pinned tag is what makes a local run authoritative): `git show origin/main:pg-pkg/api-description.yaml > /tmp/base.yaml && oasdiff breaking /tmp/base.yaml pg-pkg/api-description.yaml --allow-external-refs=false --composed=false --fail-on WARN --include-checks response-non-success-status-removed,response-property-enum-value-removed`. Two traps: `oasdiff --version` prints `oasdiff version main` after a `go install` of a tag because the version comes from release ldflags (the code is still the tag), and `--fail-on` takes `ERR`/`WARN` while `oasdiff checks --severity` takes `error`/`warn`/`info`. `--severity ERR` is a usage error, so `oasdiff checks --severity ERR | wc -l` counts the help text instead and badly undercounts the tier (it is 213 error checks, 31 warn, 265 info). The spec has no external `$ref`s, so `allow-external-refs` stays at its safe (SSRF-guarding) default. The gate only sees paths the spec documents, and the spec documents canonical paths only, so dropping the `/v2/irma/...` alias handlers (#257) passes it. +- **A history-preserving import into this repo silently closes issues here.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. The cryptify re-attach carried 18 of them (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194 #273`); 17 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed by `ba380a14` four seconds after the merge landed. Nothing warned, and it stood for five days. Audit before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Defusing costs every imported SHA (`git filter-repo`), so auditing and reopening is usually the better trade — but decide it deliberately, and do the audit either way. Reading close *state* cannot tell you which happened; read the close **event**, where a keyword-close carries a `commit_id` and a hand-close does not: `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272); the guard lives in encryption4all/postguard-js#139. - The `dobby-coder` GitHub App lacks `workflows: write` on this repo; any push touching `.github/workflows/*.yml` is rejected at the remote. Before treating a fix as blocked, check whether the same effect can be achieved in a pushable file (crate manifest, source, committed script); if a fix genuinely can only live in a workflow file, ship the pushable half and hand the maintainer ready-to-paste YAML in the PR body. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`. Measured exception, worth trying before handing the sync over: the App pushed `ce0fc59` on this branch, a merge whose diff against its first parent added main's 64 new `build.yml` lines. That merge needed no resolution inside `build.yml` — it took main's side whole, so the blob it committed already existed in the repo. Try the merge and read the remote's answer; only escalate on an actual rejection. ## Dependencies - postguard depends on `bincode-next` (crate name `bincode-next`, import `bincode_next`), a third-party fork by `panayang`/`Apich-Organization`, not the original `bincode-org`. Flag this trust caveat in any PR touching it. Pin the exact rc in use and re-audit on any rc bump (current: `bincode-next 3.0.0-rc.14`). `bincode_next::config::legacy()` is byte-compatible with bincode 1.x (pinned by a regression test); use it wherever wire/on-disk format matters. The error type split (`EncodeError`/`DecodeError`) is a breaking API change requiring a version bump of every in-repo dependent. A `bincode-next` rc bump may raise its MSRV, which can force the Docker Rust pin higher too; check before bumping either. -- postguard's `irma` client comes from `irma = { package = "irmars", version = "..." }`, encryption4all's own fork of the dormant (since 2021) `tweedegolf/irmars`, published to get a `reqwest ^0.12` release line. -- Bumping `rand` past 0.8 anywhere in the workspace is blocked until `ibe`/`ibs` (upstream crates) migrate off `rand_core 0.6`; check `cargo tree -p pg-core | grep rand_core` before adding a rand bump to a dep-update PR. The real bottleneck is `ibe` (no migration branch yet), not `ibs` (migrated on a branch, not yet published). +- postguard's `irma` client comes from `irma = { package = "irmars", version = "..." }`, encryption4all's own fork of the dormant (since 2021) `tweedegolf/irmars`, published to get a `reqwest ^0.12` release line. The package rename is what let every call site keep saying `use irma::...`. **pg-core 0.6.2 is the first published release off the legacy chain** (#263): published 0.6.1 still depends on the abandoned `irma ^0.2.1` and drags `reqwest 0.11.27`, which is what `pg-compat`'s `=0.6.1` pin resolves today, so `cargo tree` inside that crate looks nothing like the workspace's. +- Bumping `rand` past 0.8 anywhere in the workspace is blocked until `ibe`/`ibs` (upstream crates) migrate off `rand_core 0.6`; check `cargo tree -p pg-core | grep rand_core` before adding a rand bump to a dep-update PR. The real bottleneck is `ibe` (no migration branch yet), not `ibs` (migrated on a branch, not yet published). This splits the workspace rather than blocking it: `cryptify` runs on rand 0.10 and keeps a second, explicitly renamed `rand08 = { package = "rand", version = "0.8" }` in dev-dependencies purely so its test code can hand `pg-core`'s `Sealer`/`TestSetup` an RNG they accept. Expect that pin to be what breaks when `ibe` finally migrates, and delete it then rather than bumping it. - `cargo-audit` allowlist lives at repo-root `.cargo/audit.toml` (`[advisories] ignore = [...]`); each entry needs a comment naming the blocking crate plus a drop-when condition. CI does not currently run `cargo audit`; this file is for local/routine scans only. ## Security @@ -34,6 +36,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the s - release-plz reads conventional-commit PR titles (PRs are squash-merged, so the title becomes the commit subject) to decide the version bump. A breaking change needs `fix(scope)!:`/`feat(scope)!:` (the `!` is what the semantic-PR-title check and release-plz both key on) plus a `BREAKING CHANGE:` footer, or the break silently ships as a patch/minor bump with no changelog warning. - The npm package `@e4a/pg-wasm` is NOT versioned from `pg-wasm/Cargo.toml`. `delivery.yml`'s `publish-wasm` job writes `pg-wasm/pkg/package.json` from scratch and stamps it with the released pg-core version, so npm serves 0.6.1 while the crate manifest still says 0.5.5. When you need a pg-wasm version (support windows, reader pins, consumer ranges), read it from npm, never from the crate manifest. +- `cryptify` releases through the same release-plz workspace, `publish = false` + `git_only = true` (Docker only, like `pg-pkg`). Its tags changed shape on the merge: the old repo tagged bare `v0.1.27`, and this workspace's `git_tag_name` makes them `cryptify-v0.1.28` onward. A subtree import brings commits but **not tags**, so release-plz saw no previous release for the package and would have changelogged the whole imported history into one entry; `cryptify-v0.1.27` was hand-placed on the import commit to give it a floor. That tag is load-bearing and points at no release of its own — don't prune it as an orphan. Expect the same problem on any future crate imported this way. - Do not hand-write changelog entries under a crate's `## [Unreleased]` heading. `release-plz.toml` sets no changelog template, so entries are generated from commit subjects and each new version section is inserted *below* the `## [Unreleased]` heading. A manual bullet there is never folded into the release that follows it — it just sits above the newest version forever, undated. To get something into a dated changelog entry, it has to ride a commit subject on a PR that bumps that crate. - `pg-wasm`'s web target: set `[package.metadata.wasm-pack.profile.release.wasm-bindgen] omit-default-module-path = true` in `pg-wasm/Cargo.toml` to drop the `new URL('index_bg.wasm', import.meta.url)` branch from generated `__wbg_init` glue (Webpack 5 otherwise statically resolves it and breaks bundler consumers that always pass an explicit `module_or_path`). Cleaner than post-build regex-stripping the generated JS. - `pg-pkg` (the postguard PKG service) CLI flags: `-t irma_token`, `-i irma_server_url`, `-d postgres_url`. Env vars: `IRMA_SERVER`, `DATABASE_URL`, `RUST_LOG`. From eff1929ee5a08f5e36b4cb6a95fe4ab1a103d69a Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Tue, 4 Aug 2026 14:50:49 +0200 Subject: [PATCH 2/4] docs: correct the counts, the links declarant and the format matrix Review fixes on #287, all four verified against the repo first. - Line 13 still said `cargo fmt --manifest-path pg-/...`, which expands to pg-cryptify now that the matrix keys on the crate directory. The file contradicted the bullet six lines above it. - The import bullet over-counted: ba380a1 carries 17 closing references, not 18. #273 came from a55c6a0 (#274), an ordinary commit the day before, so the original range e642786..origin/main swept in keywords the import never carried. 16 no-ops, not 17. Records the range mistake itself, since the bullet's command is what the next agent runs. - #146 stood four days, not five, and the bullet now says it was reopened -- a reader who checks and finds it open doubts the rest. - links = "sqlite3" is declared by libsqlite3-sys, not rusqlite; the relative clause named the wrong claimant. Confirmed with cargo metadata: rusqlite, sqlx and sqlx-sqlite all declare none. The error came in from #285's body, so it was inherited rather than invented. - The cryptify image bullet read present-tense, but build/scan/finalize are all gated on vars.PUBLISH_CRYPTIFY_IMAGE and skip while it is unset. CLAUDE.md is auto-loaded, so a present-tense claim about a job that does not run is the expensive kind of wrong. The required-checks parenthetical held up, and is now stronger for being challenged: branch protection on main requires exactly the two Wire compat contexts. That list lives in classic branch protection, NOT in the main ruleset (which has no required_status_checks rule), and dobby-coder gets 403 on the protection endpoint -- so an agent reading the ruleset alone concludes nothing is required. Worth documenting, so it is. --- CLAUDE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d855b55..c8dad920 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,20 +4,20 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the h ## Workspace & CI -- Workspace members: `pg-core` (lib), `pg-ffi` (C ABI), `pg-pkg` (PKG service), `pg-cli`, `cryptify` (file-transfer service, merged in 2026-07-31). `pg-wasm` is a sibling crate the root `Cargo.toml` lists under `exclude`, so it is not part of the workspace and is built separately with wasm-pack (see Release & configuration). Sub-crates share workspace files. Build the workspace from repo root with `cargo build`. A bare `cargo test --workspace` FAILS to compile: `pg-core`'s tests are gated behind its `test` feature (also `rust`/`stream`), so the item is configured out and imports like `crate::test::TestSetup` don't resolve. CI (`.github/workflows/build.yml`) runs tests per crate: `cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream` for core, `--all-targets` for `cryptify` (it declares no features of its own, so that is the equivalent invocation), `--all-features` for `pkg`/`cli`/`ffi`. None of these cover `pg-wasm`. The `test`/`format`/`clippy` matrices key on the crate **directory** rather than a `pg-` suffix, because `cryptify` doesn't carry that prefix — so the check contexts are `Test workspace (pg-core)`, not `Test workspace (core)`. Nothing required points at them today (only the two `Wire compat` contexts are required), but a ruleset added later must use the current names. `pg-core` uses CGWKV + MKEM for multi-recipient encryption (production feature set `["cgwkv", "mkem"]`). -- `cryptify` was a separate repo until `ba380a1` (#277) merged it as a workspace member, history-preserving, the deliberate yes decided in #255/#285. What that bought: one lockfile, which closes a real drift class — the two trees previously built `pg-core`'s container serializer against two different `bincode-next` versions with nothing comparing them. What it costs is a **standing dependency coupling, and it does not go away**: `pg-pkg` reaches `libsqlite3-sys` through `sqlx`, `cryptify` reaches it through `rusqlite`, which sets `links = "sqlite3"`. Only one package per resolve graph may claim a given `links` value, and optional dependencies sit in the graph whether their feature is enabled or not — so `sqlx-sqlite` is locked even though `pg-pkg` enables only `postgres`. The newest intersecting pair is **sqlx 0.9 + rusqlite 0.39** (`libsqlite3-sys 0.37` in the root lockfile), so every future `rusqlite` bump waits on sqlx's `libsqlite3-sys` ceiling. Don't bump either half alone; check the intersection first. +- Workspace members: `pg-core` (lib), `pg-ffi` (C ABI), `pg-pkg` (PKG service), `pg-cli`, `cryptify` (file-transfer service, merged in 2026-07-31). `pg-wasm` is a sibling crate the root `Cargo.toml` lists under `exclude`, so it is not part of the workspace and is built separately with wasm-pack (see Release & configuration). Sub-crates share workspace files. Build the workspace from repo root with `cargo build`. A bare `cargo test --workspace` FAILS to compile: `pg-core`'s tests are gated behind its `test` feature (also `rust`/`stream`), so the item is configured out and imports like `crate::test::TestSetup` don't resolve. CI (`.github/workflows/build.yml`) runs tests per crate: `cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream` for core, `--all-targets` for `cryptify` (it declares no features of its own, so that is the equivalent invocation), `--all-features` for `pkg`/`cli`/`ffi`. None of these cover `pg-wasm`. The `test`/`format`/`clippy` matrices key on the crate **directory** rather than a `pg-` suffix, because `cryptify` doesn't carry that prefix — so the check contexts are `Test workspace (pg-core)`, not `Test workspace (core)`. Nothing required points at them today, but whatever adds to the required list later must use the current names. Where that list lives is not obvious and an agent cannot read it: required checks come from **classic branch protection** (`gh api repos/encryption4all/postguard/branches/main/protection`, currently exactly `Wire compat (published pg-core)` and `Wire compat (published pg-wasm/pg-js)`), *not* from the `main` ruleset, which carries only `deletion`/`non_fast_forward`/`pull_request` and no `required_status_checks` rule at all. `dobby-coder` gets 403 on the protection endpoint, so reading the ruleset alone concludes nothing is required, which is wrong — ask a maintainer rather than inferring. `pg-core` uses CGWKV + MKEM for multi-recipient encryption (production feature set `["cgwkv", "mkem"]`). +- `cryptify` was a separate repo until `ba380a1` (#277) merged it as a workspace member, history-preserving, the deliberate yes decided in #255/#285. What that bought: one lockfile, which closes a real drift class — the two trees previously built `pg-core`'s container serializer against two different `bincode-next` versions with nothing comparing them. What it costs is a **standing dependency coupling, and it does not go away**: `pg-pkg` reaches `libsqlite3-sys` through `sqlx`, `cryptify` reaches it through `rusqlite`, and it is `libsqlite3-sys` itself that declares `links = "sqlite3"` (`rusqlite`, `sqlx` and `sqlx-sqlite` all declare none — check with `cargo metadata`, not by guessing which crate in the chain owns it). Only one package per resolve graph may claim a given `links` value, and optional dependencies sit in the graph whether their feature is enabled or not — so `sqlx-sqlite` is locked even though `pg-pkg` enables only `postgres`. The newest intersecting pair is **sqlx 0.9 + rusqlite 0.39** (`libsqlite3-sys 0.37` in the root lockfile), so every future `rusqlite` bump waits on sqlx's `libsqlite3-sys` ceiling. Don't bump either half alone; check the intersection first. - `pg-compat` is a second excluded sibling crate (root `Cargo.toml` `exclude`), and the exclusion is load-bearing: it depends on `pg-core` from **crates.io** (`=0.6.1`), not on `../pg-core`, so it can open bytes sealed by this tree with published readers. It has its own `Cargo.lock` (run it with `--locked`). Its input comes from `cargo run -p pg-core --features stream --example seal-samples -- `, a deterministic sealer whose output layout is documented in `pg-compat/README.md`. CI wires the two together: `wire-compat-rust` in `build.yml` seals with HEAD and opens with published pg-core on any PR touching the wire surface (pg-core/pg-wasm/pg-compat/pg-compat-js trees, the ROOT `Cargo.lock`/`Cargo.toml` — pg-core resolves from the root lockfile — and build.yml itself); `pg-compat-lint` covers the crate's fmt/clippy, which the per-crate matrices don't. The `=0.6.1` pin is currently one release behind: `pg-core` 0.6.2 published 2026-07-30 and neither the pin nor `COMPATIBILITY.md`'s `crates.io` reader row moved, because nothing reads those rows — the npm rows cannot drift this way, since `manifest.test.mjs` machine-reads them. Tracked in #268; move the row and the pin together. - `pg-compat-js` is the Node half of the same gate: a private npm package (no build step, no lint/format tooling in the repo) that opens the sealer's output with the published npm readers, installed side by side through `npm:` aliases (`pg-wasm-0-6-1`, `pg-js-2-3-3`, `pg-js-1-11-0`) because several versions of one package have to coexist. Run it locally with `npm --prefix pg-compat-js ci` then `PG_COMPAT_ARTIFACTS= npm --prefix pg-compat-js test`; in CI the `wire-compat-js` job in `build.yml` downloads the artifact `wire-compat-rust` sealed and runs the same suite, gated on `needs.wire-compat-rust.outputs.sealed` rather than a second copy of the path filter. `node --test test/` does NOT work (Node reads the bare directory as a module path and dies with `Cannot find module .../test`) — the `test` script passes the glob `"test/*.test.mjs"` instead. Its reader list is not free to edit on its own: `test/manifest.test.mjs` parses the fenced `Reader list` block in root `COMPATIBILITY.md` (` ` rows) and compares the `npm` rows against `readers()`, so that block is machine-read and reformatting it into prose or a table breaks the test. It also compares each declared `package` and `version` against the installed alias's own `package.json`, read off disk because neither published package exports `package.json` (`import('/package.json')` is `ERR_PACKAGE_PATH_NOT_EXPORTED`). - JS-reader gotchas the Node gate ran into, all still true of `@e4a/pg-wasm` 0.6.1 and `@e4a/pg-js` 1.11.0/2.3.3: (a) `@e4a/pg-wasm`'s default (bundler) entry does `import * as wasm from "./index_bg.wasm"`, which plain Node cannot resolve — import `@e4a/pg-wasm/web` and pass the module bytes to its default export, resolving them via `new URL('index_bg.wasm', import.meta.resolve('@e4a/pg-wasm/web'))` since the `.wasm` file is not in the package's `exports`; (b) `Unsealer.unseal()`/`StreamUnsealer.unseal()` *consume* the unsealer (wasm-bindgen `__destroy_into_raw`), so a tidy `free()` afterwards is a double free reported as `null pointer passed to rust`, which reads exactly like a corrupt container; (c) `pg-js` is stream-mode only in both directions — `toBytes()` seals with `sealStream` and its decrypt path only ever builds a `StreamUnsealer`, so a memory-mode container fails with `mode is not supported: InMemory { size: N }`; (d) `pg-js` 1.x discards what `StreamUnsealer.unseal()` returns and reports `public_identity()` instead, so it never surfaces the private signing policy of a `*-privsig` container and its `sender.raw` is the bare header policy rather than 2.x's `{public, private?}`; (e) `pg-js`'s decrypt path needs a PKG at `pkgUrl` for exactly two GETs — `/v2/sign/parameters` for the verifying key and `/v2/irma/key/` (bearer token) for the USK — which is why the gate can drive it offline from the artifact's own `vk.json`/`usk-*.json`. - Appending a field at the *end* of `Header` really is additive: the header is a length-prefixed region and `bincode` ignores trailing bytes, so published `pg-core` 0.6.1 still opens it. A field inserted anywhere else, a changed field type, or a reorder shifts every following byte and the containers stop opening, but *not* with a decode error: 0.6.1 reads a garbage length prefix, attempts a ~20 GiB allocation, and the process aborts (SIGABRT). Expect `reader died on signal 6 ... memory allocation of N bytes failed`, not a message naming the header. This is also why `pg-compat` opens each case in a child process (its `pg-compat-case` binary): an abort is not a panic, `catch_unwind` cannot contain it, and in one process the first broken case would take the run down before the others were tried. Don't reason about "additive" from the struct alone; run the compat gate. -- CI's `Format workspace` matrix runs `cargo fmt --manifest-path pg-/Cargo.toml --all -- --check` per crate over shared workspace files; always run `cargo fmt --all -- --check` from repo root before pushing, or one crate's drift fails the whole matrix. +- CI's `Format workspace` matrix runs `cargo fmt --manifest-path /Cargo.toml --all -- --check` once per member directory over shared workspace files; always run `cargo fmt --all -- --check` from repo root before pushing, or one crate's drift fails the whole matrix. - `Run wasm tests in browsers` flakes, and the error names the wrong culprit. `Error: missing field 'chunk'` is `wasm-bindgen-test-runner` failing to parse a truncated webdriver reply; the cause is the line above it, `[SEVERE]: Timed out receiving message from renderer: 30.000`. Read the driver stderr before suspecting the test. The matrix is fail-fast, so one browser timing out reports the other two as failures when they were cancelled: check each job's own conclusion, not the summary. Seen on the same sha passing at 07:45 and failing at 07:48 (runs 30432815599 and 30432995207 on #269, a docs-only commit). Re-run rather than debug, and note that `dobby-coder` cannot: `POST /actions/runs/{id}/rerun-failed-jobs` is 403 for the App, so a maintainer has to click it, or a fresh push has to supersede the run. - `scripts/semver-checks.sh` runs `cargo-semver-checks` over the two surfaces external consumers build against: `pg-core` against its crates.io release, and `pg-wasm` against `origin/main` (it has no crates.io release; the npm package is versioned from `pg-core`). The `semver-checks` job in `build.yml` calls it on any PR touching `pg-core`, `pg-wasm`, the root manifest or the script itself; run it yourself too before pushing such a change, since the job needs a wasm32 toolchain and a pinned cargo-semver-checks download and is therefore not the fastest feedback. Four things it encodes. (1) `pg-core` needs `--only-explicit-features --features test,rust,stream`, the same set the test and clippy matrices use: cargo-semver-checks otherwise enables everything that doesn't look unstable, which pulls in `web` and hits its `compile_error!`. (2) `pg-core`'s `web,stream` surface is deliberately not checked. `Unsealer` has two `unseal` methods there on different instantiations (owned `self` in `client/web/mod.rs`, `&mut self` in `client/web/stream.rs`) and cargo-semver-checks 0.49 pairs them by name alone, so it reports `method_receiver_mut_ref_became_owned` against byte-identical source; `rust,stream` is clean because both receivers are owned there. (3) Any wasm32 run needs `RUSTFLAGS=--cap-lints=warn`, because the `--cap-lints allow` cargo-semver-checks sets silences the "dropping unsupported crate type" warnings cargo reads back when probing rustc, and cargo then dies with "output of --print=file-names missing". (4) `cargo-semver-checks` splits its non-zero exits: `100` is a semver violation, `101` is the tool or the build failing (unresolvable baseline rev, missing rustup target, registry fetch failure, compile error in the crate). Never treat "non-zero" as "breaking change" here, because the advice a semver gate prints is "declare the break", and on this repo that means a `!` in the PR title and a spurious major release of `pg-core`. `scripts/semver-checks-test.sh` pins that mapping; it stubs `cargo`, so it runs in well under a second and needs neither cargo-semver-checks nor a wasm32 toolchain. Run it after touching the gate. - release-plz owns the version numbers, so the PR making a breaking change cannot bump the crate to match (bumping `pg-core` alone doesn't even resolve: `pg-cli` requires `^0.6.1`). What the semver gate accepts as the declaration is the conventional-commit `!` in the PR title, and only that; CI turns it into `SEMVER_RELEASE_TYPE=major`, which the script passes as `--release-type major`. A `BREAKING CHANGE:` footer in the PR body is not accepted and must not be: this repo's `squash_merge_commit_message` is `COMMIT_MESSAGES`, so the body never reaches the squashed commit, and release-plz reading a bare `fix(pg-core):` subject would cut a patch release of a break the gate had already waved through. Two consequences of the merge settings worth knowing when you declare a break. `squash_merge_commit_title` is `COMMIT_OR_PR_TITLE`, which is the PR title on a multi-commit PR but the commit's subject when the PR has exactly one commit — so on a single-commit PR put the `!` in the commit subject too, or the gate goes green off the PR title while release-plz cuts a patch. And `--release-type major` doesn't merely permit a bigger bump: every lint exists to demand a bump the declaration already grants, so all of them skip and the run checks nothing (`0 checks: 0 pass, 253 skip`) on both surfaces at once. A green gate on a `!` PR verified nothing; a `!` added for a pg-wasm break also passes any unrelated pg-core break in the same PR. -- There are **two** Docker builds, `Dockerfile` (pg-pkg) and `cryptify/Dockerfile`, each with its own image name and independent version output in `delivery.yml`. cryptify's builds with the **repo root** as context (`file: cryptify/Dockerfile`), because the crate is a workspace member and needs the root manifest and lockfile; it publishes to the same `ghcr.io//cryptify` name the old repo did, so nothing downstream repoints. Both pin the same Rust today (`FROM rust:1.96.1-slim-trixie`), which is older or otherwise different from the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail a Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the current pins, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the image. +- There are **two** Docker builds, `Dockerfile` (pg-pkg) and `cryptify/Dockerfile`, each with its own image name and independent version output in `delivery.yml`. cryptify's builds with the **repo root** as context (`file: cryptify/Dockerfile`), because the crate is a workspace member and needs the root manifest and lockfile; it claims the same `ghcr.io//cryptify` name the old repo published, so nothing downstream has to repoint. It is not publishing yet: `build-cryptify`, `scan-cryptify` and `finalize-cryptify` all carry `if: vars.PUBLISH_CRYPTIFY_IMAGE == 'true'` and skip while that repo variable is unset, which it is until the existing GHCR package grants this repo Write. Don't read the bullet as "the image ships on every main push". Both Dockerfiles pin the same Rust today (`FROM rust:1.96.1-slim-trixie`), which is older or otherwise different from the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail a Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the current pins, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the image. - `pg-pkg/api-description.yaml` is the pinned v2 HTTP contract and is additive-only (see `COMPATIBILITY.md`). Its breaking-change gate is `.github/workflows/api-diff.yml`, which runs on every PR as a **two-spec matrix** — `pg-pkg/api-description.yaml` (versioned `/v2` routes) and `cryptify/api-description.yaml` (unversioned) — so the check names carry the spec path. The matrix is not cosmetic: `pg-pkg/tests/api_gate.rs` and cryptify's in-crate `mod api_gate_tests` both grep this file for exactly **one** `fail-on:` and one `include-checks:` line, so two separate oasdiff steps would give two of each and fail both suites. Vary only the spec paths; moving `fail-on` into the matrix removes the literal both tests look for. `pg-pkg/tests/api_gate.rs` is the executable record of what the gate stops on the pg-pkg side: it mutates the spec 19 ways and asserts each verdict. The verdict test skips when `oasdiff` is not on `PATH`, which is the case in CI; the other two run everywhere, one checking that every mutation still finds its anchor and one reading `fail-on`/`include-checks`/the pinned action ref back out of the workflow YAML, so editing the step and the constants apart fails the suite instead of quietly weakening the gate. The action ref is pinned because it is what selects the engine version (`v0.1.10` is `FROM tufin/oasdiff:v1.26.1`), so bumping the action silently re-measures every verdict. Those anchors are multi-line raw strings matched against the spec read off disk, which is why root `.gitattributes` marks `pg-pkg/api-description.yaml -text`: rustc normalises CRLF inside raw strings but `read_to_string` does not, so without it a Windows checkout fails `every_mutation_still_applies` on a clean tree. Run it (and read it) before touching the gate's settings: `go install github.com/oasdiff/oasdiff@v1.26.1 && cargo test --manifest-path pg-pkg/Cargo.toml --all-features --test api_gate`. - The oasdiff gate's settings are **not** self-evident and `--fail-on ERR` alone fails open. `fail-on: WARN` is deliberate: oasdiff rates removing or renaming an *optional* response property, and removing a request parameter, as WARN, and this spec marks only `status` as `required`, so at ERR the gate silently passed a removed `key` (the IBE user secret key `/v2/request/key` exists to return), a renamed `proofStatus` and a dropped `timestamp` parameter. Two more, a changed non-success status (401 to 403) and a dropped response enum value, rate ERR but are **opt-in**, so they only run when named in `include-checks`. Of the 31 WARN checks, `response-property-enum-value-added` is the only one that fires on something `COMPATIBILITY.md` does not already forbid, which is why that document now names a new response enum value as non-additive too. Reproduce a verdict with the exact flags the action's entrypoint builds (`oasdiff/oasdiff-action/breaking@v0.1.10` is `FROM tufin/oasdiff:v1.26.1`, so the pinned tag is what makes a local run authoritative): `git show origin/main:pg-pkg/api-description.yaml > /tmp/base.yaml && oasdiff breaking /tmp/base.yaml pg-pkg/api-description.yaml --allow-external-refs=false --composed=false --fail-on WARN --include-checks response-non-success-status-removed,response-property-enum-value-removed`. Two traps: `oasdiff --version` prints `oasdiff version main` after a `go install` of a tag because the version comes from release ldflags (the code is still the tag), and `--fail-on` takes `ERR`/`WARN` while `oasdiff checks --severity` takes `error`/`warn`/`info`. `--severity ERR` is a usage error, so `oasdiff checks --severity ERR | wc -l` counts the help text instead and badly undercounts the tier (it is 213 error checks, 31 warn, 265 info). The spec has no external `$ref`s, so `allow-external-refs` stays at its safe (SSRF-guarding) default. The gate only sees paths the spec documents, and the spec documents canonical paths only, so dropping the `/v2/irma/...` alias handlers (#257) passes it. -- **A history-preserving import into this repo silently closes issues here.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. The cryptify re-attach carried 18 of them (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194 #273`); 17 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed by `ba380a14` four seconds after the merge landed. Nothing warned, and it stood for five days. Audit before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Defusing costs every imported SHA (`git filter-repo`), so auditing and reopening is usually the better trade — but decide it deliberately, and do the audit either way. Reading close *state* cannot tell you which happened; read the close **event**, where a keyword-close carries a `commit_id` and a hand-close does not: `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272); the guard lives in encryption4all/postguard-js#139. +- **A history-preserving import into this repo silently closes issues here.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. The cryptify re-attach carried 17 of them (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`); 16 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed by `ba380a14` four seconds after the merge landed. Nothing warned, and it stood four days before being found and reopened. Audit before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only: run over a wider window and ordinary commits' keywords land in the count (that is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge). Defusing costs every imported SHA (`git filter-repo`), so auditing and reopening is usually the better trade — but decide it deliberately, and do the audit either way. Reading close *state* cannot tell you which happened; read the close **event**, where a keyword-close carries a `commit_id` and a hand-close does not: `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272); the guard lives in encryption4all/postguard-js#139. - The `dobby-coder` GitHub App lacks `workflows: write` on this repo; any push touching `.github/workflows/*.yml` is rejected at the remote. Before treating a fix as blocked, check whether the same effect can be achieved in a pushable file (crate manifest, source, committed script); if a fix genuinely can only live in a workflow file, ship the pushable half and hand the maintainer ready-to-paste YAML in the PR body. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`. Measured exception, worth trying before handing the sync over: the App pushed `ce0fc59` on this branch, a merge whose diff against its first parent added main's 64 new `build.yml` lines. That merge needed no resolution inside `build.yml` — it took main's side whole, so the blob it committed already existed in the repo. Try the merge and read the remote's answer; only escalate on an actual rejection. ## Dependencies From 277e85096ad0c10ac51a2a02705456cf1e91a219 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Tue, 4 Aug 2026 15:27:29 +0200 Subject: [PATCH 3/4] docs: fix the close vector and the commit_id rule in the import bullet Both findings re-derived before applying; both hold. The vector was wrong, and wrongly framed. ba380a1 has a single parent, so PR #277 was squash-merged, and squash_merge_commit_message = COMMIT_MESSAGES concatenates every imported commit message into the merge body -- 1678 lines carrying all 17 refs. That is what closed #146, four seconds after it landed. The history re-attach (9887e1a, a two-parent merge) carries no keywords of its own and landed thirteen minutes later, when #146 was already closed, so it cannot be the cause. The bullet said the opposite, which would teach the next agent that a plain squash merge of an import PR is safe. It is sufficient on its own, and it fires precisely because squashing concatenates the messages while discarding the history. That also surfaces a cheaper defusal than filter-repo: edit the squash body at merge time, no SHA rewrite. The commit_id heuristic was falsified by the bullet's own example. It claimed a keyword close carries a commit_id and a hand close does not, but #273 -- named right there as legitimately closed by a55c6a0, whose message ends in Closes #273 -- has commit_id: null. The discriminator is where the keyword sat: a commit-message keyword is attributed to the commit and carries the sha, a PR-body keyword is attributed to the PR and carries null. #146 has the sha because PR #277's body said only Closes #255; #273 has null because PR #274's body said Closes #273. So null means "not attributed to a commit", not "closed by hand", and the old rule would have called a real keyword close a hand close. Also fixed a stale cross-reference: the squash setting is documented in the release-plz bullet above, not below. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c8dad920..cc47845c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the h - There are **two** Docker builds, `Dockerfile` (pg-pkg) and `cryptify/Dockerfile`, each with its own image name and independent version output in `delivery.yml`. cryptify's builds with the **repo root** as context (`file: cryptify/Dockerfile`), because the crate is a workspace member and needs the root manifest and lockfile; it claims the same `ghcr.io//cryptify` name the old repo published, so nothing downstream has to repoint. It is not publishing yet: `build-cryptify`, `scan-cryptify` and `finalize-cryptify` all carry `if: vars.PUBLISH_CRYPTIFY_IMAGE == 'true'` and skip while that repo variable is unset, which it is until the existing GHCR package grants this repo Write. Don't read the bullet as "the image ships on every main push". Both Dockerfiles pin the same Rust today (`FROM rust:1.96.1-slim-trixie`), which is older or otherwise different from the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail a Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the current pins, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the image. - `pg-pkg/api-description.yaml` is the pinned v2 HTTP contract and is additive-only (see `COMPATIBILITY.md`). Its breaking-change gate is `.github/workflows/api-diff.yml`, which runs on every PR as a **two-spec matrix** — `pg-pkg/api-description.yaml` (versioned `/v2` routes) and `cryptify/api-description.yaml` (unversioned) — so the check names carry the spec path. The matrix is not cosmetic: `pg-pkg/tests/api_gate.rs` and cryptify's in-crate `mod api_gate_tests` both grep this file for exactly **one** `fail-on:` and one `include-checks:` line, so two separate oasdiff steps would give two of each and fail both suites. Vary only the spec paths; moving `fail-on` into the matrix removes the literal both tests look for. `pg-pkg/tests/api_gate.rs` is the executable record of what the gate stops on the pg-pkg side: it mutates the spec 19 ways and asserts each verdict. The verdict test skips when `oasdiff` is not on `PATH`, which is the case in CI; the other two run everywhere, one checking that every mutation still finds its anchor and one reading `fail-on`/`include-checks`/the pinned action ref back out of the workflow YAML, so editing the step and the constants apart fails the suite instead of quietly weakening the gate. The action ref is pinned because it is what selects the engine version (`v0.1.10` is `FROM tufin/oasdiff:v1.26.1`), so bumping the action silently re-measures every verdict. Those anchors are multi-line raw strings matched against the spec read off disk, which is why root `.gitattributes` marks `pg-pkg/api-description.yaml -text`: rustc normalises CRLF inside raw strings but `read_to_string` does not, so without it a Windows checkout fails `every_mutation_still_applies` on a clean tree. Run it (and read it) before touching the gate's settings: `go install github.com/oasdiff/oasdiff@v1.26.1 && cargo test --manifest-path pg-pkg/Cargo.toml --all-features --test api_gate`. - The oasdiff gate's settings are **not** self-evident and `--fail-on ERR` alone fails open. `fail-on: WARN` is deliberate: oasdiff rates removing or renaming an *optional* response property, and removing a request parameter, as WARN, and this spec marks only `status` as `required`, so at ERR the gate silently passed a removed `key` (the IBE user secret key `/v2/request/key` exists to return), a renamed `proofStatus` and a dropped `timestamp` parameter. Two more, a changed non-success status (401 to 403) and a dropped response enum value, rate ERR but are **opt-in**, so they only run when named in `include-checks`. Of the 31 WARN checks, `response-property-enum-value-added` is the only one that fires on something `COMPATIBILITY.md` does not already forbid, which is why that document now names a new response enum value as non-additive too. Reproduce a verdict with the exact flags the action's entrypoint builds (`oasdiff/oasdiff-action/breaking@v0.1.10` is `FROM tufin/oasdiff:v1.26.1`, so the pinned tag is what makes a local run authoritative): `git show origin/main:pg-pkg/api-description.yaml > /tmp/base.yaml && oasdiff breaking /tmp/base.yaml pg-pkg/api-description.yaml --allow-external-refs=false --composed=false --fail-on WARN --include-checks response-non-success-status-removed,response-property-enum-value-removed`. Two traps: `oasdiff --version` prints `oasdiff version main` after a `go install` of a tag because the version comes from release ldflags (the code is still the tag), and `--fail-on` takes `ERR`/`WARN` while `oasdiff checks --severity` takes `error`/`warn`/`info`. `--severity ERR` is a usage error, so `oasdiff checks --severity ERR | wc -l` counts the help text instead and badly undercounts the tier (it is 213 error checks, 31 warn, 265 info). The spec has no external `$ref`s, so `allow-external-refs` stays at its safe (SSRF-guarding) default. The gate only sees paths the spec documents, and the spec documents canonical paths only, so dropping the `/v2/irma/...` alias handlers (#257) passes it. -- **A history-preserving import into this repo silently closes issues here.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. The cryptify re-attach carried 17 of them (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`); 16 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed by `ba380a14` four seconds after the merge landed. Nothing warned, and it stood four days before being found and reopened. Audit before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only: run over a wider window and ordinary commits' keywords land in the count (that is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge). Defusing costs every imported SHA (`git filter-repo`), so auditing and reopening is usually the better trade — but decide it deliberately, and do the audit either way. Reading close *state* cannot tell you which happened; read the close **event**, where a keyword-close carries a `commit_id` and a hand-close does not: `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272); the guard lives in encryption4all/postguard-js#139. +- **Importing another repo's history here silently closes issues here, and the *squash merge* is what fires it.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. The delivery vector is this repo's `squash_merge_commit_message = COMMIT_MESSAGES` (the same setting the release-plz bullet above turns on its head — there it is why a `BREAKING CHANGE:` footer in a PR body never reaches the commit): squash-merging the import PR concatenates every imported commit message into the merge commit's body, so `ba380a1`'s body is 1678 lines and carries all 17 refs (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`). 16 pointed at numbers already closed here; one — #146, a live unimplemented feature request — was closed by `ba380a1` four seconds after it landed, nothing warned, and it stood four days before being found and reopened. **Preserving the history is not the trigger, and assuming it is gets the risk backwards:** the re-attach merge (`9887e1a`) carries no keywords of its own and landed thirteen minutes later, when #146 was already closed. A plain squash merge of an import PR is sufficient on its own — and it fires *because* squashing concatenates the messages, even though squashing throws the history away. That also makes the cheapest defusal the merge itself: the squash body is editable in the merge dialog and via `commit_message` on `PUT /pulls/N/merge`, so strip the keywords there. Rewriting them out of the imported commits works too and costs every imported SHA (`git filter-repo`). Either way, audit before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only — run it over a wider window and ordinary commits' keywords land in the count, which is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge. Reading close *state* cannot tell you which happened, so read the close **event** — and read it correctly, because the obvious rule is wrong. A keyword in a *commit message* is attributed to the commit and carries a `commit_id`; a keyword in the *PR body* is attributed to the PR and carries `commit_id: null`. #146 shows `ba380a1464…` because PR #277's body said only `Closes #255`, while #273 shows `null` because PR #274's body said `Closes #273`. So `null` means "not attributed to a commit", **not** "closed by hand": `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272); the guard lives in encryption4all/postguard-js#139. - The `dobby-coder` GitHub App lacks `workflows: write` on this repo; any push touching `.github/workflows/*.yml` is rejected at the remote. Before treating a fix as blocked, check whether the same effect can be achieved in a pushable file (crate manifest, source, committed script); if a fix genuinely can only live in a workflow file, ship the pushable half and hand the maintainer ready-to-paste YAML in the PR body. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`. Measured exception, worth trying before handing the sync over: the App pushed `ce0fc59` on this branch, a merge whose diff against its first parent added main's 64 new `build.yml` lines. That merge needed no resolution inside `build.yml` — it took main's side whole, so the blob it committed already existed in the repo. Try the merge and read the remote's answer; only escalate on an actual rejection. ## Dependencies From 18bdf084c5143fde76f083c3391c2d0e88339612 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Tue, 4 Aug 2026 15:51:51 +0200 Subject: [PATCH 4/4] docs: state both import close-vectors, drop the unverified defusal Cycle-3 review findings, both re-derived from primary sources first. The blocking one is right and my previous fix overcorrected. "Preserving the history is not the trigger" is false as a general rule, and the counterexample is the case the bullet cites as its own guard. Measured on postguard-js: import PR #137 merged with a REAL two-parent merge commit (b1bb2ee, three-line message, zero closing keywords, no squash body anywhere), and js#128/#129 closed six seconds later attributed to imported commits a0ce27f/2acf42f -- single-parent, committed 2026-06-04 -- then reopened fifteen minutes later. In the history-preserving case GitHub never reads the merge commit's message; it attributes the closes to the imported commits once they become reachable from the default branch. What postguard's own incident supports is weaker than what I wrote: the squash body got there first, and by the time 9887e1a landed all 17 targets were already closed, so GitHub had nothing left to close. That vector was untested here, not disproven. The bullet now states both as independently sufficient, which also matters because the squash-body defusal does not exist on vector 2 -- there is no body to edit. Dropped the commit_message-on-PUT-/pulls/N/merge recipe rather than keep it. The REST reference words that field "Extra detail to append to automatic commit message" and documents nothing about merge_method=squash, so whether it replaces the concatenated body is unverified -- and if it appends, the defusal silently no-ops and every keyword still fires. Recorded as an open question to confirm on a throwaway repo, not as a recipe. Non-blocking: the sqlx ceiling is binding today, not future. rusqlite 0.40.0 wants libsqlite3-sys ^0.38.0 and 0.40.1 wants ^0.38.1, both published and unyanked, both outside sqlx-sqlite 0.9.0's >=0.30.1, <0.38.0 -- so a lone rusqlite bump does not resolve at all. "Future" invited a reader to treat the current pins as headroom and misdiagnose the resolver error. --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cc47845c..72f2cdf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the h ## Workspace & CI - Workspace members: `pg-core` (lib), `pg-ffi` (C ABI), `pg-pkg` (PKG service), `pg-cli`, `cryptify` (file-transfer service, merged in 2026-07-31). `pg-wasm` is a sibling crate the root `Cargo.toml` lists under `exclude`, so it is not part of the workspace and is built separately with wasm-pack (see Release & configuration). Sub-crates share workspace files. Build the workspace from repo root with `cargo build`. A bare `cargo test --workspace` FAILS to compile: `pg-core`'s tests are gated behind its `test` feature (also `rust`/`stream`), so the item is configured out and imports like `crate::test::TestSetup` don't resolve. CI (`.github/workflows/build.yml`) runs tests per crate: `cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream` for core, `--all-targets` for `cryptify` (it declares no features of its own, so that is the equivalent invocation), `--all-features` for `pkg`/`cli`/`ffi`. None of these cover `pg-wasm`. The `test`/`format`/`clippy` matrices key on the crate **directory** rather than a `pg-` suffix, because `cryptify` doesn't carry that prefix — so the check contexts are `Test workspace (pg-core)`, not `Test workspace (core)`. Nothing required points at them today, but whatever adds to the required list later must use the current names. Where that list lives is not obvious and an agent cannot read it: required checks come from **classic branch protection** (`gh api repos/encryption4all/postguard/branches/main/protection`, currently exactly `Wire compat (published pg-core)` and `Wire compat (published pg-wasm/pg-js)`), *not* from the `main` ruleset, which carries only `deletion`/`non_fast_forward`/`pull_request` and no `required_status_checks` rule at all. `dobby-coder` gets 403 on the protection endpoint, so reading the ruleset alone concludes nothing is required, which is wrong — ask a maintainer rather than inferring. `pg-core` uses CGWKV + MKEM for multi-recipient encryption (production feature set `["cgwkv", "mkem"]`). -- `cryptify` was a separate repo until `ba380a1` (#277) merged it as a workspace member, history-preserving, the deliberate yes decided in #255/#285. What that bought: one lockfile, which closes a real drift class — the two trees previously built `pg-core`'s container serializer against two different `bincode-next` versions with nothing comparing them. What it costs is a **standing dependency coupling, and it does not go away**: `pg-pkg` reaches `libsqlite3-sys` through `sqlx`, `cryptify` reaches it through `rusqlite`, and it is `libsqlite3-sys` itself that declares `links = "sqlite3"` (`rusqlite`, `sqlx` and `sqlx-sqlite` all declare none — check with `cargo metadata`, not by guessing which crate in the chain owns it). Only one package per resolve graph may claim a given `links` value, and optional dependencies sit in the graph whether their feature is enabled or not — so `sqlx-sqlite` is locked even though `pg-pkg` enables only `postgres`. The newest intersecting pair is **sqlx 0.9 + rusqlite 0.39** (`libsqlite3-sys 0.37` in the root lockfile), so every future `rusqlite` bump waits on sqlx's `libsqlite3-sys` ceiling. Don't bump either half alone; check the intersection first. +- `cryptify` was a separate repo until `ba380a1` (#277) merged it as a workspace member, history-preserving, the deliberate yes decided in #255/#285. What that bought: one lockfile, which closes a real drift class — the two trees previously built `pg-core`'s container serializer against two different `bincode-next` versions with nothing comparing them. What it costs is a **standing dependency coupling, and it does not go away**: `pg-pkg` reaches `libsqlite3-sys` through `sqlx`, `cryptify` reaches it through `rusqlite`, and it is `libsqlite3-sys` itself that declares `links = "sqlite3"` (`rusqlite`, `sqlx` and `sqlx-sqlite` all declare none — check with `cargo metadata`, not by guessing which crate in the chain owns it). Only one package per resolve graph may claim a given `links` value, and optional dependencies sit in the graph whether their feature is enabled or not — so `sqlx-sqlite` is locked even though `pg-pkg` enables only `postgres`. The newest intersecting pair is **sqlx 0.9 + rusqlite 0.39** (`libsqlite3-sys 0.37` in the root lockfile), so every `rusqlite` bump waits on sqlx's `libsqlite3-sys` ceiling — and that block is live today, not merely future: `rusqlite` 0.40.0 wants `libsqlite3-sys ^0.38.0` and 0.40.1 wants `^0.38.1`, both published, neither yanked, and both outside `sqlx-sqlite` 0.9.0's `>=0.30.1, <0.38.0`, so a lone rusqlite bump does not resolve at all. Don't bump either half alone; check the intersection first. - `pg-compat` is a second excluded sibling crate (root `Cargo.toml` `exclude`), and the exclusion is load-bearing: it depends on `pg-core` from **crates.io** (`=0.6.1`), not on `../pg-core`, so it can open bytes sealed by this tree with published readers. It has its own `Cargo.lock` (run it with `--locked`). Its input comes from `cargo run -p pg-core --features stream --example seal-samples -- `, a deterministic sealer whose output layout is documented in `pg-compat/README.md`. CI wires the two together: `wire-compat-rust` in `build.yml` seals with HEAD and opens with published pg-core on any PR touching the wire surface (pg-core/pg-wasm/pg-compat/pg-compat-js trees, the ROOT `Cargo.lock`/`Cargo.toml` — pg-core resolves from the root lockfile — and build.yml itself); `pg-compat-lint` covers the crate's fmt/clippy, which the per-crate matrices don't. The `=0.6.1` pin is currently one release behind: `pg-core` 0.6.2 published 2026-07-30 and neither the pin nor `COMPATIBILITY.md`'s `crates.io` reader row moved, because nothing reads those rows — the npm rows cannot drift this way, since `manifest.test.mjs` machine-reads them. Tracked in #268; move the row and the pin together. - `pg-compat-js` is the Node half of the same gate: a private npm package (no build step, no lint/format tooling in the repo) that opens the sealer's output with the published npm readers, installed side by side through `npm:` aliases (`pg-wasm-0-6-1`, `pg-js-2-3-3`, `pg-js-1-11-0`) because several versions of one package have to coexist. Run it locally with `npm --prefix pg-compat-js ci` then `PG_COMPAT_ARTIFACTS= npm --prefix pg-compat-js test`; in CI the `wire-compat-js` job in `build.yml` downloads the artifact `wire-compat-rust` sealed and runs the same suite, gated on `needs.wire-compat-rust.outputs.sealed` rather than a second copy of the path filter. `node --test test/` does NOT work (Node reads the bare directory as a module path and dies with `Cannot find module .../test`) — the `test` script passes the glob `"test/*.test.mjs"` instead. Its reader list is not free to edit on its own: `test/manifest.test.mjs` parses the fenced `Reader list` block in root `COMPATIBILITY.md` (` ` rows) and compares the `npm` rows against `readers()`, so that block is machine-read and reformatting it into prose or a table breaks the test. It also compares each declared `package` and `version` against the installed alias's own `package.json`, read off disk because neither published package exports `package.json` (`import('/package.json')` is `ERR_PACKAGE_PATH_NOT_EXPORTED`). - JS-reader gotchas the Node gate ran into, all still true of `@e4a/pg-wasm` 0.6.1 and `@e4a/pg-js` 1.11.0/2.3.3: (a) `@e4a/pg-wasm`'s default (bundler) entry does `import * as wasm from "./index_bg.wasm"`, which plain Node cannot resolve — import `@e4a/pg-wasm/web` and pass the module bytes to its default export, resolving them via `new URL('index_bg.wasm', import.meta.resolve('@e4a/pg-wasm/web'))` since the `.wasm` file is not in the package's `exports`; (b) `Unsealer.unseal()`/`StreamUnsealer.unseal()` *consume* the unsealer (wasm-bindgen `__destroy_into_raw`), so a tidy `free()` afterwards is a double free reported as `null pointer passed to rust`, which reads exactly like a corrupt container; (c) `pg-js` is stream-mode only in both directions — `toBytes()` seals with `sealStream` and its decrypt path only ever builds a `StreamUnsealer`, so a memory-mode container fails with `mode is not supported: InMemory { size: N }`; (d) `pg-js` 1.x discards what `StreamUnsealer.unseal()` returns and reports `public_identity()` instead, so it never surfaces the private signing policy of a `*-privsig` container and its `sender.raw` is the bare header policy rather than 2.x's `{public, private?}`; (e) `pg-js`'s decrypt path needs a PKG at `pkgUrl` for exactly two GETs — `/v2/sign/parameters` for the verifying key and `/v2/irma/key/` (bearer token) for the USK — which is why the gate can drive it offline from the artifact's own `vk.json`/`usk-*.json`. @@ -17,7 +17,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the h - There are **two** Docker builds, `Dockerfile` (pg-pkg) and `cryptify/Dockerfile`, each with its own image name and independent version output in `delivery.yml`. cryptify's builds with the **repo root** as context (`file: cryptify/Dockerfile`), because the crate is a workspace member and needs the root manifest and lockfile; it claims the same `ghcr.io//cryptify` name the old repo published, so nothing downstream has to repoint. It is not publishing yet: `build-cryptify`, `scan-cryptify` and `finalize-cryptify` all carry `if: vars.PUBLISH_CRYPTIFY_IMAGE == 'true'` and skip while that repo variable is unset, which it is until the existing GHCR package grants this repo Write. Don't read the bullet as "the image ships on every main push". Both Dockerfiles pin the same Rust today (`FROM rust:1.96.1-slim-trixie`), which is older or otherwise different from the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail a Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the current pins, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the image. - `pg-pkg/api-description.yaml` is the pinned v2 HTTP contract and is additive-only (see `COMPATIBILITY.md`). Its breaking-change gate is `.github/workflows/api-diff.yml`, which runs on every PR as a **two-spec matrix** — `pg-pkg/api-description.yaml` (versioned `/v2` routes) and `cryptify/api-description.yaml` (unversioned) — so the check names carry the spec path. The matrix is not cosmetic: `pg-pkg/tests/api_gate.rs` and cryptify's in-crate `mod api_gate_tests` both grep this file for exactly **one** `fail-on:` and one `include-checks:` line, so two separate oasdiff steps would give two of each and fail both suites. Vary only the spec paths; moving `fail-on` into the matrix removes the literal both tests look for. `pg-pkg/tests/api_gate.rs` is the executable record of what the gate stops on the pg-pkg side: it mutates the spec 19 ways and asserts each verdict. The verdict test skips when `oasdiff` is not on `PATH`, which is the case in CI; the other two run everywhere, one checking that every mutation still finds its anchor and one reading `fail-on`/`include-checks`/the pinned action ref back out of the workflow YAML, so editing the step and the constants apart fails the suite instead of quietly weakening the gate. The action ref is pinned because it is what selects the engine version (`v0.1.10` is `FROM tufin/oasdiff:v1.26.1`), so bumping the action silently re-measures every verdict. Those anchors are multi-line raw strings matched against the spec read off disk, which is why root `.gitattributes` marks `pg-pkg/api-description.yaml -text`: rustc normalises CRLF inside raw strings but `read_to_string` does not, so without it a Windows checkout fails `every_mutation_still_applies` on a clean tree. Run it (and read it) before touching the gate's settings: `go install github.com/oasdiff/oasdiff@v1.26.1 && cargo test --manifest-path pg-pkg/Cargo.toml --all-features --test api_gate`. - The oasdiff gate's settings are **not** self-evident and `--fail-on ERR` alone fails open. `fail-on: WARN` is deliberate: oasdiff rates removing or renaming an *optional* response property, and removing a request parameter, as WARN, and this spec marks only `status` as `required`, so at ERR the gate silently passed a removed `key` (the IBE user secret key `/v2/request/key` exists to return), a renamed `proofStatus` and a dropped `timestamp` parameter. Two more, a changed non-success status (401 to 403) and a dropped response enum value, rate ERR but are **opt-in**, so they only run when named in `include-checks`. Of the 31 WARN checks, `response-property-enum-value-added` is the only one that fires on something `COMPATIBILITY.md` does not already forbid, which is why that document now names a new response enum value as non-additive too. Reproduce a verdict with the exact flags the action's entrypoint builds (`oasdiff/oasdiff-action/breaking@v0.1.10` is `FROM tufin/oasdiff:v1.26.1`, so the pinned tag is what makes a local run authoritative): `git show origin/main:pg-pkg/api-description.yaml > /tmp/base.yaml && oasdiff breaking /tmp/base.yaml pg-pkg/api-description.yaml --allow-external-refs=false --composed=false --fail-on WARN --include-checks response-non-success-status-removed,response-property-enum-value-removed`. Two traps: `oasdiff --version` prints `oasdiff version main` after a `go install` of a tag because the version comes from release ldflags (the code is still the tag), and `--fail-on` takes `ERR`/`WARN` while `oasdiff checks --severity` takes `error`/`warn`/`info`. `--severity ERR` is a usage error, so `oasdiff checks --severity ERR | wc -l` counts the help text instead and badly undercounts the tier (it is 213 error checks, 31 warn, 265 info). The spec has no external `$ref`s, so `allow-external-refs` stays at its safe (SSRF-guarding) default. The gate only sees paths the spec documents, and the spec documents canonical paths only, so dropping the `/v2/irma/...` alias handlers (#257) passes it. -- **Importing another repo's history here silently closes issues here, and the *squash merge* is what fires it.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. The delivery vector is this repo's `squash_merge_commit_message = COMMIT_MESSAGES` (the same setting the release-plz bullet above turns on its head — there it is why a `BREAKING CHANGE:` footer in a PR body never reaches the commit): squash-merging the import PR concatenates every imported commit message into the merge commit's body, so `ba380a1`'s body is 1678 lines and carries all 17 refs (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`). 16 pointed at numbers already closed here; one — #146, a live unimplemented feature request — was closed by `ba380a1` four seconds after it landed, nothing warned, and it stood four days before being found and reopened. **Preserving the history is not the trigger, and assuming it is gets the risk backwards:** the re-attach merge (`9887e1a`) carries no keywords of its own and landed thirteen minutes later, when #146 was already closed. A plain squash merge of an import PR is sufficient on its own — and it fires *because* squashing concatenates the messages, even though squashing throws the history away. That also makes the cheapest defusal the merge itself: the squash body is editable in the merge dialog and via `commit_message` on `PUT /pulls/N/merge`, so strip the keywords there. Rewriting them out of the imported commits works too and costs every imported SHA (`git filter-repo`). Either way, audit before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only — run it over a wider window and ordinary commits' keywords land in the count, which is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge. Reading close *state* cannot tell you which happened, so read the close **event** — and read it correctly, because the obvious rule is wrong. A keyword in a *commit message* is attributed to the commit and carries a `commit_id`; a keyword in the *PR body* is attributed to the PR and carries `commit_id: null`. #146 shows `ba380a1464…` because PR #277's body said only `Closes #255`, while #273 shows `null` because PR #274's body said `Closes #273`. So `null` means "not attributed to a commit", **not** "closed by hand": `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272); the guard lives in encryption4all/postguard-js#139. +- **Importing another repo's history here silently closes issues here. Two vectors fire it, each sufficient on its own, so rule out neither.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. **Vector 1, the squash body.** This repo's `squash_merge_commit_message` is `COMMIT_MESSAGES` (the same setting the release-plz bullet above turns on its head — there it is why a `BREAKING CHANGE:` footer in a PR body never reaches the commit), so squash-merging the import PR concatenates every imported commit message into the merge commit's body. `ba380a1`'s body is 1678 lines and carries all 17 refs (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`); 16 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed four seconds after it landed, with nothing warning, and stood four days before being found and reopened. Note what this means: a plain squash merge is enough by itself, *because* squashing concatenates the messages even as it throws the history away. **Vector 2, the history itself.** Here GitHub never reads the merge commit's message — it attributes the closes to the **imported commits**, once they become reachable from the default branch. This vector went untested in this repo rather than disproven: `9887e1a` carries no keywords of its own, and by the time it landed thirteen minutes later all 17 targets were already closed, and GitHub does not re-close a closed issue. It is measured in encryption4all/postguard-js#139, which is also the guard: import PR #137 merged with a real two-parent merge commit (`b1bb2ee`, a three-line message, no keywords, no squash body anywhere), and js#128/#129 closed six seconds later attributed to imported commits `a0ce27f`/`2acf42f` — single-parent, committed 2026-06-04 — then reopened fifteen minutes later. **So the audit is the only step that covers both.** Before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only — run it over a wider window and ordinary commits' keywords land in the count, which is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge. Editing the squash body in the merge dialog defuses vector 1 and only vector 1; on vector 2 there is no body to edit, and rewriting the keywords out of the imported commits costs every imported SHA (`git filter-repo`). Do **not** reach for `commit_message` on `PUT /pulls/N/merge` as the scripted version of that: the REST reference words it "Extra detail to append to automatic commit message" and documents nothing about `merge_method=squash`, so whether it replaces the concatenated body or appends to it is unverified — and if it appends, the defusal silently no-ops and every keyword still fires. Confirm it on a throwaway repo and record the answer here before relying on it. Reading close *state* cannot tell you which happened, so read the close **event** — and read it correctly, because the obvious rule is wrong. A keyword in a *commit message* is attributed to the commit and carries a `commit_id`; a keyword in the *PR body* is attributed to the PR and carries `commit_id: null`. #146 shows `ba380a1464…` because PR #277's body said only `Closes #255`, while #273 shows `null` because PR #274's body said `Closes #273`. So `null` means "not attributed to a commit", **not** "closed by hand": `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272). - The `dobby-coder` GitHub App lacks `workflows: write` on this repo; any push touching `.github/workflows/*.yml` is rejected at the remote. Before treating a fix as blocked, check whether the same effect can be achieved in a pushable file (crate manifest, source, committed script); if a fix genuinely can only live in a workflow file, ship the pushable half and hand the maintainer ready-to-paste YAML in the PR body. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`. Measured exception, worth trying before handing the sync over: the App pushed `ce0fc59` on this branch, a merge whose diff against its first parent added main's 64 new `build.yml` lines. That merge needed no resolution inside `build.yml` — it took main's side whole, so the blob it committed already existed in the repo. Try the merge and read the remote's answer; only escalate on an actual rejection. ## Dependencies