Skip to content

feat(cryptify): merge cryptify into the workspace - #277

Merged
rubenhensen merged 407 commits into
mainfrom
spike/cryptify-in-workspace
Jul 31, 2026
Merged

feat(cryptify): merge cryptify into the workspace#277
rubenhensen merged 407 commits into
mainfrom
spike/cryptify-in-workspace

Conversation

@rubenhensen

Copy link
Copy Markdown
Contributor

Closes #255. The decision comment on that issue has the full spike writeup and the measurements; this is the change it recommends.

cryptify becomes a workspace member, compiled against the in-tree pg-core. All 402 of its commits come across as a history-preserving subtree merge.

Why

Today postguard builds pg-core against bincode-next 3.0.0-rc.14 and cryptify builds pg-core against bincode-next 3.1.1, because each repo resolves its own lockfile. Same source, two versions of the serializer that writes the container header, in two services that exchange containers, with nothing comparing them. CLAUDE.md's rule (pin the exact rc, re-audit on any bump) is not reaching cryptify's copy.

That is benign right now, and provably so: pg-compat's lockfile is also on 3.1.1, so the green wire-compat check already demonstrates rc.14-sealed bytes opening under 3.1.1. But it falls out of another gate's dependency resolution by accident, and the next bincode bump is unguarded. One workspace is one lockfile, and the #127 memory-vs-stream class becomes a PR-time test failure rather than a cross-repo surprise.

The price

libsqlite3-sys sets links = "sqlite3" and only one package per resolve graph may claim it. pg-pkg reaches it through sqlx, cryptify through rusqlite. Optional deps are in the graph regardless of features, so pg-pkg pulls sqlx-sqlite in despite enabling only postgres.

sqlx-sqlite 0.9.0  wants libsqlite3-sys >=0.30.1, <0.38.0
rusqlite    0.40.1 wants libsqlite3-sys ^0.38.1

Empty intersection. The newest pair that intersects is sqlx 0.9 + rusqlite 0.39, so this PR carries both. pg-pkg needed no source changes for sqlx 0.9.

The standing cost, and the part worth a deliberate yes: every future rusqlite bump now waits on sqlx's libsqlite3-sys ceiling. cryptify is already one release ahead of what sqlx permits.

What else had to change, and why

  • cryptify/build.rs read Cargo.lock beside its manifest to stamp PG_CORE_VERSION into the X-PostGuard header. A member has no lockfile there, so it now walks up from CARGO_MANIFEST_DIR.
  • api-diff.yml covers both specs via a matrix, not two jobs. pg-pkg/tests/api_gate.rs and cryptify's mod api_gate_tests each require exactly one fail-on: and one include-checks: line in that file, so neither can claim a setting the committed job does not use. Two oasdiff steps would give two of each and fail both suites.
  • build.yml's matrices key on the crate directory instead of a pg- prefix, which renames the Test/Format/Clippy workspace (…) contexts. Only the two Wire compat contexts are required on main, so nothing breaks, but a ruleset added later needs the new names.
  • Both Dockerfiles. cryptify's builds from the repo root now. postguard's needed COPY cryptify (cargo-chef loads every member's manifest even for a build that never compiles it) and its Rust pin moved 1.91.1 → 1.96.1, because sqlx 0.9 declares rust-version = 1.94.0. Both of those passed the whole test matrix on host stable and failed only in Docker.
  • cryptify's own .github/, Cargo.lock and release-plz.toml are gone, replaced by an entry in the root release-plz.toml.

Verification

Run locally on this branch, mirroring build.yml's per-crate invocations:

lane result
pg-core (test,rust,stream) pass, 18s
pg-pkg (all-features) pass, 29s
pg-cli (all-features) pass, 17s
pg-ffi (all-features) pass, 10s
cryptify (all-targets) 157 tests pass, 27s
cryptify clippy -D warnings pass
cargo fmt --all --check pass
wire-compat (seal with HEAD, open with published pg-core) pass

Both images build: docker build . and docker build -f cryptify/Dockerfile ..

Deliberately not in this PR

Image publishing still comes from the cryptify repo. delivery.yml's Docker jobs push to ghcr.io/${{ github.repository }}, so publishing cryptify's image from here means either renaming it away from ghcr.io/encryption4all/cryptify or changing that package's GHCR access. Either affects whatever pulls it, so it is an ops decision rather than a mechanical port.

That makes this a coherent intermediate state, not a half-finished one: postguard becomes source of truth for the code and gets the one-lockfile guarantee immediately, while the cryptify repo keeps building and pushing images exactly as it does today. Decommissioning it is a follow-up, and it needs privacybydesign/postguard-ops in the loop.

Also outstanding: release-plz will treat cryptify as an initial release. A subtree import brings commits, not tags, and the old tags were bare v0.1.27 against this workspace's cryptify-v{version} pattern. Without a hand-placed cryptify-v0.1.27 tag on the import commit, the first changelog entry covers all 402 imported commits. Worth doing before this lands on main.

Reviewing this

The subtree merge makes the diff large. git diff main...HEAD -- . ':!cryptify' is the 8-file change to the existing tree; everything under cryptify/ is imported as-is apart from Cargo.toml, build.rs, Dockerfile and the one include_str! path in src/main.rs.

rubenhensen and others added 30 commits February 12, 2026 16:17
Remove frontend, add dev API UI, and clean up project
Add linux/arm64 Docker image builds
Remove frontend, improve backend reliability
Replace single QEMU multi-platform job with parallel native runner jobs
that merge into a multi-arch manifest. Add cargo-chef to Dockerfile for
dependency layer caching. Add release/edge Cargo profiles (lto=true for
published releases, lto=false for all other builds).
anchore/scan-action@v3 was pinning grype v0.74.4 (Nov 2023). v7.3.2
brings grype v0.104+ with an up-to-date vulnerability database.

github/codeql-action/upload-sarif@v3 was showing a deprecation warning
and will be removed in December 2026; upgrade to v4.

Both actions are now pinned to their commit SHA per security best practice.
Bumps [keccak](https://github.com/RustCrypto/sponges) from 0.1.5 to 0.1.6.
- [Commits](RustCrypto/sponges@keccak-v0.1.5...keccak-v0.1.6)

---
updated-dependencies:
- dependency-name: keccak
  dependency-version: 0.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.1 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](tokio-rs/bytes@v1.10.1...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [time](https://github.com/time-rs/time) from 0.3.44 to 0.3.47.
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](time-rs/time@v0.3.44...v0.3.47)

---
updated-dependencies:
- dependency-name: time
  dependency-version: 0.3.47
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
…keccak-0.1.6

chore(deps): bump keccak from 0.1.5 to 0.1.6 in /cryptify
…bytes-1.11.1

chore(deps): bump bytes from 1.10.1 to 1.11.1 in /cryptify
…time-0.3.47

chore(deps): bump time from 0.3.44 to 0.3.47 in /cryptify
Many email clients strip out SVGs for security and anti-tracking
reasons, causing the PostGuard logo to not render for a large portion
of recipients. Replace the inline SVG logo with a hosted PNG image
from postguard.eu.

Closes encryption4all/postguard-website#29
…site

fix: replace SVGs with accessible alternatives in email template
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.8 to 0.103.10.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](rustls/webpki@v/0.103.8...v/0.103.10)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
…rustls-webpki-0.103.10

chore(deps): bump rustls-webpki from 0.103.8 to 0.103.10 in /cryptify
…als-separate-fields

feat: accept smtp_username and smtp_password as separate config fields
dobby-coder Bot and others added 16 commits July 14, 2026 13:43
…email (#187)

* fix(email): make recipient download link a prominent selectable code block

The notification email's download-link block ({{link_str}}) rendered at
13px — smaller than the 16px primary button above it — and used a low
weight/contrast style that was hard to read and select. July 2026 user
test (postguard-website#296) flagged it as small and hard to copy.

Restyle the {{link_str}} URL as a full-width monospace code block:
- font-size 16px (no smaller than the primary button)
- monospace family, higher contrast (dark #030E17 text)
- more padding, subtle background + border so it reads as a code block
- display:block + word-break for easy full selection

No inline copy buttons (unreliable across email clients). Plain-text
template already renders the label + URL on their own lines and stays
consistent. Adds a render assertion guarding the >=16px monospace style.

Closes #186

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(email): pin download-link assertion to restyled <a>

The prior font-size:16px substring check was satisfied by the primary
download button regardless, so it failed to guard the link block. Assert
a contiguous substring unique to the restyled <a> (display:block +
monospace + font-size:16px) so a font-size regression genuinely fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add an optional `metrics_token` config (also settable via ROCKET_METRICS_TOKEN).
When set, /metrics requires `Authorization: Bearer <token>` (constant-time
compared via subtle); when unset it stays open and logs a startup warning, so
rolling this out doesn't break existing scrapers. The auth lives in the app,
not the ingress, so it travels with cryptify to external hosts that can't be
firewalled. /health stays public.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
The website tags its uploads with X-Cryptify-Source for per-channel
metrics (postguard-website#228), but the header was never added to the
CORS allow-list. Browsers include it in the preflight's
Access-Control-Request-Headers, rocket_cors rejects the preflight with
a 403 carrying no Access-Control-Allow-Origin, and uploads from the
website fail before they start. Staging hits this directly; production
only works because its nginx answers preflights itself.

Add the header to the allow-list, and extract build_cors() so the
preflight smoke tests exercise the production CORS config instead of a
test-local copy — the duplicated config is why the existing smoke test
couldn't catch this regression.
* docs: add agent & contributor notes (migrated from dobby memory)

* docs: correct purge timer notes in CLAUDE.md

The Purge timer block described a hard 15-minute eviction from creation
at src/store.rs:80. It is actually a resettable 1-hour idle timeout:
Store::create (src/store.rs:292) seeds the deadline from idle_ttl
(DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS = 60 * 60) and Store::touch
re-inserts it on each chunk PUT / status check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix: retry the startup verifying-key fetch instead of panicking

Cryptify fetched the IBS verifying key from pg-pkg once at startup and
panicked if the PKG was unreachable or the response unparsable, creating a
hard restart-order dependency (cryptify -> pg-pkg -> irma server): a brief PKG
unavailability window during a rolling deploy took cryptify down with it.

The fetch now retries transient failures with exponential backoff (500ms..10s)
for a 120s budget, mirroring the existing /api-key/validate retry pattern, and
runs the blocking minreq call via spawn_blocking. If the budget is exhausted
the process still exits with a clear error so a misconfigured pkg_url does not
fail silently.

Fixes #235

* fix: log non-2xx PKG responses distinctly from parse failures in the startup retry
…#193)

Part 2 of #236 (the cryptify leg). Finalize required
exactly pbdf.sidn-pbdf.email.email in the sender's signing identity; test
environments cannot issue pbdf credentials, blocking real end-to-end flows.
The `email_attribute` config key (default: the production value, so deployed
configs are unaffected) now names the attribute type carrying the sender's
email. Companion changes: pg-pkg (#244) and pg-js.
These three dependencies were declared in Cargo.toml but never used
anywhere in the crate. Removing them prunes qrcode's image subtree
(image, moxcms, pxfm, byteorder-lite) and strum's heck from Cargo.lock.

Closes #191

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore: remove unused irma dependency

The `irma` crate was declared in Cargo.toml but never imported anywhere in
the crate. Attributes reach cryptify already signed inside the
PostGuard-sealed file and are read back through pg-core's Unsealer, so
there is no IRMA client here to migrate.

`irma` stays in Cargo.lock as a transitive dependency of pg-core.

Closes #194

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say irma is still compiled in via pg-core, not just in the lockfile

`cargo tree -i irma` shows irma 0.2.1 on the normal dependency edge under
pg-core, so it is built into the binary, not lockfile residue. It also brings
reqwest 0.11.27 alongside cryptify's own reqwest 0.13.4. The old wording could
lead the postguard-e2e knownDrift follow-up to record the pair as resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs: true up api-description.yaml with the mounted routes

The spec had drifted from the service in ways a client reading it would hit:

- `/staging/preview/{uuid}` was mounted but undocumented.
- `PUT /fileupload/{uuid}` and `POST /fileupload/finalize/{uuid}` documented a
  409 for a `cryptifytoken` mismatch; both return 400, and no route in the
  service returns 409.
- Finalize validates a `cryptifytoken` header, which the spec did not list, so
  a client following the spec got a 400 from the extractor.
- 400 bodies are plain-text messages, not `{"message": …}` JSON.
- `/metrics` requires `Authorization: Bearer <metrics_token>` when the
  deployment configures one; the spec said firewall-only and listed no 401.
- `/filedownload/{uuid}` answers `Range` requests with 206 or 416 and never
  returns the documented 400, and it sets no `Content-Type` at all.
- Missing responses: 400/422/500 on init, 500/503 on chunk PUT, 500 on
  finalize, 503 on `/usage`.
- `PayloadTooLarge` gained `resets_at` on the rolling-window 413.

`api_routes()` is now the single mount list, and `mod api_description_tests`
compares it against the spec so a route can no longer be added, removed, or
renamed without the spec following.

Refs #247

* docs: correct four spec statements and document the exclusive Content-Range end

Follow-up on the review of this PR. All five were verified against the
handlers, not inferred from the spec:

- `PUT /fileupload/{uuid}`: the `Content-Range` end byte is exclusive
  (`upload_chunk` uses `end - start`), which no version of the spec has ever
  said. An external client following RFC 7233 sends one byte too many and gets
  a 400. Documented on the parameter, since the spec is the contract pg-js and
  pg-dotnet are written against.
- `GET /filedownload/{uuid}`: `Accept-Ranges: bytes` is set on the response
  builder after both 404 early returns, so it is not "always" advertised.
  Reworded, and added the header to the 416, which does carry it.
- `GET /filedownload/{uuid}`: added the 500 from a failed `file.seek` on the
  range path.
- `POST /fileupload/init`: the 400 also covers a syntactically invalid JSON
  body, which Rocket answers with its default error page rather than the
  plain-text message. Same caveat the chunk and finalize 400s already carry.
- `StagingPreview.confirmation`: null whenever `state.sender` is unset, i.e.
  when previewing before finalize — the normal staging flow, and the case the
  old wording excluded.

cargo fmt/clippy/test all pass (154 tests); the spec still validates as
OpenAPI 3.0.3 with all $refs resolving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b2a0cda35e4646797ab2e3e3bc952685236647fb)
#203)

* ci: pin the oasdiff gate's settings with a mutation test

`fail-on: ERR` with no `include-checks`, the setting the gate landed with in
#201, fails open on ten of the changes the spec's contract forbids. Measured
against oasdiff v1.26.1 by mutating the real spec one way per rule: a `401`
that becomes a `403` and a dropped response enum value rate ERR but are
opt-in, and a removed or renamed optional response property, a removed request
parameter, a removed request property and the constraint-narrowing `*-set`
family all rate WARN. Cryptify's routes are unversioned, so each of those
reaches every pinned client the moment it deploys.

Adds `mod api_gate_tests` in `src/main.rs`, which runs the engine with the
flags the action's entrypoint builds and asserts stop-or-pass per mutation. It
fails on the shipped settings and names every gap; it passes on `fail-on: WARN`
plus the two opt-in checks. The engine is not on any runner, so it skips in CI
while `every_api_gate_mutation_still_applies` still runs there.

The workflow itself is in a PR comment: the App has no `workflows: write`.

Refs #202. Part of #247 (workstream C).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: assert the workflow runs the settings the gate test pins

Review on #203: `FAIL_ON` and `INCLUDE_CHECKS` described settings nothing
tied to the committed workflow, which still says `fail-on: ERR` with no
`include-checks`, and `cargo test` was green anyway. So the branch shipped a
passing test certifying a configuration the gate does not use, which is the
fail-open the module exists to catch.

`the_workflow_uses_the_settings_this_module_pins` reads
`.github/workflows/api-diff.yml` and asserts its two step inputs are the two
constants. It needs no `oasdiff`, so it runs on every runner. It is red until
a maintainer applies the workflow patch from the PR comment, which makes the
merge order enforced rather than documented.

Also from the review: CLAUDE.md now says the settings are pending rather than
present-tense fact and names where the patch lives; the tier counts are the
`-f json` ones (WARN adds 30 on top of ERR's 212, not 31 on 213 — the table
output has a header row and a trailing blank); the versioned-route mutation
goes through `once` like every other anchor, so a renamed `getUsage` fails
loudly instead of no-op'ing; and a root `.gitattributes` marks the spec `-text`
so the multi-line anchors survive a Windows checkout.

Refs #202.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: assert the api gate still runs, not only what it runs with

`the_workflow_uses_the_settings_this_module_pins` pinned the gate's two
inputs but not whether the job is ever triggered, which is the one
fail-open it could not see: settings that read correctly on a gate that
never fires. The workflow's own header comment names the foreseeable edit
("There is deliberately no `on: paths:` filter"), and a later `paths:`
would skip the gate on every PR that does not touch the spec with the
test still green.

Asserts a `pull_request` trigger, no `paths:`/`paths-ignore:` filter, and
no `if:` on the job. The trigger check is line-based over the `on:` block
rather than a byte-exact match, so a rewrite between the mapping, list
and scalar forms of `on:` does not go red for nothing.

* ci: tune the oasdiff gate to WARN with the two opt-in ERR checks

Dobby's YAML from the #203 comment, applied verbatim (the App cannot push workflows). This is the half its guard test was failing on: the_workflow_uses_the_settings_this_module_pins reads this file and asserts fail-on and include-checks match the constants the test module pins, so the settings and the mutation test that certifies them cannot land apart.

At fail-on=ERR with no include-checks the gate passed ten of the changes this spec's contract forbids.

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ruben Hensen <ruben.hensen@protonmail.com>
git-subtree-dir: cryptify
git-subtree-mainline: e642786
git-subtree-split: 4df67fa
Second half of the #255 spike. The commit before this
one is the history-preserving subtree import; this one makes it build, test and
release as a workspace member.

Resolver: pg-pkg's sqlx and cryptify's rusqlite both reach libsqlite3-sys, which
sets `links = "sqlite3"`, and only one package per resolve graph may claim a
given `links` value. Optional dependencies sit in the graph whether or not their
feature is on, so pg-pkg pulls sqlx-sqlite in despite enabling only `postgres`.
sqlx-sqlite 0.9.0 wants libsqlite3-sys >=0.30.1,<0.38 and rusqlite 0.40.1 wants
^0.38.1: empty intersection, workspace does not resolve. sqlx 0.9 + rusqlite
0.39 is the newest pair that does.

- root Cargo.toml: cryptify joins `members`
- cryptify: pg-core becomes a path dependency; own Cargo.lock, release-plz.toml,
  .github/ and duplicate profile blocks dropped
- build.rs: walk up for Cargo.lock — a member has none beside its manifest, and
  the panic would have been at build time, not resolve time
- api-diff.yml: one matrix over both specs, not two jobs. Both api-gate suites
  require exactly one `fail-on:`/`include-checks:` line in the file
- build.yml: matrices key on the crate directory so `cryptify` fits; this
  renames the `Test/Format/Clippy workspace` contexts
- Dockerfile: build context moves to the repo root
Root .gitignore's `*.pem` matches the four fixtures #241 committed under
pg-pkg/testdata/jwt_rotation/, leaving them tracked *and* ignored. release-plz
refuses to run against that, so delivery.yml's `Release-plz PR` job has failed
on every push to main since #241 landed on 2026-07-16 (12 runs). `Release-plz
release` is unaffected, which is why nothing looked broken.

Belongs in its own PR, not the cryptify spike; carried here only so the spike
could get far enough to answer the release-plz question on #255.
Neither failure shows up in the test matrix; both are Docker-only.

`cargo chef prepare` loads every workspace member's manifest, including ones
the build never compiles, so the Dockerfile's member-by-member COPY list has to
gain cryptify or the image fails with "failed to load manifest for workspace
member /app/cryptify".

With that fixed it fails again on MSRV: sqlx 0.9 declares rust-version 1.94.0
and this image pinned rust 1.91.1. sqlx 0.9 is what resolves the libsqlite3-sys
`links` conflict, so the pin moves rather than the dependency. 1.96.1-slim-trixie
is what cryptify's own Dockerfile already used, so both images now agree.

Verified by building both: pg-pkg-spike:edge (192MB), cryptify-spike:edge (170MB).
Brings in the 0.6.2/0.3.7/0.6.0/0.1.3 release (#187) and the .gitignore fix
(#274). Two conflicts:

- .gitignore: took main's wording, which is what #274 actually shipped. The
  spike carried an earlier, terser version of the same fix.
- cryptify's pg-core requirement moved 0.6.1 -> 0.6.2 by hand. release-plz
  maintains these reqs on unpublished members too (pg-pkg carries one), but it
  could not touch cryptify's during #187 because cryptify was not yet a member.

Merged rather than rebased on purpose: rebasing replays all 296 imported
commits onto main and discards the subtree merge.
@dobby-coder
dobby-coder Bot requested a review from leonbotros July 30, 2026 20:15
@rubenhensen
rubenhensen merged commit ba380a1 into main Jul 31, 2026
33 of 34 checks passed
@rubenhensen
rubenhensen deleted the spike/cryptify-in-workspace branch July 31, 2026 07:07
@github-actions github-actions Bot mentioned this pull request Jul 31, 2026
rubenhensen added a commit that referenced this pull request Aug 4, 2026
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.
rubenhensen added a commit that referenced this pull request Aug 4, 2026
* 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.

* 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-<crate>/...`, 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.

* 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.

* 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.
rubenhensen pushed a commit to encryption4all/postguard-docs that referenced this pull request Aug 5, 2026
* docs: correct the notify-shape claim in the pg-js upload options (from encryption4all/postguard-js#151)

The SDK never shipped a notify-shape validator; the pg-js test suite pins
its removal. `{ notify: true }` sends no mail and suppresses the
silent-upload notice instead of throwing.

* docs: point the repo pages at the postguard-js and postguard workspaces

postguard-website, postguard-tb-addon, postguard-outlook-addon and
postguard-examples were folded into the postguard-js pnpm workspace and
archived (encryption4all/postguard-js#132, #134, #137, #141, #145).
Cryptify's source moved into the postguard Cargo workspace, with image
publishing left behind in its own repository (encryption4all/postguard#277).

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

decide & spike: merge cryptify into the postguard workspace

1 participant