ADR 0033: Read Closed Value Sets Tolerantly
Amended 2026-09-26: runtime_interaction.action was retired by the backend.
The SDK no longer declares its response field or action type; the historical
value-set decision below still applies to the remaining response fields.
Amended 2026-09-25 for SDK issue #162:
public ResourceRelease collection creation is retired. The historical
ResourceRelease.create sending examples below are superseded; the SDK now
blocks inherited create calls before HTTP. Tolerant response reading and
closed request parameter validation remain in force for supported operations.
Date: 2026-09-21
Status: Accepted and implemented
Issue: mainsequence-sdk #119, and the half of #120 that ADR 0032 left open.
Stable fingerprint:
mainsequence-sdk|response-parsing|strict-reader-breaks-on-compatible-backend-change
Context
ADR 0032 made the SDK ignore a response field it does not declare. It left the other half of #120 open, and named it: a response value the SDK does not declare still raised.
119 is that half, in a user's hands. The backend persists release_kind
harness_agent; ResourceReleaseKind listed agent, fastapi and
static_site. One such release in a branch made ResourceRelease.filter(...)
raise before the caller could read any release in the listing:
1 validation error for ResourceRelease
release_kind
Input should be 'agent', 'fastapi' or 'static_site'
The same file already read harness_agent in ResourceReleaseRuntimeAccess, so
the two response models disagreed about what a release kind is.
This is the same defect as the undeclared field, in a second place. The backend's
API rules call a new documented enum member a compatible change; about 60
Literal[...] fields and five str, Enum classes called it fatal. A state, a
phase or a kind the backend adds is exactly the kind of value a user often does
not read, and it failed the whole row, and the whole listing the row was in.
Decision
The SDK reads a closed value set tolerantly when the value comes from the backend, and keeps it closed when the value goes to the backend.
- One mechanism, in
mainsequence/client/value_sets.py. OpenStrEnumreplacesstr, Enumfor response vocabularies. A declared value still validates to its member, sokind is ResourceReleaseKind.AGENTandkind == "agent"keep working. An undeclared value becomes a member-like object carrying that exact string.OpenValueSet[Literal[...]]replacesLiteral[...]on a response field. It reads as astrthat keeps an undeclared value.- The values stay inside a real
Literal.OpenValueSetwraps aLiteralrather than taking loose strings, so the declared values keep one spelling, an alias shared by a request parameter and a response field keeps one definition, and tools that rewrite annotations leave them alone. - An undeclared value is reported once. One
warningnames the model, the field, the value and the declared values. Once per process per distinct value, not once per row, so a listing of 500 rows carrying one new state is one line. - An undeclared value is not a declared member. It is deliberately not
registered on the enum, so iterating the enum lists only what this release
declares, and
value not in set(TheEnum)is how code asks. harness_agentis declared.ResourceReleaseKindgains it, andResourceReleaseRuntimeAccess.release_kindreads that same enum instead of its ownLiteral, so the release and its runtime access name a kind the same way (#119).- What the SDK sends stays closed. A request parameter typed
Literal[...]still rejects an undeclared value, because there the mistake is the caller's and raising is what catches it. Direct ResourceRelease creation is retired; repository workflows provide the creation input. - An unknown discriminator is unchanged.
AgentSession.get_insightsdispatches onharnessand raises for a harness it cannot place. That is one object, fetched by itself, so the error is already scoped to the object that carries it and never fails a listing, which is what #120 asked for.
What opened, and what did not
Opened: the response vocabularies — release kind, runtime state, phase, wake state, admission state, severity, deployment-run step kind and state, pricing state, log stream and level, log-source state, agent runtime state, interaction state, runtime access mode, streaming status, environment log owner type and truncation reason, public log level, GitHub issue state and operation type and status, image provisioning and verification state, notification source and type, access level, MetaTable management, schema-management, provisioning and storage-access modes.
Left closed, deliberately:
- Request parameters —
action: Literal["archive", "unarchive"],output_format,state_reason, the MetaTable request payloads. #120 put these out of scope, and they are right as they are. - Constant tags —
kind: Literal["task"],scope: Literal["knative_runtime"],harness: Literal["pi"],confirm_cascade_delete: Literal[True]. These are not vocabularies; widening them would remove the tag. DataFrequency— the SDK builds these locally for the data interfaces; none is parsed from a response.mainsequence/code_repository_context.py— plain dataclasses, so theirLiteralannotations are hints that never validate at runtime.
Consequences
- A backend release that adds a state, a phase or a kind no longer breaks installed SDKs, and one such row no longer fails the listing it is in.
- The static type of an opened
Literalfield is nowstr. Editors no longer complete the declared values at the field; the values stay readable in the source, anddeclared_values()reads them back for tests and documentation. - An undeclared enum value is a member-like object that is not in the enum.
Code that asks
kind is ResourceReleaseKind.AGENTorkind in set(ResourceReleaseKind)keeps a correct answer. Code that assumed every parsed value is a declared member gets a value that compares equal to a string and to nothing else. - Two
OpenStrEnumobjects built from the same undeclared value are equal but not identical, because neither is registered.==is how to compare them. - A test that asserted a retired value is rejected now asserts that the release
does not declare it and that a response carrying it parses. Two such tests
changed: the branch provisioning-state contract in
tests/test_code_repository_branch_contract.py, and the retired-release-kind contract in the domain-focused tests undertests/client/. - The SDK is now silent about drift by default except for one log line. The backend's pre-merge comparison against the newest SDK release is what reports it, as ADR 0032 already assumed.
Alternatives considered
Declare harness_agent and stop there
Rejected. It fixes #119 and leaves the defect. Seven closed issues under #120 are one field each; this would have been the eighth, for a value instead of a field.
Literal[...] | str on each response field
Rejected. It reads as "anything", gives no place to report an undeclared value,
and loses the declared values entirely — they survive in OpenValueSet and are
readable through declared_values().
OpenLiteral["a", "b"] taking loose strings
Rejected after trying it. ruff check --fix reads bare strings in a subscript as
forward-reference annotations and strips their quotes, silently turning
OpenLiteral["a", "b"] into OpenLiteral[a, b]. Wrapping a real Literal is
safe from that, and reuses a Literal alias directly.
Register the undeclared value on the enum
Rejected. Iterating the enum would then list values that depend on what the process happened to read, so declared membership stays stable.
Required Invariants
- Parsing a response with an undeclared enum or literal value never raises.
- One row with an undeclared value never fails a listing.
ResourceRelease.filter(...)returns a listing containing aharness_agentrelease, withrelease_kindreadable as"harness_agent".ResourceReleaseandResourceReleaseRuntimeAccessread the same release-kind vocabulary.- Reading an undeclared value does not add it to the enum.
- Direct ResourceRelease collection creation is blocked before an HTTP request.
- An undeclared value is reported once, naming the model, the field and the value.
tests/test_tolerant_value_sets.pyholds these rules.