`serializable synthesizes a decode that ends in return Self(field: local, ...), constructing the type field-by-field. Any invariant the type enforces in its factory is not enforced on the way in from the wire.
This is a design question rather than a straightforward bug — the current behaviour is defensible and the escape hatch works — but the default is silent, and it is silent on the one side where the data is untrusted.
What happens
type Name `public `serializable `clone {
string _n `final `key("n");
parse!(string move n) Self `factory `public {
if n.is_empty {
raise NameError(message: "a name cannot be empty");
}
return Name(_n: move n);
}
get is_empty bool `public => this._n.is_empty;
}
type Box `public `serializable `clone {
Name _name `final `key("name");
of(Name move name) Self `factory `public { return Box(_name: move name); }
name(this) Name `public => this._name.clone();
}
the_factory_refuses_it() `test { // passes
bool refused = false;
Name n = Name.parse("") ? e { refused = true; Name.parse("x")?!; };
assert(refused, msg: "the only constructor rejects an empty name");
}
the_wire_does_not() `test { // also passes
string wire = "{\"name\":{\"n\":\"\"}}";
Box b = json.decode_string[Box](move wire)?!;
Name n = b.name();
assert(n.is_empty, msg: "decode built the value the factory exists to prevent");
}
parse is the only way to make a Name in Promise. It is not the only way to make one from JSON.
The escape hatch, and why it is not the answer
serialize.go:61 — "Synthesize decode factory method if not user-defined" — so a hand-written decode wins, and the synthesized signature is already failable, so a validating one is expressible. Verified working:
decode!(Decoder ~d) Self `factory `public {
d.begin_object();
string n = "";
while key := d.next_key() {
if key == "n" { n = d.decode_string(); }
}
d.end_object();
return Name.parse(move n);
}
// json.decode_string[Box]("{\"name\":{\"n\":\"\"}}") now raises
That works, and it is the same trade as hand-writing clone: the field list and every wire key are now written twice, and a field added later is silently dropped on the read side by a decode nobody remembered to update. Derivation exists precisely so that cannot happen, so "write it by hand when you have invariants" spends the feature to get the guarantee.
It is also the wrong default direction. A type with a validating factory is a type whose author has said which values are legal; decode is where illegal ones arrive.
Some directions, none of them obviously right
- A post-decode hook. Synthesize as today, then call a declared
validate!(this) if one exists. Cheapest, keeps derivation, composes with `flatten, and one method holds the invariant for both paths.
- Route through the factory.
`serializable(via: "parse"), or automatically when the type has exactly one failable factory whose parameters match the fields. Strongest guarantee, but the parameter/field correspondence is a real constraint and not every type will fit it.
- Refuse the silence. Leave the behaviour alone and warn when a
`serializable type declares a failable factory and no hand-written decode. Cheap, and turns a silent hole into a decision at the declaration.
- Say it is intended. Decoding is deserialization of something already validated when written, and revalidation is the reader's job. A defensible position, but it should be written down, because the current behaviour reads as an oversight.
Why this matters for BASE
base publishes the contracts different owners speak, and the identity types validate at exactly that boundary — ProjectId.parse! refuses a URL carrying a password, Target.parse! refuses an empty or whitespace name, Exclusion.parse! refuses a scope outside the closed set, Manifest.of! refuses a gate that can never run.
None of those are `serializable yet, so nothing is broken today. But every one of them needs a wire form, and the read side is where a foreign gate's JSON arrives — the side the validation was written for. Whatever shape this takes, we would rather build on it than around it.
Related, same annotation family: `key on an enum variant is silently ignored
`key pins a field's wire name. On a variant it parses, compiles, and does nothing:
enum V `public `serializable `clone {
Int(int value) `key("int"),
Float(f64 value) `key("float"),
}
// still encodes as {"type":"Int","value":5}
So a variant's wire spelling is its Promise identifier and cannot be pinned, while the discriminator key itself can be (`serializable(tag: "kind")). For a cross-language contract that makes renaming a variant a silent wire break. Either honour `key there or reject it, but accepting it and ignoring it is the worst of the three.
`serializablesynthesizes adecodethat ends inreturn Self(field: local, ...), constructing the type field-by-field. Any invariant the type enforces in its factory is not enforced on the way in from the wire.This is a design question rather than a straightforward bug — the current behaviour is defensible and the escape hatch works — but the default is silent, and it is silent on the one side where the data is untrusted.
What happens
parseis the only way to make aNamein Promise. It is not the only way to make one from JSON.The escape hatch, and why it is not the answer
serialize.go:61— "Synthesize decode factory method if not user-defined" — so a hand-writtendecodewins, and the synthesized signature is already failable, so a validating one is expressible. Verified working:That works, and it is the same trade as hand-writing
clone: the field list and every wire key are now written twice, and a field added later is silently dropped on the read side by adecodenobody remembered to update. Derivation exists precisely so that cannot happen, so "write it by hand when you have invariants" spends the feature to get the guarantee.It is also the wrong default direction. A type with a validating factory is a type whose author has said which values are legal; decode is where illegal ones arrive.
Some directions, none of them obviously right
validate!(this)if one exists. Cheapest, keeps derivation, composes with`flatten, and one method holds the invariant for both paths.`serializable(via: "parse"), or automatically when the type has exactly one failable factory whose parameters match the fields. Strongest guarantee, but the parameter/field correspondence is a real constraint and not every type will fit it.`serializabletype declares a failable factory and no hand-writtendecode. Cheap, and turns a silent hole into a decision at the declaration.Why this matters for BASE
base publishes the contracts different owners speak, and the identity types validate at exactly that boundary —
ProjectId.parse!refuses a URL carrying a password,Target.parse!refuses an empty or whitespace name,Exclusion.parse!refuses a scope outside the closed set,Manifest.of!refuses a gate that can never run.None of those are
`serializableyet, so nothing is broken today. But every one of them needs a wire form, and the read side is where a foreign gate's JSON arrives — the side the validation was written for. Whatever shape this takes, we would rather build on it than around it.Related, same annotation family:
`keyon an enum variant is silently ignored`keypins a field's wire name. On a variant it parses, compiles, and does nothing:So a variant's wire spelling is its Promise identifier and cannot be pinned, while the discriminator key itself can be (
`serializable(tag: "kind")). For a cross-language contract that makes renaming a variant a silent wire break. Either honour`keythere or reject it, but accepting it and ignoring it is the worst of the three.