Skip to content

Commit 1ccc8d8

Browse files
committed
docs(extension-cipherstash): drop transient projects/ links; add DEVELOPING.md (M2 R2 plan amendment 2)
README.md and test/descriptor.test.ts previously linked into projects/extension-contract-spaces/..., violating the always-applied workspace rule .cursor/rules/doc-maintenance.mdc ("Docs must not link to transient project artifacts under projects/"). Pgvector README is the durable-link precedent — it links only to ADRs, the Architecture Overview, and external docs. Resolution mirrors pgvector pattern (and the F3 resolution from PR #416): - README rewritten to focus on user-facing surface: subpath exports, configuration, PSL/TS authoring, database setup, runtime usage. Durable links only — ADR 207 (per-call codec context), ADR 208 (parameterized codec descriptor), Architecture Overview, Extension Packs Naming and Layout. The five projects/ links are gone. - DEVELOPING.md collects contributor-facing detail: source layout, implemented + forthcoming surface, design choices (handle storage, plaintext-retention, per-SDK codec construction, SDK-free metadata codec, framework-native SDK shape, (table, column) routing). Mirrors the DEVELOPING.md precedent under packages/2-sql/2-authoring/contract-ts/. - test/descriptor.test.ts file-header docstring rewritten to drop the projects/extension-contract-spaces/plan.md cross-link in favour of inline prose (the test itself is self-describing) plus a forward-pointer to DEVELOPING.md for the deferred storage round-trip work. Verification: rg projects/extension-contract-spaces packages/3-extensions/cipherstash/ returns 0 matches. Phase: M2 R2 plan amendment 2 (cipherstash-integration / project-1).
1 parent 64b76ba commit 1ccc8d8

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
## Developing `@prisma-next/extension-cipherstash`
2+
3+
Contributor-facing notes for the cipherstash extension. The user-facing
4+
surface lives in `README.md`; this file collects the in-progress
5+
milestones, internal layout, and design choices a contributor needs to
6+
know when extending the package.
7+
8+
## Source layout
9+
10+
```
11+
packages/3-extensions/cipherstash/
12+
└── src/
13+
├── core/
14+
│ ├── envelope.ts EncryptedString class + handle helpers
15+
│ ├── sdk.ts CipherstashSdk interface (framework-native shape)
16+
│ ├── codec-runtime.ts cipherstash/string@1 SDK-bound codec factory
17+
│ ├── codec-metadata.ts cipherstash/string@1 SDK-free metadata codec (for pack-meta)
18+
│ ├── parameterized.ts RuntimeParameterizedCodecDescriptor + arktype params schema
19+
│ ├── authoring.ts cipherstash.EncryptedString PSL constructor descriptor
20+
│ ├── descriptor-meta.ts cipherstashPackMeta (authoring + storage + codec metadata)
21+
│ ├── cipherstash-codec.ts control-plane codec lifecycle hook (TML-2397)
22+
│ ├── contract.ts contract-space ContractIR (TML-2397)
23+
│ ├── migrations.ts contract-space baseline migration (TML-2397)
24+
│ ├── eql-bundle.ts EQL install SQL (vendored byte-for-byte)
25+
│ └── constants.ts shared identifiers (codec id, native types, invariant ids)
26+
└── exports/
27+
├── control.ts SqlControlExtensionDescriptor (control-plane entry)
28+
├── runtime.ts EncryptedString + SDK + parameterized codec (runtime entry)
29+
├── pack.ts cipherstashPackMeta default export (TS contract authoring)
30+
└── column-types.ts encryptedString({...}) TS contract factory
31+
```
32+
33+
## Implemented surface
34+
35+
- `cipherstash.EncryptedString({ equality?, freeTextSearch? })` PSL
36+
constructor and the `encryptedString({...})` TS factory; both lower
37+
to a `ColumnTypeDescriptor` byte-identical to the other (verified
38+
by the parity fixture under
39+
`test/integration/test/authoring/parity/cipherstash-encrypted-string/`).
40+
- `EncryptedString.from(plaintext)` and
41+
`EncryptedString.fromInternal({ ciphertext, table, column, sdk })`
42+
envelope constructors (handle is package-private).
43+
- `envelope.decrypt({ signal? })` — returns cached plaintext when
44+
present, otherwise routes through the SDK's single-cell `decrypt`
45+
and forwards the caller-supplied `AbortSignal` by identity via
46+
`ifDefined` from `@prisma-next/utils/defined`.
47+
- `cipherstash/string@1` codec with target type `eql_v2_encrypted`,
48+
traits `['equality']`, and `renderOutputType` returning
49+
`EncryptedString`.
50+
- `RuntimeParameterizedCodecDescriptor<{ equality, freeTextSearch }>`
51+
with arktype `paramsSchema` validated at the contract boundary.
52+
- `SqlControlExtensionDescriptor` carrying the contract-space
53+
artefacts (TML-2397) plus pack-meta authoring contributions and
54+
the codec lifecycle hook.
55+
56+
## Forthcoming surface (in-flight work)
57+
58+
Tracked under the `cipherstash-integration / project-1` plan:
59+
60+
| Surface | Round |
61+
| -------------------------------------------------------- | ------ |
62+
| `bulkEncryptMiddleware(sdk)` factory | M2 R3 |
63+
| `createCipherstashRuntimeDescriptor({ sdk })` wrapper | M2 R3 |
64+
| Real EQL install bundle (replaces placeholder) | M2 R3 |
65+
| Live-Postgres + live-EQL storage round-trip e2e | M2 R3 |
66+
| `eq` / `ilike` operator lowering | M3 |
67+
| `decryptAll(rows, opts?)` walker | M3 |
68+
69+
The shipping package surface — subpath exports, codec id, descriptor
70+
shapes — is stable across these milestones; new surfaces ship as
71+
separate subpath exports rather than restructuring existing ones.
72+
73+
## Design choices worth knowing
74+
75+
### Handle storage — `WeakMap`
76+
77+
The `EncryptedStringHandle` shape is a module-private mutable record
78+
keyed off a module-scoped `WeakMap<EncryptedString, ...>`. The
79+
alternative — `#`-prefixed class fields — provides the same
80+
package-internal isolation, but the `WeakMap` shape keeps
81+
`Object.keys(envelope)` and the default `JSON.stringify` shape
82+
trivially clean across every JS host without per-class `toJSON`
83+
overrides. (A `toJSON()` override ships anyway to produce the
84+
documented `{ "$encryptedString": "<opaque>" }` placeholder.)
85+
86+
### Plaintext is retained post-encrypt
87+
88+
The bulk-encrypt middleware (M2 R3) populates the handle's ciphertext
89+
slot but does **not** zero the plaintext slot. Zeroing in JS is
90+
best-effort (strings are immutable) and the GC-driven lifecycle is
91+
sufficient for this project's scope. As a side effect, a write-side
92+
envelope's `decrypt()` returns the original plaintext synchronously
93+
without an SDK round-trip.
94+
95+
### Codec is constructed per SDK binding
96+
97+
`createCipherstashStringCodec(sdk)` is a factory rather than a module
98+
singleton. The codec's `decode` body captures the SDK so the
99+
read-side envelope can issue `decrypt({ signal? })` against it. This
100+
differs from pgvector (whose codec is fully stateless and *can* be a
101+
module singleton) but aligns with multi-tenant deployments
102+
constructing one extension descriptor per tenant.
103+
104+
### SDK-free metadata codec for pack-meta
105+
106+
`core/codec-metadata.ts` ships an SDK-free codec used in
107+
`cipherstashPackMeta.types.codecTypes.codecInstances`. Pack-meta
108+
consumers only read codec metadata (`typeId`, `targetTypes`,
109+
`traits`, `renderOutputType`) at contract emit time — they never
110+
call `encode`/`decode`. Keeping the metadata codec separate from the
111+
SDK-bound runtime codec preserves the control vs runtime split:
112+
control-plane consumers (`exports/control.ts`, `exports/pack.ts`)
113+
pull this file but never the envelope, the SDK interface, or the
114+
codec runtime.
115+
116+
### `CipherstashSdk` is framework-native, not the upstream SDK shape
117+
118+
The interface declares three async methods (`decrypt`, `bulkEncrypt`,
119+
`bulkDecrypt`), each accepting an optional `AbortSignal`. This is
120+
deliberately smaller than CipherStash's upstream `EncryptionClient`
121+
(rich `EncryptOperation` / `LockContext` / lazy-init machinery) so
122+
real-world usage wraps the upstream client behind a thin adapter
123+
satisfying `CipherstashSdk`. Keeps the framework-side surface free of
124+
upstream-specific types.
125+
126+
### Routing key is `(table, column)`
127+
128+
`bulkEncrypt` and `bulkDecrypt` accept a `routingKey: { table,
129+
column }` so each ZeroKMS round-trip handles one homogeneous batch.
130+
The envelope's read-side handle carries the same `(table, column)`
131+
captured from `SqlCodecCallContext.column` at decode time so
132+
`decrypt({ signal? })` can issue the right routing.
133+
134+
## References
135+
136+
- [pgvector extension](../pgvector/README.md) and its
137+
`src/exports/runtime.ts` — the structural precedent for codec,
138+
parameterized descriptor, and pack-meta layout.
139+
- [ADR 207 — Codec call context (per-query AbortSignal and column
140+
metadata)](../../../docs/architecture%20docs/adrs/ADR%20207%20-%20Codec%20call%20context%20per-query%20AbortSignal%20and%20column%20metadata.md).
141+
- [ADR 208 — Higher-order codecs for parameterized
142+
types](../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md).

0 commit comments

Comments
 (0)