Skip to content

release: Chatter.Rest.Hal 2.0.0 - #122

Merged
brenpike merged 8 commits into
mainfrom
release/2.0
Aug 23, 2026
Merged

brenpike merged 8 commits into
mainfrom
release/2.0

Conversation

@brenpike

Copy link
Copy Markdown
Owner

Merges the release/2.0 integration branch: the coordinated fix wave from the full-repository review (Tracks 1–4: converters input hardening, write-side correctness, domain types, builders/extensions — 22 issues), the HAL §8.3 curies conformance fix (#119), and the atomic 2.0.0 version bump (csproj + CLAUDE.md table + CHANGELOG with breaking-changes/migration section).

Validation: 446 tests green on the integrated branch; clause-by-clause HAL spec-conformance audit passed (28/30 conform, both deviations resolved — #119 fixed, #120 documented and deferred); CodeQL green.

On merge, hal-cicd deploys Chatter.Rest.Hal 2.0.0 to NuGet (deploy environment approval required) and create-version-tag cuts hal/v2.0.0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

brenpike and others added 7 commits August 22, 2026 20:51
…CURIE expansion (#115)

* fix(builders): resolve the owning resource in FindParent walks

FindParent<T>() matched the calling builder itself before walking to Parent.
That made ResourceCollectionBuilder — which covariantly satisfies
IBuildHalPart<Resource> but throws from that BuildPart() — resolve itself, and
left the fallback branch in ResourceCollectionBuilder.AddEmbedded as dead code
that would have recursed forever if reached.

FindParent now starts at Parent and walks up iteratively. Builders that satisfy
IBuildHalPart<T> for a part they cannot build declare it via the new internal
IDeclareUnbuildableHalParts marker and are skipped, so a lookup never resolves
to a builder whose BuildPart() throws.

LinkObjectBuilder.AddEmbedded and ResourceCollectionBuilder.AddEmbedded now
resolve the nearest Resource ancestor before falling back to an
EmbeddedResourceCollection ancestor. Previously a link object on an embedded
resource skipped past its own resource to the root resource's embedded
collection, silently attaching the new embed to the wrong node.

Refs #103

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(builders): stop AddLink/AddSelf/AddCuries throwing after AddResources

ResourceCollectionBuilder's explicit IAddLinkToEmbeddedStage,
IAddSelfLinkToEmbeddedStage and IAddCuriesLinkToEmbeddedStage implementations
looked up FindParent<LinkCollection>() and dereferenced the result with the
null-forgiving operator. A LinkCollectionBuilder is only ever a child of a
resource builder, never an ancestor of a ResourceCollectionBuilder, so the
lookup returned null every time and plain fluent chaining such as

    ResourceBuilder.New().AddEmbedded("items").AddResources(items).AddLink("next")

threw NullReferenceException.

These stages now resolve the resource that owns the "_embedded" entry being
filled and add the link to that resource's link collection. Per-resource links
remain configurable through the AddResources builder callback. The remaining
null-forgiving parent dereferences in ResourceCollectionBuilder,
ResourceCollectionResourceBuilder and LinkObjectBuilder are replaced with
explicit InvalidOperationException guards that name the missing ancestor.

Refs #102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(builders): merge repeated link relations into a single link

AddLink/AddSelf/AddCuries appended a new LinkBuilder on every call, so a
repeated relation produced two Link instances and the converter wrote the
relation twice:

    ResourceBuilder.New().AddLink("a").AddLinkObject("/1")
                         .AddLink("a").AddLinkObject("/2").Build();
    // {"_links":{"a":{"href":"/1"},"a":{"href":"/2"}}}

Duplicate member names are ambiguous per RFC 8259 and most parsers keep only
the last, silently dropping the first link. HAL's "_links" is a JSON object
keyed by relation, so the duplicate could never be spec-valid.

LinkCollectionBuilder now indexes its link builders by relation (ordinal, so
relations stay case-sensitive) and returns the existing builder when a relation
repeats. Link objects merge into that single link and the array form follows
naturally from the object count, so a second AddCuries() extends the one
"curies" array. First-insertion order is preserved.

Refs #104

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(extensions): correct link lookup, CURIE expansion and builder validation

Four defects in the link extensions and their builder call sites:

- The single-argument GetLinkObjectOrDefault is documented as returning the
  first link object but used SingleOrDefault, throwing InvalidOperationException
  for any relation legitimately carrying more than one link object. It now uses
  FirstOrDefault.
- ExpandCurieRelation resolved "curies" through GetLinkOrDefault, which throws
  when a collection carries two "curies" links, breaking its documented promise
  to return the original relation for every edge case. It now searches the link
  objects of every "curies" link directly.
- ExpandCurieRelation substituted the CURIE reference verbatim, so "a:x y#z"
  injected a raw space and fragment into the href; it "expanded" an empty
  reference such as "a:"; and it ignored the definition's templated flag. The
  reference is now percent-encoded per RFC 6570 simple string expansion, an
  empty reference is returned unchanged, and a definition that is not marked
  templated is not expanded.
- Null or whitespace rel/href/name arguments were accepted at the call site and
  only threw from inside Build(), far from the faulting call. AddLink and
  AddLinkObject now validate eagerly with ArgumentException.

Two existing assertions encoded the old expansion behaviour and are updated:
"foo:bar:baz" now expands with the colon percent-encoded, and "acme:" is
returned unchanged.

Refs #105

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ON (#116)

* fix(converters): honor caller case sensitivity, keep HAL reserved names literal

Every converter Read hardcoded JsonNodeOptions.PropertyNameCaseInsensitive
= true, ignoring the caller's JsonSerializerOptions. That violated the HAL
specification (draft-kelly-json-hal section 4.1 defines _links and _embedded
as literal, case-sensitive reserved names) and caused two further defects:

- state properties differing only by case ({"Name":"a","name":"b"} is legal
  JSON) collided in the case-insensitive node tree and broke the resource
- ResourceConverter looked up the reserved names case-insensitively but
  stripped them from state case-sensitively, so "_LINKS" both populated
  Links and stayed in state, serializing the link data twice

All eight converters now derive their JsonNodeOptions from the caller's
JsonSerializerOptions.PropertyNameCaseInsensitive, so ordinary property
names follow the caller's configuration. The reserved _links/_embedded
lookups are matched ordinally regardless of that setting, keeping the
lookup and the state stripping consistent in both directions.

Refs #93

* fix(converters): surface malformed HAL JSON as JsonException

Malformed input leaked InvalidOperationException/ArgumentException out of the
converters, so callers that catch JsonException crashed. Every case verified
in the issue now throws System.Text.Json.JsonException:

- LinkObjectConverter: a non-string "href" (123, {}, []) and a Link Object
  that is not a JSON object (reachable via {"_links":{"self":[[]]}}) replace
  GetValue<string>()/indexer InvalidOperationException with JsonException.
- LinkCollectionConverter: a numeric or boolean rel value
  ({"_links":{"self":123}}) is rejected instead of throwing from
  GetValue<string?>(); the string href shorthand still works.
- LinkCollectionConverter and EmbeddedResourceCollectionConverter: a reserved
  array containing non-objects ({"_links":[1]}, {"_embedded":[1]}) is rejected
  instead of throwing from AsObject(), as is a reserved value that is neither
  an object nor an array.
- LinkObjectCollectionConverter: a non-string primitive element is rejected
  rather than silently swallowed by a catch-all.

Duplicate property names are normalized last-wins by a new
ConverterHelpers.ParseNode, which materializes the node tree through
JsonDocument instead of JsonNode.Parse. JsonNode otherwise defers a
duplicate-key ArgumentException to whenever the object is first materialized,
which for the lazy resource creators is property-access time. Last-wins
matches what most JSON parsers do with a construct RFC 8259 leaves undefined,
and it keeps the read path compatible with the decision in #86 that the link
and embedded collections reject duplicate keys. The link and embedded
collection converters additionally de-duplicate rel/name across array
elements, keeping the first-seen position.

Refs #94

* fix(converters): reject non-object resources at parse time

ResourceConverter's lazy linkCollectionCreator/embeddedCollectionCreator
closures indexed the parsed node without checking it was a JsonObject, so a
primitive or array resource deserialized "successfully" and then threw
InvalidOperationException at property-access time, far from the
deserialization call — Resource.Parse("123")!.Links and
Resource.Parse("{\"_embedded\":{\"orders\":[123]}}")!
.Embedded[0].Resources[0].Links both reproduced it.

Per the exception contract in #86 the converter now rejects a non-object
resource with JsonException at parse time, and materializes _links and
_embedded eagerly during Read so a malformed member fails at the
deserialization call rather than on a later property read. The creator
delegates are retained, returning the already-materialized collections, so
Resource's internal constructor and the lazy Links/Embedded getters are
unchanged.

ResourceConvertersTests.Resource_As_Typed_For_Primitive_Should_Return_Value
asserted the old tolerance — that "123" deserializes into a usable Resource —
which is exactly the defect this change removes and which contradicts the
existing HalDeserializationRobustnessTests root-type expectations. It is
updated in place to assert the new contract.

Refs #95

* fix(converters): build node tree ordinally and stop cloning per scalar

Addresses two review findings on the parse path introduced by this PR.

Deriving the node tree's JsonNodeOptions from the caller's
PropertyNameCaseInsensitive merged distinct JSON members before any lookup
could run. A document carrying both "_LINKS" and "_links" collapsed to one
entry, so depending on member order either the literal _links was lost or the
case variant was treated as reserved, and state properties or relation names
differing only by case were discarded the same way — the very defect #93
exists to remove, reintroduced for callers that opt into case-insensitivity.

The tree is now always built with an ordinal comparer. Case-insensitive
matching is applied at lookup time, by ConverterHelpers.GetProperty, and only
to ordinary Link Object attribute names; the reserved names stay literal, and
an exact match still wins over a case variant.

Cloning each scalar JsonElement out of the JsonDocument allocated a separate
backing document per value, so a large state array multiplied allocations by
an order of magnitude. Well-formed payloads no longer go through JsonDocument
at all: duplicate property names are detected with an allocation-free pre-scan
over a copy of the reader (Utf8JsonReader is a struct), and only a payload
that actually contains duplicates is rebuilt — cloning the root element once
rather than once per scalar. The pre-scan is deliberately conservative, taking
the rebuild path on a hash collision, an escaped property name, or a name
split across buffer segments, all of which yield the same tree.

Measured on Resource.Parse, before -> after:
  100k-scalar state array     24.69 MB ->  2.27 MB
  20k embedded HAL resources 201.68 MB -> 93.83 MB

Refs #93, #94

* fix(converters): hash-based duplicate scan overflow, option-aware href prechecks

The duplicate-name pre-scan's overflow list scanned linearly past the
16-entry inline buffer, making wide untrusted objects O(n^2); a HashSet
keeps it linear. LinkConverter's href prechecks used ordinal indexing
after the DOM went ordinal, rejecting case-variant hrefs that
LinkObjectConverter would accept under PropertyNameCaseInsensitive; both
prechecks now go through ConverterHelpers.GetProperty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(converters): walk existing node tree for nested resources, scan and rebuild iteratively

Nested resources were materialized with JsonNode.Deserialize, which
re-serializes the subtree to UTF-8 and re-parses it — once per ancestor,
turning a deeply nested _embedded chain into O(depth x size) work even
when the caller never touches Embedded. Internal ReadFromNode paths on
the resource, link-collection, embedded-collection, and
resource-collection converters now walk the already-parsed tree, so the
whole document is materialized in O(size).

The duplicate-name pre-scan and rebuild also recursed once per nesting
level (the scan with a stackalloc per object frame), so a caller raising
MaxDepth could hit an uncatchable StackOverflowException on permitted
input. Both are now iterative with heap-tracked depth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(converters): dispatch nested parts through options-registered custom converters

The node-walking fast path bypassed custom JsonConverter registrations
for LinkCollection, EmbeddedResourceCollection, Resource, and
ResourceCollection, though options-registered converters are documented
to take precedence over attribute-wired ones. Each dispatch point now
checks options.GetConverter and falls back to JsonNode.Deserialize when
the selected converter is not the built-in, keeping the fast path for
the default configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(converters): last case-insensitive variant wins in GetProperty fallback

The fallback returned the first case-insensitive match, while the old
case-insensitive JsonObject resolved a later variant over an earlier one
and duplicate keys normalize last-wins everywhere else. Exact-name
preference is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(converters): custom Resource converters see every collection item shape

The object-shape guard ran before the custom-converter dispatch, so an
options-registered Resource converter that deliberately accepts scalar
items threw instead of running. The custom path now dispatches first for
any node shape; the built-in path keeps the guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
)

* fix(converters): stop dropping state fields named Links/Embedded

ResourceConverter.Write skipped state properties whose names matched the
naming-policy-converted CLR names "Links"/"Embedded". Real _links/_embedded
never reach the state object — ReadFromNode's jsonObjectCreator strips them —
so the filter could only ever delete legitimate user state, and under a
camelCase policy it deleted a state field literally named "links".

Compare against the literal reserved names instead, and skip a state property
carrying one only when the resource's own collection is written under the same
name, so the output never contains a duplicate JSON member.

Closes #96

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(converters): preserve embedded array shape on round trip

HAL clients read array-vs-object shape as the signal that a relation is a
collection. Links preserved it via Link.IsArray; embedded resources did not.

- Reading an _embedded relation whose value is a JSON array now sets
  ForceWriteAsCollection, so a single-element array no longer collapses to an
  object on round trip. Applied in both EmbeddedResourceCollectionConverter and
  the standalone EmbeddedResourceConverter.
- EmbeddedResourceConverter.Write now honors ForceWriteAsCollection instead of
  delegating shape selection to ResourceCollectionConverter, so an instance
  serializes identically standalone and inside an _embedded collection.
- Pin the empty-relation shape: a relation carrying no link objects writes as
  [], the only spec-conformant rendering, documented on
  LinkCollectionConverter.Write and covered by tests.

Closes #97

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(converters): primitive state, registration guard, multi-rel, node reuse

- ResourceConverter.Write rejects state that serializes to something other than
  a JSON object with a JsonException instead of letting EnumerateObject leak an
  InvalidOperationException. A HAL Resource Object has no place for a primitive
  or array state; state serializing to JSON null contributes no members.
- AddHalConverters guards per converter type. The old guard checked only
  LinkCollectionConverter, so a consumer who had registered just that one got a
  silent no-op and the remaining seven converters were never registered (and
  halOptions was ignored). A consumer's own converter stays ahead of ours in the
  list, so JsonSerializer keeps selecting it.
- EmbeddedResourceConverter.Read throws on a multi-relation object instead of
  keeping the first relation and dropping the rest; a single EmbeddedResource
  holds exactly one name. EmbeddedResourceCollection remains the multi-relation
  reader.
- The remaining node-to-UTF-8-to-node hops are gone: EmbeddedResourceConverter,
  LinkCollectionConverter, LinkConverter, and LinkObjectCollectionConverter now
  materialize nested parts from the retained JsonNode subtree through internal
  ReadFromNode fast paths, with options-registered custom converters still
  dispatched ahead of them.

Closes #98

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(converters): keep custom ResourceCollection converters on the write path

Inlining the embedded shape logic in EmbeddedResourceConverter.Write bypassed a
consumer's options-registered JsonConverter<ResourceCollection>, which the
previous delegation to JsonSerializer.Serialize had dispatched through.

Both write paths now route through ConverterHelpers.WriteEmbeddedResources,
which hands the whole collection to a custom converter when one is registered
and applies the built-in count/ForceWriteAsCollection rule otherwise. Sharing
the routine also keeps the standalone and in-collection shapes identical by
construction, including in the custom-converter case — EmbeddedResourceCollection
Converter.Write previously bypassed such a converter too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e caches (#114)

* fix(domain): reject duplicate rels and keep collection indexes consistent

LinkCollection.Add overwrote _index[item.Rel] on a duplicate relation and
Remove dropped the index entry keyed by rel without checking that the indexed
value was the removed item, so the backing list and the relation index
desynchronized. EmbeddedResourceCollection had the identical defect keyed by
name.

Per the duplicate-rel decision recorded in #86, Add now throws ArgumentException
on a duplicate relation (or embedded resource name), completing the dictionary
semantics the existing index already implied. HAL models _links and _embedded as
JSON objects keyed by relation, so duplicate entries could never serialize into
spec-valid output. With uniqueness enforced at Add, Remove can drop the index
entry unconditionally and stay consistent.

Add and Remove also reject null instead of throwing NullReferenceException.

Three existing extension tests asserted the removed duplicate-tolerant behavior
and could no longer compile-and-pass as written; they now assert that duplicates
are rejected at Add time.

Refs #99

* fix(domain): give HAL domain types real structural equality

The synthesized record equality on Resource included the mutable private fields
that back its lazy caches (_linksImpl, _embeddedImpl, _stateObject,
_resourceNode). Reading Links, Embedded, State<T> or As<T> populated those
fields, so a pure read changed the resource's hash code and evicted it from any
hash-based collection it had been placed in.

The synthesized equality on the collection records was also only nominally
structural: each holds a Collection<T> (and, for the keyed collections, a
Dictionary<,>) that the compiler compares by reference, so two collections
holding identical content were never equal and Remove/Contains could not find a
structurally equal item.

Per the semver decision in #86 the types stay records, preserving source
compatibility and with-expressions; Equals/GetHashCode are overridden explicitly
instead:

- Resource compares its links, its embedded resources and a stable JSON key for
  its state. The caches are excluded, so the hash code no longer changes when a
  getter runs. For a parsed resource the state key is derived from the original
  JSON rather than from the cached state object, which State<T> replaces.
- LinkCollection and EmbeddedResourceCollection compare by relation and by name
  respectively, since a HAL "_links"/"_embedded" value is a JSON object and JSON
  object members are unordered. The derived index takes no part in equality.
- LinkObjectCollection and ResourceCollection compare positionally, since both
  serialize as JSON arrays, where order is significant.

Link, LinkObject and EmbeddedResource keep their synthesized equality, which is
now genuinely structural because the collection members they hold are.

Refs #100

* fix(domain): correct the State<T> and As<T> caches on Resource

State<T> cached the first successful deserialization in _stateObject and then
returned it through an unguarded (T?) cast. A later State<U> for a different type
threw InvalidCastException, which the blanket catch swallowed, so the call
returned null even though the JSON deserialized as U perfectly well. The cache is
now only reused when it already holds an instance of T; otherwise the state is
deserialized again from the underlying JSON. The cache is populated only when it
was empty, so projecting a resource onto a second state type cannot replace the
state the resource serializes from.

As<T> cached the serialized JsonNode on its first call and never invalidated it,
so any link, embedded resource or state change made afterwards was invisible to
every later call. A resource constructed in memory is mutable and is now
re-serialized on every call. A parsed resource keeps converting from the document
it was parsed from, which preserves the original shape and is the behavior the
node was there to provide.

Refs #101

* fix(domain): canonicalize the Resource state equality key

Two defects in the state half of Resource equality, both raised in review of #114.

The key came from JsonObject.ToJsonString(), which preserves insertion order, so
two resources parsed from the same state properties written in a different order
compared unequal and hashed differently. That contradicted the rest of the
equality design, where LinkCollection and EmbeddedResourceCollection already
compare order-insensitively because JSON object members are unordered. The key is
now canonical: object properties are ordered by name and array element order is
preserved, since JSON array order is significant. Canonicalization runs over a
JsonNode, so a parsed resource and an in-memory resource holding the same state
produce the same key.

Serialization failures were also mapped onto the same null key used for an absent
state, so a resource whose state cannot be represented as JSON — a cyclic graph,
a delegate — compared equal to a stateless resource, and two resources holding
different unserializable states compared equal to each other. The key helper now
reports whether the state could be represented at all; when either side could
not, equality falls back to reference identity of the state object, and every
such state contributes one fixed hash-code component so equal resources still
agree on their hash code.

Refs #100

* fix(domain): empty state object normalizes to the absent-state equality key

Resource.Parse("{}") and new Resource() serialize to the identical HAL
document, so they must compare equal under HAL-content equality.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(domain): preserve constructor-supplied JsonElement for later State projections

The first State<T>() call replaced a JsonElement state with the
projected object, so a later State<U>() reached the always-null creator
of public-constructor instances and returned null. The original element
is preserved as the rematerialization source, giving constructed
resources the same multi-type projection contract as parsed ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(domain): equality ignores shape flags without serialization effect; key from source element

Link.IsArray and EmbeddedResource.ForceWriteAsCollection only change the
serialized document when the count is exactly one, so explicit equality
normalizes the flag to its effective value. The state equality key of a
constructor-supplied JsonElement resource derives from the preserved
element rather than a projected DTO, so reading state cannot change the
hash code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(domain): State<T> projections are detached from the serialization source

A projection cached into _stateObject let a mutated DTO change what
Write serializes while equality kept reading the original JSON — the two
could permanently disagree. State<T> now returns a detached snapshot
materialized from the state of record (parsed JSON or the
constructor-supplied JsonElement) on every call; only a state supplied
directly as T is returned by reference. This also removes the
_sourceStateElement field added in the previous round — the element
simply stays the state of record. Three tests asserting the old
same-instance caching contract are updated to the detached contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(domain): LinkObject equality normalizes converter-omitted optional values

LinkObjectConverter.Write omits optional string properties that are null
or whitespace-only alike, so both forms serialize to identical HAL and
must compare equal with matching hash codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* docs(domain): describe detached State<T> projections; drop obsolete caching claims

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* docs(domain): As<T> no longer caches for in-memory resources; describe both paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(domain): equality key mirrors WhenWritingNull omission of null state properties

With DefaultIgnoreCondition = WhenWritingNull in a resource's own
options, ResourceConverter.Write omits top-level null-valued state
properties, so the equality key omits them identically. The options are
immutable per-instance state, so the key stays deterministic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* docs(domain): note the by-reference constructor-state exception in the api summary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* test: reconcile curie tests with the duplicate-rel reject policy after integration

Merging release/2.0 (Tracks 1, 2, 4) surfaced two Track 4 tests that
hand-build a second 'curies' link, a state LinkCollection.Add now
rejects per #86 decision 1. The multi-definition scenario lives as
multiple curie link objects within the single curies link; the
two-links hazard is asserted as rejected at Add.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(domain): equality key mirrors reserved-name suppression and null-serializing state

Two more writer/key mirror gaps against the integrated WriteStateMembers:
a state property literally named _links/_embedded is excluded from the
key exactly when the writer suppresses it (nonempty own collection
written under that name), and a state that serializes to JSON null keys
as absent, matching the empty document the writer produces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* refactor(domain): derive the state equality key from the writer itself

The key builder had become a hand-maintained mirror of
ResourceConverter's write rules (null omission, reserved-name
suppression, null/empty state), and every writer change could silently
desynchronize it — the source of a long review-comment stream. The
writer's state-member emission is now factored into
SerializeStateMembersToUtf8 (shared CreateStateDocument +
WriteStateMembers code paths), and equality canonicalizes those exact
bytes. All mirrored rules are deleted; equality is state-equal exactly
when the writer emits the same members, by construction, for any future
writer behavior. All prior equality tests pass unchanged against the
derived implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(domain): netstandard2.0 build — JsonSerializerOptions.Default unavailable

Use a shared default-configured instance for equality-key serialization;
.Default is net7+ only and the package multi-targets netstandard2.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
)

* fix(builders): curies serializes as an array by default per HAL spec section 8.3

The spec establishes CURIEs via an array of Link Objects and common HAL
clients index _links.curies as an array, so a single definition must
still serialize as a one-element array. AsArray() becomes a no-op. Found
by the full spec-conformance audit; the single-definition default was
the one medium-severity deviation.

Closes #119

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

* fix(builders): curies array default applies to every builder path

Set in the LinkBuilder constructor keyed on the rel, so AddCuries(),
AddLink("curies"), and merges into an existing builder all get the
array form; the factory-only initializer missed the generic path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Atomic version bump per CLAUDE.md: csproj Version, CLAUDE.md package
table, and CHANGELOG.md release section with breaking-changes and
migration notes for the coordinated 2.0.0 fix wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaPH7EX9mPnwPDegPBophY

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba9133720f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CHANGELOG.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9523c70a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Chatter.Rest.Hal/Resource.cs
@brenpike
brenpike merged commit c93781c into main Aug 23, 2026
11 checks passed
@brenpike
brenpike deleted the release/2.0 branch August 23, 2026 21:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant