Skip to content

Commit 93c9039

Browse files
authored
feat(stack-encrypt)!: a data plan field with a term output declares its type
Closes #1082 (CIP-4326). A data plan field with any term output (eq, match, ore, ope) and no "type" is now refused when the plan is built, with Error::Plan, by record::plan, plan_with and Plan::new / new_with alike: an indexed field's terms derive from the one declared kind, and every value is checked against it on the way in and on the way out, so the engine never trusts a binding to have tagged each value the same way. Before this a 34 sent once as a Float64 and once as an Int64 under one "ore" field was accepted both times and stored two terms. That gap was kept open only until the Go side filled the type; the Go SDK (#1094) fills it on every field from the Go kind, so nothing a binding sends today is refused. The type stays optional where no term derives from the field: a field whose only output is "c" or "passthrough", and a target field, whose kind is the EQL type's own (#1095). Declaring a type changes no stored byte (#1093), so no row is re-encrypted. The IndexSpec: Index<Value> dispatch is unchanged: the value still carries its tag, now always checked against a declaration before the term is derived. The "transitional" wording goes from the dynamic module, kind and record docs and from the plan document. Tests: refused at build for every index kind, alone and beside a ciphertext, spelled as a key and as the match object form, by the parser and by hand; a sealed-only and a passthrough field still build, seal and open without a type. Every test that indexed an untyped field now declares the type; the one that fed a float to an untyped equality field now shows there is no such field to feed, since float64 is refused at build and an integer kind refuses the float value. The guest's native tests and plan_check cover the refusal; the check_record fuzz generator emits a "type" entry so indexed plans still parse under fuzzing. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
1 parent 3eb459b commit 93c9039

7 files changed

Lines changed: 209 additions & 72 deletions

File tree

‎docs/plans/2026-10-04-plan-builder.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,9 +1122,10 @@ uses the guest.
11221122
kinds, not new names. A wire-format addition, so it goes in the first
11231123
engine PR. This is the one gap in the plan approach itself that the
11241124
language check found. #1069 added the type to the grammar as an optional
1125-
key, so a field with none is still dispatched on each value's own tag;
1126-
that is transitional, until Go fills the type (#1046) and the key becomes
1127-
required on indexed fields.
1125+
key; the Go SDK fills it on every field from the Go type (#1094), and
1126+
since #1082 the key is required on every field with a term output: a plan
1127+
whose indexed field has none is refused when it is built. A field that
1128+
only seals, or only carries its value through, may still leave it out.
11281129
2. **The guest's synchronous transport import, on the edge path only.**
11291130
`transport_send` is synchronous from the guest's point of view and the ABI
11301131
relies on it ("`block_on` never parks"). A wazero host function may block a

‎languages/golang/encrypt/guest/tests/native_ops.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,15 @@ fn plan_under(ctx: impl Fn(&str) -> FfiValue) -> Vec<u8> {
163163
obj(vec![
164164
("context", ctx("age")),
165165
("outputs", FfiValue::Array(vec![s("c"), s("eq"), s("ore")])),
166+
("type", s("uint32")),
166167
]),
167168
),
168169
(
169170
"name",
170171
obj(vec![
171172
("context", ctx("name")),
172173
("outputs", FfiValue::Array(vec![s("c"), s("match")])),
174+
("type", s("string")),
173175
]),
174176
),
175177
]))
@@ -1296,6 +1298,17 @@ fn plan_check_answers_without_a_cipher() {
12961298
)]));
12971299
assert_eq!(ops::plan_check(&bad_index), Err(STATUS_ENCODING));
12981300

1301+
// an indexed field with no declared type: every term derives from one
1302+
// declared kind, so the plan is refused before any value arrives.
1303+
let untyped_index = encode(obj(vec![(
1304+
"age",
1305+
obj(vec![
1306+
("context", label("age")),
1307+
("outputs", FfiValue::Array(vec![s("c"), s("eq")])),
1308+
]),
1309+
)]));
1310+
assert_eq!(ops::plan_check(&untyped_index), Err(STATUS_ENCODING));
1311+
12991312
// a one-segment context is not a label a fields plan can seal under.
13001313
let one_segment = encode(obj(vec![(
13011314
"age",

‎packages/stack-encrypt/CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232

3333
### Breaking
3434

35+
- **A data plan field with a term output must declare its `"type"`.** A
36+
plan whose indexed field (`"eq"`, `"match"`, `"ore"`, `"ope"`) has no
37+
`"type"` is refused when it is built (`Error::Plan`), by `record::plan`,
38+
`record::plan_with` and `Plan::new` / `Plan::new_with` alike: the field's
39+
terms derive from the one declared kind, with every value checked against
40+
it, never from whatever tag each value arrived with. The type stays
41+
optional on a field whose only output is `"c"` or `"passthrough"`, and on
42+
a target field, whose kind is the EQL type's own. The Go SDK already
43+
fills `"type"` on every field from the Go type, and `record.Plan.Validate`
44+
refuses an indexed `Untyped` field naming it, so generated code is
45+
unaffected; a plan written by hand without types must add them. Declaring
46+
a type changes no stored byte, so no row is re-encrypted.
47+
3548
- **A target description carries a source mode.** `Encryption` gains a
3649
last type parameter, `M: SourceMode = Borrowed`, saying how it is handed
3750
its plaintext. Code that names `Encryption<'s, S, T, K, Ctx>` still

‎packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,41 @@ enum SpecEntry {
166166
Context(Ctx),
167167
Outputs(Vec<Name>),
168168
Target(Name),
169+
/// A `"type"`: required on an indexed field, so a plan with terms parses
170+
/// only when one of these names a kind that admits them.
171+
Type(Kind),
169172
ContextWrongShape(u32),
170173
OutputsWrongShape(u32),
171174
TargetWrongShape(u32),
172175
Unknown(Ctx),
173176
}
174177

178+
/// A `"type"` value: a few kinds, and a name that is not one.
179+
#[derive(Arbitrary, Debug, Clone, Copy)]
180+
enum Kind {
181+
UInt32,
182+
Int64,
183+
String,
184+
Bytes,
185+
Float64,
186+
Object,
187+
NotAKind,
188+
}
189+
190+
impl Kind {
191+
fn as_str(self) -> &'static str {
192+
match self {
193+
Kind::UInt32 => "uint32",
194+
Kind::Int64 => "int64",
195+
Kind::String => "string",
196+
Kind::Bytes => "bytes",
197+
Kind::Float64 => "float64",
198+
Kind::Object => "object",
199+
Kind::NotAKind => "integer",
200+
}
201+
}
202+
}
203+
175204
impl SpecEntry {
176205
fn into_entry(self) -> (String, FfiValue) {
177206
match self {
@@ -189,6 +218,7 @@ impl SpecEntry {
189218
"target".to_string(),
190219
FfiValue::String(name.as_str().into()),
191220
),
221+
SpecEntry::Type(kind) => ("type".to_string(), FfiValue::String(kind.as_str().into())),
192222
SpecEntry::ContextWrongShape(v) => ("context".to_string(), FfiValue::UInt32(v)),
193223
SpecEntry::OutputsWrongShape(v) => ("outputs".to_string(), FfiValue::UInt32(v)),
194224
SpecEntry::TargetWrongShape(v) => ("target".to_string(), FfiValue::UInt32(v)),

‎packages/stack-encrypt/src/dynamic/kind.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
//! types of its own can rely on the declaration for what it gets back (an
3434
//! integer, not a float; bytes, not a string).
3535
//!
36-
//! A field with no `"type"` is dispatched on each value's own tag. That is
37-
//! transitional; see [`record::plan`](super::record::plan()).
36+
//! Every field with a term output declares its kind; a plan whose indexed
37+
//! field has none is refused when it is built
38+
//! ([`record::plan`](super::record::plan())). A field that only seals, or
39+
//! only carries its value through, may leave it out: no term derives from it.
3840
use vitaminc_aead_value::{FfiValue, ValueKind};
3941

4042
use super::Error;

‎packages/stack-encrypt/src/dynamic/mod.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@
5959
//! vitaminc's [`ValueKind`], re-exported here, whose names vitaminc freezes
6060
//! beside its tag table. This crate adds only what a kind means to an index
6161
//! ([`admits`]) and to a query value ([`read`]); it decides nothing about
62-
//! the bytes ([`record`]). A field without `"type"` is dispatched on each
63-
//! value's own tag; that is transitional, and [`record::plan`] says until
64-
//! when.
62+
//! the bytes ([`record`]). Every field with a term output declares one;
63+
//! a plan whose indexed field has none is refused when it is built
64+
//! ([`record::plan`]).
6565
//!
6666
//! For the same reason the enums that spell them — [`Output`],
6767
//! [`IndexSpec`] and [`ValueKind`] — are *not* `#[non_exhaustive]`, against this workspace's

0 commit comments

Comments
 (0)