Skip to content

Commit 776a098

Browse files
claude[bot]os-salesclaude
authored
docs(spec,service-datasource): name the three CryptoContext producer vocabularies and narrow the AAD guarantee (#12599) (#12838)
`CryptoContext.namespace` / `.key` documented themselves as a settings coordinate, one layer below the three producers that construct the type: SettingsService (settings namespace / specifier key), the ObjectQL engine's secret-field path (object name / field name) and the datasource secret binder (caller-supplied namespace, default `datasource` / datasource name). The prose now names all three, and `CryptoHandle.id`, `encrypt()` and `rotateKey()` stop describing `sys_setting.value_enc` as the general destination of a handle. The load-bearing correction is the AAD sentence, which claimed a binding over this pair "Helps reject ciphertexts that were copied across namespaces". The pair is one flat space shared by all three vocabularies, `sys_secret` declares it non-unique by design, and nothing reserves a name in one vocabulary against another — so the sentence is replaced by the guarantee that actually holds: such a binding rejects a swap WITHIN one producer's vocabulary and does NOT exclude a cross-vocabulary pair. The docblock records this as the contract's present state and names the intended end state so the weak guard is not read as the designed one. The identical settings-only prose on `DatasourceSecretBinderDeps.namespace` is corrected in the same change. Documentation only: no behavior change, no schema shape change — `gen:schema`, `gen:openapi` and `gen:docs` all regenerate byte-identically over this diff. Recorded under the maintainer ruling of 2026-08-27 on #12599. Co-authored-by: os-sales <sales@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent a3765f6 commit 776a098

3 files changed

Lines changed: 131 additions & 20 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/service-datasource": patch
4+
---
5+
6+
docs(spec,service-datasource): `CryptoContext` documents the three producer vocabularies, and the AAD sentence narrows to the guarantee that holds (#12599)
7+
8+
Documentation-only correction to the `ICryptoProvider` contract. No behavior
9+
changes and no schema shape changes: `gen:schema`, `gen:openapi` and `gen:docs`
10+
all regenerate byte-identically over this diff. It ships because the corrected
11+
TSDoc is emitted into the published `.d.ts`, so it is what every consumer of
12+
`@objectstack/spec` reads at the call site.
13+
14+
`CryptoContext.namespace` / `.key` documented themselves as a settings
15+
coordinate ("Settings namespace the value belongs to" / "Specifier key within
16+
the namespace"), one layer below the three producers that actually construct
17+
the type:
18+
19+
| producer | `ctx.namespace` | `ctx.key` | where `handle.id` is recorded |
20+
|---|---|---|---|
21+
| `SettingsService` | settings namespace | specifier key | `sys_setting.value_enc` |
22+
| the ObjectQL engine's secret-field path | **object name** | **field name** | a `secret:` ref on the business row |
23+
| the datasource secret binder | caller-supplied, default `datasource` | datasource name | a `sys_secret:` credentialsRef |
24+
25+
The prose now names all three, and `CryptoHandle.id`, `encrypt()` and
26+
`rotateKey()` no longer describe `sys_setting.value_enc` as the general
27+
destination of a handle.
28+
29+
The load-bearing half is the AAD sentence. It read "Helps reject ciphertexts
30+
that were copied across namespaces", which overstates what a binding over this
31+
pair can provide: `(namespace, key)` is **one flat space shared by all three
32+
vocabularies**, `sys_secret` declares the pair non-unique by design, and nothing
33+
reserves a name in one vocabulary against another. The sentence is replaced by
34+
the guarantee that actually holds — such a binding rejects a ciphertext swapped
35+
between two coordinates *within one producer's vocabulary*, and does **not**
36+
exclude a cross-vocabulary pair. The docblock records this as the contract's
37+
present state and names the intended end state (a producer-discriminated AAD)
38+
so the weak guard is not read as the designed one.
39+
40+
The same settings-only prose on `DatasourceSecretBinderDeps.namespace`
41+
("Settings namespace recorded on the secret row") is corrected in the same
42+
change.
43+
44+
Recorded under the maintainer ruling of 2026-08-27 on #12599 (Option A now,
45+
with the producer-discriminated AAD recorded as direction in its own ADR).

packages/services/service-datasource/src/datasource-secret-binder.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,14 @@ export interface DatasourceSecretBinderDeps {
4848
engine: SecretStoreEngineLike;
4949
/** Crypto provider that wraps cleartext into a {@link CryptoHandle}. */
5050
cryptoProvider: ICryptoProvider;
51-
/** Settings namespace recorded on the secret row (default `'datasource'`). */
51+
/**
52+
* Namespace recorded on the secret row and used as the `CryptoContext`
53+
* namespace (default `'datasource'`). This is the datasource producer's
54+
* own vocabulary, **not** a settings namespace — the three producers of
55+
* `sys_secret` rows share one flat `(namespace, key)` space and the pair
56+
* does not attribute a row to a producer. See `CryptoContext` in
57+
* `@objectstack/spec`.
58+
*/
5259
namespace?: string;
5360
}
5461

packages/spec/src/contracts/crypto-provider.ts

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* ICryptoProvider — pluggable encryption hook for the Settings subsystem.
4+
* ICryptoProvider — pluggable encryption hook shared by every platform
5+
* surface that seals a secret at rest.
56
*
67
* The provider's only job is to round-trip plaintext to a *handle*
78
* (a string the caller persists; opaque to everyone else). The handle
8-
* doubles as the value stored in `sys_setting.value_enc` — it usually
9-
* points to a row in `sys_secret`, but the contract intentionally
10-
* leaves the format up to the implementation.
9+
* usually points to a row in `sys_secret`, but the contract intentionally
10+
* leaves the format up to the implementation. Where the caller *records*
11+
* the handle differs per producer — see "Producers" below.
12+
*
13+
* Producers — three independent call sites construct a
14+
* {@link CryptoContext}, and only the first of them means "settings":
15+
*
16+
* 1. **Settings** (`SettingsService`) — `ctx.namespace` is the settings
17+
* namespace, `ctx.key` the specifier key; `handle.id` is recorded in
18+
* `sys_setting.value_enc`.
19+
* 2. **Object secret fields** (the ObjectQL engine's secret-field path)
20+
* — `ctx.namespace` is the **object name**, `ctx.key` the **field
21+
* name**; `handle.id` is recorded as a `secret:` ref on the business
22+
* row itself.
23+
* 3. **Datasource credentials** (the datasource secret binder) —
24+
* `ctx.namespace` is caller-supplied (default `'datasource'`),
25+
* `ctx.key` the datasource name; `handle.id` is recorded as the
26+
* artefact's `sys_secret:` credentialsRef.
27+
*
28+
* All three persist a `sys_secret` row keyed by `handle.id`, and all three
29+
* share one flat `(namespace, key)` space: `sys_secret` declares that pair
30+
* **non-unique** precisely because it does not attribute a row to a
31+
* producer. See {@link CryptoContext} for what that costs an AAD binding.
1132
*
1233
* Why an interface (not a concrete class):
1334
*
@@ -24,15 +45,16 @@
2445
*
2546
* Lifecycle:
2647
*
27-
* 1. `encrypt(plain)` — called once per `set()` for an encrypted
28-
* specifier. Returns a `CryptoHandle` describing both the storage
48+
* 1. `encrypt(plain, ctx)` — called once per write of an encrypted
49+
* value (a settings `set()`, an object secret field, a datasource
50+
* credential). Returns a `CryptoHandle` describing both the storage
2951
* handle and the KMS metadata. The caller persists a `sys_secret`
30-
* row keyed by `handle.id` and stores `handle.id` in
31-
* `sys_setting.value_enc`.
32-
* 2. `decrypt(handle)` — called on every `get()` of an encrypted
33-
* specifier to reveal the plaintext to the consumer (e.g.
34-
* EmailService building a transport). Implementations may cache
35-
* decrypted plaintext in-process for the duration of a request.
52+
* row keyed by `handle.id` and records `handle.id` wherever its
53+
* producer keeps it (see "Producers" above).
54+
* 2. `decrypt(handle, ctx)` — called on every read of an encrypted
55+
* value to reveal the plaintext to the consumer (e.g. EmailService
56+
* building a transport). Implementations may cache decrypted
57+
* plaintext in-process for the duration of a request.
3658
* 3. `rotateKey(handle)` — re-wraps the same plaintext under a new
3759
* KMS key. Returns a new handle (typically `version + 1`). Audit
3860
* trail records the rotation as `action='rotate'`.
@@ -41,7 +63,12 @@
4163
* multiple async tasks. They should *not* assume sequential access.
4264
*/
4365
export interface CryptoHandle {
44-
/** Stable opaque id stored in `sys_setting.value_enc`. */
66+
/**
67+
* Stable opaque id — the key of the `sys_secret` row, recorded by the
68+
* producer wherever that producer keeps its reference (`sys_setting
69+
* .value_enc`, a `secret:` ref on a business row, or a `sys_secret:`
70+
* credentialsRef). Not a settings-only coordinate.
71+
*/
4572
readonly id: string;
4673
/** Identifier of the KMS key that wrapped the cipher. */
4774
readonly kmsKeyId: string;
@@ -60,21 +87,52 @@ export interface CryptoHandle {
6087
/**
6188
* Optional context passed to encrypt/decrypt so providers can implement
6289
* Additional Authenticated Data (AAD) bindings — e.g. AWS KMS encryption
63-
* context. Helps reject ciphertexts that were copied across namespaces.
90+
* context.
91+
*
92+
* ⚠️ **What an AAD over this pair does and does not guarantee.**
93+
* `(namespace, key)` is one flat space shared by the three producer
94+
* vocabularies listed under {@link ICryptoProvider} — settings
95+
* namespace/specifier key, object name/field name, datasource binder —
96+
* and nothing reserves a name in one vocabulary against another. So a
97+
* provider binding its ciphertext to this pair **rejects a ciphertext
98+
* swapped between two coordinates within one producer's vocabulary** (a
99+
* settings value moved to another specifier; a secret field moved to
100+
* another field). It does **NOT** exclude a cross-vocabulary pair: an
101+
* object named `mail` carrying a secret field named `api_key` yields the
102+
* same coordinate as the `mail` settings namespace's `api_key` specifier,
103+
* under the same provider and key, in a `sys_secret` table that permits
104+
* both rows — and a ciphertext swapped between those two rows decrypts
105+
* cleanly. Implementations MUST NOT treat this pair as attributing a
106+
* ciphertext to a producer.
107+
*
108+
* This describes the contract as it stands today, not the shape it is
109+
* meant to keep: the intended end state is a producer-discriminated AAD
110+
* (a scope discriminant on this type, delimiter-safe encoding), deferred
111+
* because it is a breaking `ICryptoProvider` change plus an at-rest
112+
* rewrap of every existing ciphertext. Until that lands, the paragraph
113+
* above is the guarantee — do not read a stronger one into it.
64114
*/
65115
export interface CryptoContext {
66-
/** Settings namespace the value belongs to. */
116+
/**
117+
* Producer-scoped namespace: a settings namespace, an **object name**
118+
* (secret fields), or a caller-supplied datasource namespace (default
119+
* `'datasource'`). Not a settings namespace in general.
120+
*/
67121
namespace: string;
68-
/** Specifier key within the namespace. */
122+
/**
123+
* Producer-scoped key within {@link CryptoContext.namespace}: a settings
124+
* specifier key, a **field name** (secret fields), or a datasource name.
125+
*/
69126
key: string;
70127
/** Optional tenant id for multi-tenant key segregation. */
71128
tenantId?: string;
72129
}
73130

74131
export interface ICryptoProvider {
75132
/**
76-
* Encrypt plaintext and return a handle. The handle is stored in
77-
* `sys_secret` and referenced by `sys_setting.value_enc`.
133+
* Encrypt plaintext and return a handle. The caller persists it as a
134+
* `sys_secret` row and references it from wherever its producer keeps
135+
* the reference (see "Producers" on {@link ICryptoProvider}).
78136
*/
79137
encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle>;
80138

@@ -89,7 +147,8 @@ export interface ICryptoProvider {
89147
* Re-wrap the plaintext under the provider's current KMS key.
90148
* The returned handle replaces the input handle in `sys_secret`.
91149
* Implementations SHOULD bump `version` and update `kmsKeyId` while
92-
* leaving `id` stable (so `sys_setting.value_enc` need not be rewritten).
150+
* leaving `id` stable, so no producer's stored reference to the handle
151+
* has to be rewritten.
93152
*/
94153
rotateKey(handle: CryptoHandle, ctx: CryptoContext): Promise<CryptoHandle>;
95154

0 commit comments

Comments
 (0)