Skip to content

[DMS-1252] Derive _etag from ContentVersion instead of a content hash#1091

Draft
stephenfuqua wants to merge 77 commits into
mainfrom
etag-content-version
Draft

[DMS-1252] Derive _etag from ContentVersion instead of a content hash#1091
stephenfuqua wants to merge 77 commits into
mainfrom
etag-content-version

Conversation

@stephenfuqua

@stephenfuqua stephenfuqua commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Changes the redesigned relational backend's _etag from a SHA-256 content hash to a composed strong validator, implementing reference/adr-etag-from-content-version.md (accepted 2026-07-03).

_etag       = "{ContentVersion}-{variantKey}"
variantKey  = schemaEpoch "." format "." profileCode "." linkFlag
ETag header = "{...}"   (quotes are HTTP framing only)
  • ContentVersion = dms.Document.ContentVersion (opaque string, never parsed numerically).
  • schemaEpoch = first 8 hex of the in-force EffectiveSchemaHash; format = j (JSON registry); profileCode = _ or 8-hex SHA-256 of the profile name; linkFlag = l/n from ResourceLinks:Enabled.

Why

The old hash could not be computed from the request body (persisted state differs after merges/cascades/normalization), so every write did a full hydrate-materialize-hash readback inside the write transaction solely to populate a response header. Composition removes that readback entirely — _etag is now a string built from a stored counter plus precomputed representation tokens, with no DB dependency.

Behavioral changes

  • RFC 7232 strong validators: ETag is served quoted, never W/. Different representations of the same state (readable profile, links on/off) now carry different _etag values — a deliberate reversal of the prior profile/link-insensitive contract, per the ADR.
  • If-Match compares the state-significant projection only (ContentVersion, schemaEpoch, profileCode); format and linkFlag are excluded, so they never cause a spurious 412. A cross-profile If-Match does 412 even at the same ContentVersion.
  • Fixed a real bug where the standard-write/DELETE precondition hard-coded no-profile, 412-ing profiled updates.

Scope

  • New Etag/ building blocks (EtagComposer, VariantKey/VariantKeyFactory, ProfileVariantCode, EtagMatchProjection, EtagValue, DescriptorVariantKey) with unit tests.
  • Resource and descriptor read/write paths converted to compose (descriptors honor the ContentVersion stamp trigger: INSERT via RETURNING/OUTPUT, UPDATE via follow-up SELECT).
  • Removed dead hash code (ResourceEtagFormatter, RelationalApiMetadataFormatter) and the write-path hash readback.
  • Design docs aligned: update-tracking.md (owning contract + normative variantKey encoding), transactions-and-concurrency.md, flattening-reconstitution.md.

Test Plan

  • Solution build: 0 warnings, 0 errors (SonarAnalyzer clean)
  • Unit: backend 1839, core 2554, frontend 193 — all pass
  • Targeted PG integration: descriptor read+write (22) pass
  • Targeted MSSQL integration: descriptor read+write (12) pass
  • Earlier phases: PG/MSSQL profile-If-Match, links-flag, cascade, and caller-agnostic etag suites verified green (see checkpoint)
  • E2E (etag.feature, ProfileFiltering.feature 07–08) — updated + compile-verified;

🤖 Generated with Claude Code

@stephenfuqua stephenfuqua marked this pull request as draft July 4, 2026 00:46
@stephenfuqua stephenfuqua requested a review from Copilot July 4, 2026 00:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the Ed-Fi DMS relational backend’s _etag semantics from a SHA-256 content hash to a composed strong validator derived from ContentVersion plus representation variant inputs, aligning runtime behavior and tests/docs with the accepted ADR (adr-etag-from-content-version.md).

Changes:

  • Replace hash-based ETag generation with ContentVersion + variantKey composition across relational read/write paths (including descriptors) and update optimistic concurrency (If-Match) to compare a state-significant projection.
  • Move HTTP ETag header formatting to a quoted strong-validator boundary and normalize If-Match parsing to accept quoted/unquoted strong tags while rejecting weak validators.
  • Update unit/integration/E2E tests and redesign design docs to reflect profile-/links-sensitive ETags and projection-based If-Match matching.

Reviewed changes

Copilot reviewed 77 out of 78 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/dms/tests/EdFi.DataManagementService.Tests.E2E/StepDefinitions/StepDefinitions.cs Strips quotes from ETag header when capturing _etag for scenario use.
src/dms/tests/EdFi.DataManagementService.Tests.E2E/StepDefinitions/ProfileStepDefinitions.cs Normalizes stored etag header values by stripping strong-validator quotes.
src/dms/tests/EdFi.DataManagementService.Tests.E2E/Features/Profiles/ProfileFiltering.feature Updates expectations to require profile-sensitive _etag values.
src/dms/frontend/EdFi.DataManagementService.Frontend.AspNetCore/AspNetCoreFrontend.cs Quotes etag response header as a strong validator at the HTTP boundary.
src/dms/frontend/EdFi.DataManagementService.Frontend.AspNetCore.Tests.Unit/RelationalWriteSmokeTests.cs Updates frontend smoke tests to expect quoted ETag header wrapping an opaque composed value.
src/dms/frontend/EdFi.DataManagementService.Frontend.AspNetCore.Tests.Unit/AspNetCoreFrontendResponseHeaderTests.cs Verifies ETag header is quoted; adds empty-etag behavior coverage.
src/dms/core/EdFi.DataManagementService.Core/Utilities/ResourceEtagFormatter.cs Removes legacy canonical-hash ETag formatter.
src/dms/core/EdFi.DataManagementService.Core/Utilities/EtagValue.cs Adds helpers for composing _etag values and quoting/parsing header wire forms.
src/dms/core/EdFi.DataManagementService.Core/Middleware/InjectVersionMetadataToEdFiDocumentMiddleware.cs Removes middleware-side _etag derivation from request body.
src/dms/core/EdFi.DataManagementService.Core/Handler/Utility.cs Threads readable profile name into projection context for profile-sensitive ETags.
src/dms/core/EdFi.DataManagementService.Core/Handler/UpsertHandler.cs Uses backend-provided ETag only (no request-body fallback).
src/dms/core/EdFi.DataManagementService.Core/Handler/UpdateByIdHandler.cs Uses backend-provided ETag only (no request-body fallback).
src/dms/core/EdFi.DataManagementService.Core/Backend/WritePreconditionFactory.cs Normalizes If-Match header to an unquoted opaque tag via EtagValue.
src/dms/core/EdFi.DataManagementService.Core.Tests.Unit/Utilities/ResourceEtagFormatterTests.cs Removes unit tests for legacy hash ETag behavior.
src/dms/core/EdFi.DataManagementService.Core.Tests.Unit/Utilities/EtagValueTests.cs Adds unit tests for composing/quoting/parsing ETag wire forms.
src/dms/core/EdFi.DataManagementService.Core.Tests.Unit/Middleware/InjectVersionMetadataToEdFiDocumentMiddlewareTests.cs Removes tests asserting middleware-generated _etag.
src/dms/core/EdFi.DataManagementService.Core.Tests.Unit/Handler/UpsertHandlerTests.cs Updates handler tests to no longer depend on request-body _etag fallback.
src/dms/core/EdFi.DataManagementService.Core.Tests.Unit/Handler/UpdateByIdHandlerTests.cs Updates handler tests to no longer depend on request-body _etag fallback.
src/dms/core/EdFi.DataManagementService.Core.Tests.Unit/Handler/RelationalWriteSeamTests.cs Removes stale request _etag injection assertions now that ETag comes from backend.
src/dms/core/EdFi.DataManagementService.Core.Tests.Unit/Handler/DeleteByIdHandlerTests.cs Updates expectations for normalized If-Match (unquoted opaque tag).
src/dms/core/EdFi.DataManagementService.Core.Tests.Unit/Backend/WritePreconditionFactoryTests.cs Updates tests to validate normalization and weak-tag handling.
src/dms/backend/EdFi.DataManagementService.Backend/RelationalWriteExecutionStateResolver.cs Switches precondition evaluation to composed ETags and projection-based comparison.
src/dms/backend/EdFi.DataManagementService.Backend/RelationalReadMaterializer.cs Composes _etag during ExternalResponse materialization; adds wiring guard requiring variant inputs + mapping set.
src/dms/backend/EdFi.DataManagementService.Backend/RelationalDocumentStoreRepository.cs Supplies ETag variant inputs (profile + format) to materializer for GET/query.
src/dms/backend/EdFi.DataManagementService.Backend/RelationalCurrentEtagPreconditionChecker.cs Composes current ETag from locked ContentVersion + variant inputs and compares projections.
src/dms/backend/EdFi.DataManagementService.Backend/RelationalCommittedRepresentationReader.cs Replaces hydrate/materialize/hash readback with a ContentVersion scalar read + composed ETag.
src/dms/backend/EdFi.DataManagementService.Backend/RelationalApiMetadataFormatter.cs Removes legacy relational metadata hash formatter.
src/dms/backend/EdFi.DataManagementService.Backend/ReferenceResolverServiceCollectionExtensions.cs Registers singleton IEtagComposer for reuse across backend components.
src/dms/backend/EdFi.DataManagementService.Backend/Etag/VariantKey.cs Introduces variantKey model + factory (schemaEpoch/format/profileCode/linkFlag).
src/dms/backend/EdFi.DataManagementService.Backend/Etag/ProfileVariantCode.cs Adds stable profileCode derivation using SHA-256 hex prefix.
src/dms/backend/EdFi.DataManagementService.Backend/Etag/EtagMatchProjection.cs Adds state-significant projection for If-Match comparisons.
src/dms/backend/EdFi.DataManagementService.Backend/Etag/EtagComposer.cs Implements IEtagComposer composing {ContentVersion}-{variantKey}.
src/dms/backend/EdFi.DataManagementService.Backend/Etag/DescriptorVariantKey.cs Provides fixed variant key for descriptor representations.
src/dms/backend/EdFi.DataManagementService.Backend/DescriptorWriteHandler.cs Updates descriptor write responses + precondition checks to use ContentVersion-based composed ETags.
src/dms/backend/EdFi.DataManagementService.Backend/DescriptorReadRowReader.cs Adds ContentVersion to descriptor read row shape.
src/dms/backend/EdFi.DataManagementService.Backend/DescriptorReadHandler.cs Composes descriptor _etag from row ContentVersion + fixed descriptor variant key.
src/dms/backend/EdFi.DataManagementService.Backend/DescriptorDocumentMaterializer.cs Switches descriptor external response _etag to composed ETag (no hashing).
src/dms/backend/EdFi.DataManagementService.Backend/DefaultRelationalWriteExecutor.cs Wires IEtagComposer into the write execution path.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/RelationalReadMaterializerTests.cs Updates materializer tests to assert composed ETags + new wiring guard behavior.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/RelationalDocumentStoreRepositoryTests.cs Updates repository tests to treat write-result ETags as opaque values (no hashing).
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/RelationalCurrentEtagPreconditionCheckerTests.cs Updates precondition checker tests to validate composed ETags and projection matching.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/RelationalCommittedRepresentationReaderTests.cs Updates committed representation reader tests to validate scalar ContentVersion read + composed ETag.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/RelationalApiMetadataFormatterTests.cs Removes unit tests for legacy metadata hash formatter.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/Etag/VariantKeyTests.cs Adds unit tests for variantKey formatting rules.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/Etag/ProfileVariantCodeTests.cs Adds unit tests for profileCode derivation.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/Etag/EtagMatchProjectionTests.cs Adds unit tests for projection behavior and malformed-tag handling.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/Etag/EtagComposerTests.cs Adds unit tests for composed {ContentVersion}-{variantKey} output.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/Etag/DescriptorVariantKeyTests.cs Adds unit tests for descriptor fixed variant key.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/DescriptorWriteHandlerResponseEtagTests.cs Updates descriptor write tests to assert composed response ETags.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/DescriptorWriteHandlerPreconditionTests.cs Updates descriptor precondition tests for composed ETags + contentVersion return rows.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/DescriptorWriteHandlerNamespaceAuthorizationTests.cs Updates namespace-authorization tests to include contentVersion rows for ETag composition.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/DescriptorWriteHandlerDeleteTests.cs Wires IEtagComposer into delete tests fixture.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/DescriptorReadRowReaderTests.cs Updates row-reader tests for added ContentVersion column.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/DescriptorReadHandlerTests.cs Updates descriptor read handler tests to assert composed descriptor ETags.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/DescriptorReadHandlerNamespaceAuthorizationTests.cs Updates namespace-authorization read tests for added ContentVersion column and composer wiring.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Unit/DescriptorDocumentMaterializerTests.cs Updates materializer tests to assert composed descriptor ETags.
src/dms/backend/EdFi.DataManagementService.Backend.Tests.Common/RelationalGetIntegrationTestHelper.cs Removes body-derived expected ETag; adds composed ETag shape assertion and excludes _etag from canonical body comparisons.
src/dms/backend/EdFi.DataManagementService.Backend.Postgresql.Tests.Integration/PostgresqlResourceLinksFlagIntegrationTests.cs Updates link-flag integration tests: served ETags differ by linkFlag, but projections match.
src/dms/backend/EdFi.DataManagementService.Backend.Postgresql.Tests.Integration/PostgresqlRelationalWritePostAsUpdateSmokeTests.cs Updates assertions to validate composed write-result ETag shape rather than content-hash.
src/dms/backend/EdFi.DataManagementService.Backend.Postgresql.Tests.Integration/PostgresqlRelationalWriteAuthoritativeSampleStudentSchoolAssociationSmokeTests.cs Updates profile/link-mode ETag expectations; adds profile name threading for ETag composition.
src/dms/backend/EdFi.DataManagementService.Backend.Postgresql.Tests.Integration/PostgresqlRelationalWriteAuthoritativeSampleSmokeTests.cs Updates profile-sensitive ETag expectations and cascade assertions using composed ETag shape.
src/dms/backend/EdFi.DataManagementService.Backend.Postgresql.Tests.Integration/PostgresqlRelationalQueryExecutionTests.cs Wires composer into integration test materializer wrapper.
src/dms/backend/EdFi.DataManagementService.Backend.Postgresql.Tests.Integration/PostgresqlProfileIfMatchEtagTests.cs Updates tests to assert profile-sensitive ETags + same ContentVersion component across profiles.
src/dms/backend/EdFi.DataManagementService.Backend.Postgresql.Tests.Integration/PostgresqlDescriptorReadGetTests.cs Updates descriptor GET integration tests to assert composed ETag shape (no body hashing).
src/dms/backend/EdFi.DataManagementService.Backend.Mssql.Tests.Integration/MssqlResourceLinksFlagIntegrationTests.cs MSSQL counterpart: served ETags differ across link-flag flip; projections match.
src/dms/backend/EdFi.DataManagementService.Backend.Mssql.Tests.Integration/MssqlRelationalWriteAuthoritativeSampleStudentSchoolAssociationSmokeTests.cs MSSQL counterpart: updates link/profile ETag expectations; threads profile name into ETag.
src/dms/backend/EdFi.DataManagementService.Backend.Mssql.Tests.Integration/MssqlRelationalWriteAuthoritativeDs52SurveySmokeTests.cs Updates cascade assertions to validate composed ETag shape rather than body-derived ETag.
src/dms/backend/EdFi.DataManagementService.Backend.Mssql.Tests.Integration/MssqlRelationalQueryExecutionTests.cs Wires composer into integration test materializer wrapper.
src/dms/backend/EdFi.DataManagementService.Backend.Mssql.Tests.Integration/MssqlProfileIfMatchEtagTests.cs MSSQL counterpart: profile-sensitive ETags and ContentVersion-component equivalence checks.
src/dms/backend/EdFi.DataManagementService.Backend.Mssql.Tests.Integration/MssqlDescriptorWriteTests.cs Adds descriptor write ETag parity test vs follow-up GET; updates delete precondition tests.
src/dms/backend/EdFi.DataManagementService.Backend.Mssql.Tests.Integration/MssqlDescriptorReadGetTests.cs Updates descriptor GET integration tests to assert composed ETag shape.
src/dms/backend/EdFi.DataManagementService.Backend.External/RelationalGetRequestContracts.cs Extends readable profile projection context contract to include ProfileName for ETag composition.
reference/design/backend-redesign/design-docs/update-tracking.md Updates normative design to specify composed ETag + variantKey encoding and projection-based If-Match matching.
reference/design/backend-redesign/design-docs/transactions-and-concurrency.md Updates concurrency docs to reflect composed ETags and projected If-Match comparison.
reference/design/backend-redesign/design-docs/flattening-reconstitution.md Updates reconstitution docs to remove hashing requirement and specify composed ETag.
.gitignore Normalizes ignore rule for docs/superpowers path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/dms/core/EdFi.DataManagementService.Core/Handler/Utility.cs
Comment thread src/dms/backend/EdFi.DataManagementService.Backend/Etag/EtagMatchProjection.cs Outdated
@stephenfuqua stephenfuqua force-pushed the etag-content-version branch from eb9d44c to 415708e Compare July 4, 2026 18:22
@bradbanister

Copy link
Copy Markdown
Contributor

I was doing some perf testing this weekend, and a subset of what you are working on here surfaced so I held off on making that subset change. I did take a look at the design so far and have a recommendation to move the ContentVersion lookup:

Suggestion

The lookup should move into RelationalWriteNoProfilePersister because the persister owns the write boundary and is the only layer that knows when all persistence-side mutations are complete.

Right now the flow is:

  1. DefaultRelationalWriteExecutor calls _persister.PersistAsync(...).
  2. The persister writes dms.Document, root rows, child rows, deletes, inserts, updates.
  3. It returns only DocumentId and DocumentUuid.
  4. DefaultRelationalWriteExecutor then calls RelationalCommittedRepresentationReader.
  5. That reader does a separate SELECT ContentVersion ... FOR UPDATE just to compose _etag.

That last step is no longer really a “committed representation read.” It does not hydrate a representation anymore; it only fetches write metadata. That metadata is part of the persistence outcome, so it belongs in RelationalWritePersistResult.

The safer incremental design would be:

  1. Extend RelationalWritePersistResult:
internal sealed record RelationalWritePersistResult(
    long DocumentId,
    DocumentUuid DocumentUuid,
    long ContentVersion
);
  1. At the end of RelationalWriteNoProfilePersister.PersistAsync, after ExecuteDeletesAsync and ExecuteUpsertsAsync, read the final ContentVersion and return it.

That timing matters. Reading the root insert stamp is not enough because child table deletes/upserts can fire stamp triggers that bump the owning root document. The final version is only known after every table operation has run.

  1. Change RelationalCommittedRepresentationReader so it no longer queries dms.Document. It should compose from persistedTarget.ContentVersion.

  2. For guarded no-op, no persister runs. DefaultRelationalWriteExecutor can construct the result with guardedTarget.ObservedContentVersion after the freshness check passes.

The “why” is mostly ownership and correctness:

  • The persister is the authoritative source for “what did this write commit?”
  • The final ContentVersion is persistence metadata, not response materialization metadata.
  • Moving it into the result makes the write contract explicit instead of hiding a database lookup in the response reader.
  • It also prevents future code from accidentally reintroducing representation hydration/hash work behind a “read committed response” abstraction.
  • It sets up the next optimization: later, the persister can capture the stamp from DML/triggers instead of doing even the final lightweight lookup.

This move would not immediately eliminate the database round trip unless we also teach the DML path to surface the final stamp. But it puts that round trip in the right layer, after all mutations, and makes the remaining work visible and easy to remove later.

see [Amendment (2026-07-05, wildcard)](#amendment-2026-07-05-if-match-wildcard-matching). \
**Date:** 2026-06-30 (accepted 2026-07-03) \
**Deciders:** Development team (signed off 2026-07-03). \
**Author:** Stephen Fuqua, with analysis assistance from Claude Opus 4.8 (Claude Code).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I made several key amendments to ensure backwards compatibility with the ODS/API.

Before final merge, I want to consolidate the amendments directly into the flow of the document. But leaving for now so that reviewers can clearly see and confirm those changes to the requirements.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This file was previously recreating the Descriptor object in order to hash it, hence no longer needed.

"""
When a DELETE if-match "{IfMatch}" request is made to "/ed-fi/students/{id}"
Then it should respond with 204
@e2e-ci-shard-1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have not studied the "sharding" concept, so I have no idea if shard-1 is appropriate or not.

// As of the 2026-07-04 ADR amendment, profileCode is no longer state-significant for If-Match
// (EtagMatchProjection compares only ContentVersion + schemaEpoch), so using an etag from a
// profiled GET here is incidental rather than required - the unprofiled seed POST's etag would
// also match. GET never sets an ETag response header (GetByIdHandler always returns Headers: []),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

GET never sends an ETag response handler? Is the plain reading accurate - which would indicate a potential bug?

The Ed-Fi API Design Guidelines partially misses this point. The GET Requests section does not list it as a header, but it is mentioned as a requirement in the Optimistic Concurrency section.

Note: RFC 9110 specifies that "An origin server SHOULD send an ETag for any selected representation for which detection of changes can be reasonably and consistently determined...".

I need to investigate this point in the DMS code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copilot review also noted this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is addressed in another PR into this branch.

Comment thread .gitignore
*.lscache

./docs/superpowers/**
docs/superpowers/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The original rule was not working properly.

@stephenfuqua stephenfuqua changed the title Derive _etag from ContentVersion instead of a content hash [DMS-1252] Derive _etag from ContentVersion instead of a content hash Jul 6, 2026
@stephenfuqua

stephenfuqua commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed by Claude Sonnet 4.6 with subagents for: Security · Functionality · Maintainability · Usability · Test Coverage.


🔴 Critical

None found.


🟠 High

  1. [Functionality] GET-by-id responses do not emit an ETag response header
    Detail: GetByIdHandler.CreateSuccessResponse returns Headers: [] and no later path copies the _etag body field to the HTTP ETag header for reads. The PR updates write responses to emit quoted strong validators, but read responses omit the header, which misses the RFC 7232 requirement that a resource's ETag be served on GET.
    Fix: Populate FrontendResponse.Headers["etag"] from the body _etag on successful GET-by-id responses (or add a centralized response-layer mapping in AspNetCoreFrontend).

Note

This is an existing bug that I did not (yet) try to fix. I will create a new ticket for that, and create a pull request into this branch with the fix. -SF

  1. [Functionality] Descriptor _etag ignores the active profile even though descriptor bodies can be profile-projected
    Detail: DescriptorReadHandler applies ReadableProfileProjector to descriptor responses so the served bytes vary by profile, but DescriptorVariantKey.For(...) hard-codes no profile. Two profile-different descriptor representations thus share the same strong validator, violating the PR's RFC 7232 representation-sensitive contract.
    Fix: Include profileCode in DescriptorVariantKey composition when a readable profile is active, or explicitly document and test that readable-profile projection is blocked for descriptors.

Note

I need to confirm this. Having a profile on descriptor bodies seems a bit strange. But if technically feasible, then yes we need to account for it. -SF

  1. [Usability] 412 responses are too generic and can actively mislead clients after this etag format change
    Detail: UpdateByIdHandler, DeleteByIdHandler, and UpsertHandler all return the same "The item has been modified by another user" body for every precondition failure—covering a real concurrent update, a stored legacy hash-based etag after upgrade, and If-Match: * failing on a non-existent resource. These need different recovery actions, but the API gives no clue which occurred.
    Fix: Return distinct detail text for stale/legacy-format tags vs. true concurrency failures vs. wildcard existence failures. Explicitly tell clients to re-GET and retry with the latest _etag.

Note

Will look into this. -SF


🟡 Medium

  1. [Maintainability] ETag construction and If-Match comparison logic are duplicated across multiple write/read paths
    Detail: The "compose served etag" and "compare via EtagMatchProjection.Of(...)" pattern appears in RelationalCurrentEtagPreconditionChecker, RelationalWriteExecutionStateResolver, and DescriptorWriteHandler, with variant-key composition also repeated in RelationalReadMaterializer, RelationalCommittedRepresentationReader, and several descriptor write paths. Future format changes will require synchronized edits across all these sites.
    Fix: Introduce a single service for "compose served etag" and another for "evaluate If-Match" so callers supply only context.

Note

I agree. -SF

  1. [Maintainability] The variant-key wire format is only partially encapsulated; parsing depends on implicit structure
    Detail: VariantKeyFactory.Create hardcodes schemaEpoch.format.profileCode.linkFlag in VariantKey.cs while EtagMatchProjection.cs independently assumes a - separator plus exactly four dot-delimited parts and indexes parts[0]. A future variant-key shape change will ripple across factories, parsers, tests, and comments.
    Fix: Give VariantKey/EtagValue a typed parser/formatter with constants for part count and separators, and expose ProjectForIfMatch() so the wire shape is defined in one place.

Note

I agree. -SF

  1. [Usability] Breaking _etag contract change lacks user-facing documentation
    Detail: The ADR and tests describe the new format well internally, but there are no matching OpenAPI description updates or release notes for API consumers. Clients that assumed a hex-hash etag or "same resource = same etag" will break in confusing ways, especially those that persist etags across deployments.
    Fix: Update OpenAPI descriptions for _etag and If-Match, and add release notes / migration guidance with concrete before/after examples.

Note

Technically an interesting point, but I don't know that it is relevant here. The etag is opaque, so why would there be any details in the OpenAPI descriptions? I should look carefully to confirm that. As a brand-new application, release notes / migration guidance are irrelevant. -SF

  1. [Usability] First write after upgrade immediately 412s clients with persisted legacy etags
    Detail: EtagMatchProjection treats old hash-style etags as malformed, so stored legacy values produce 412 until the client re-reads. There is no server-side hint that this is a format change, not a concurrency collision.
    Fix: Add explicit migration guidance in release notes. Optionally provide a short compatibility window that accepts legacy hash etags if feasible.

Note

Not relevant, since we don't migrate data within a school year. -SF

  1. [Test Coverage] Quoted If-Match round-trip is never exercised end-to-end
    Detail: etag.feature uses {IfMatch} but StepDefinitions strips quotes before reuse. E2E writes only prove unquoted acceptance, not that the RFC-form quoted header value actually succeeds on PUT/DELETE.
    Fix: Add PUT and DELETE E2E scenarios that send the raw quoted ETag response header unchanged and expect 200/204.

Note

I agree, this is important functionality and it needs to be exercised appropriately. -SF

  1. [Test Coverage] Schema-epoch mismatch is only indirectly tested; a real precondition regression could slip through
    Detail: EtagMatchProjectionTests proves projections differ when epoch differs, but RelationalCurrentEtagPreconditionCheckerTests only checks mismatched ContentVersion, not same-version/different-epoch. Dropping schemaEpoch from the real comparison would not be caught.
    Fix: Add checker/descriptor-precondition tests where ContentVersion matches but EffectiveSchemaHash differs, asserting mismatch/412.

Note

I agree. -SF

  1. [Test Coverage] Profiled DELETE parity is not tested — only half of the "profiled writes no longer 412" fix is verified
    Detail: PUT parity is covered by ProfileFiltering.feature scenario 10 and integration tests, but there is no profiled/unprofiled DELETE parity test. The stated bug fix is only half protected.
    Fix: Add MSSQL/PostgreSQL integration tests (or E2E) for DELETE using profiled and unprofiled etags interchangeably against the same document.

Note

I agree. -SF


🔵 Low

  1. [Maintainability] Stale XML doc comments describe a legacy content-hash fallback that no longer exists
    Detail: RelationalReadMaterializer.cs comments say missing EtagVariant falls back to a legacy hash, but ComposeEtag() now throws on missing EtagVariant/MappingSet. This will mislead future maintainers.
    Fix: Update the request-property XML docs to state these fields are required and that absence is a wiring bug.

Note

I agree. -SF

  1. [Maintainability] ETag mismatch decisions are logged at no level, making 412 debugging difficult in production
    Detail: When precondition evaluation fails in RelationalCurrentEtagPreconditionChecker, RelationalWriteExecutionStateResolver, and DescriptorWriteHandler, no trace-level log records whether the failure was malformed input, schema-epoch drift, or stale content version.
    Fix: Add debug-level logging at decision points with non-sensitive fields (trace id, wildcard vs. specific tag, projected current/client etag values).

Note

I agree. -SF

  1. [Security] ProfileVariantCode uses only 32 bits of SHA-256 — low collision risk, but worth noting
    Detail: ProfileVariantCode.Of() truncates SHA-256 to 8 hex chars (32 bits). A collision between two profile names would cause them to share the same served ETag for the same content version, weakening representation uniqueness for caches. Profile names are server-controlled so exploitability is low.
    Fix: Use a longer prefix (e.g., 16 hex chars / 64 bits) or a stable, collision-free profile index.

Note

Thinking about that, and need to evaluate the impact of this change. Its not like there are a large number of profiles that would make this risk very likely. Also very low impact in the rare case that a collision occurs. -SF


⚪ Nit

  1. [Security] EtagMatchProjection intentionally matches only ContentVersion-schemaEpoch — a fabricated but correctly projected tag satisfies the precondition. This is explicitly documented and ETags are not an auth boundary, so no fix is required; just worth noting for future reviewers.

Note

Yeah, this wasn't worth mentioning. -SF


Specialist Verdicts

Reviewer Verdict
Security No material OWASP/authz/input-validation regressions; only low-risk validator-design tradeoffs.
Functionality PARTIAL — ContentVersion composition and If-Match amendments are largely implemented, but the PR misses a required read-side ETag header and has a descriptor/profile strong-validator gap.
Maintainability Solid direction and good naming, but the ETag contract still leaks into too many call sites, so future format changes will be more expensive than they should be.
Usability Correctness looks improved, but the user-facing failure and migration story around 412s is not clear enough for API consumers.
Test Coverage ADEQUATE — core behavior is well covered, but a few important regression paths remain untested.

Summary

Two High functionality gaps need resolution before merge: GET-by-id responses never emit an ETag HTTP header (breaking RFC 7232 cache correctness for reads), and descriptors that are profile-projected can share the same strong validator across representations. There are also three Medium usability items around the breaking etag-format change that should be addressed — at minimum, clear release notes and an improved 412 error body to distinguish format-mismatch from concurrency collisions. The security posture is clean and the test suite is solid, with a few targeted gaps (quoted If-Match E2E, schema-epoch precondition test, profiled DELETE) that are straightforward to close.

Note

This summary doesn't count things very well... -SF

Reference: DMS-1252

stephenfuqua added a commit that referenced this pull request Jul 8, 2026
…5, 8–12) (#1099)

* refactor: add EtagValue.TryParse and Separator constant

* refactor: give VariantKey a typed component parser/formatter (finding 5)

* refactor: rename VariantKey builder to FromComponents to avoid member shadow

* test: guard schema-epoch mismatch in the precondition checker (finding 9)

* refactor: EtagMatchProjection delegates to the typed wire parsers (finding 5)

* feat: add IServedEtagComposer service (finding 4)

* feat: add IIfMatchEvaluator service (finding 4)

* chore: register IServedEtagComposer and IIfMatchEvaluator

* refactor: route the precondition checker through the etag services (finding 4)

* refactor: route the write execution resolver through the etag services (finding 4)

* refactor: compose read/write-response etags via IServedEtagComposer (finding 4)

* refactor: route descriptor write compose/compare through the etag services (finding 4)

* fix: update integration read-materializer wrappers to IServedEtagComposer

Fallout from routing RelationalReadMaterializer through IServedEtagComposer;
these integration-test wrapper constructions use target-typed new(...) and were
not caught by the unit-project build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: descriptor served etag varies by readable profile (finding 2)

* feat: add ETagPreconditionFailureReason to precondition-failure results (finding 3)

* feat: tag precondition failures with concurrency vs target-missing reason (finding 3)

* feat: reason-specific 412 body factory (finding 3)

* feat: return reason-specific 412 detail from write handlers (finding 3)

* test: assert distinct 412 body for wildcard-missing target (finding 3)

Adds response-body assertions to etag.feature scenarios 10 and 11 (wildcard
If-Match on a non-existent PUT/DELETE target) asserting the new
target-does-not-exist 412 body. Concurrency scenarios 04/05 are unchanged
(that body stayed byte-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: capture raw quoted ETag and add {IfMatchQuoted} token (finding 8)

* test: quoted If-Match round-trip on PUT and DELETE (finding 8)

* test: profiled/unprofiled DELETE etag parity on PostgreSQL (finding 10)

* test: assert profiled etag changes on hidden-field bump before stale DELETE (finding 10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: profiled/unprofiled DELETE etag parity on SQL Server (finding 10)

* docs: correct stale legacy-content-hash comments on the read materializer (finding 11)

The EtagVariant XML docs on RelationalReadMaterializationRequest and
RelationalReadPageMaterializationRequest claimed a null value falls back to a
legacy content hash, but ComposeEtag throws on a null EtagVariant/MappingSet for
an external response. Reframe both as: required on external-response
materialization; absence is a caller wiring bug; no legacy content-hash path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: debug-log the If-Match decision in the precondition checker (finding 12)

* fix: sanitize the client If-Match tag in the precondition-checker debug log (finding 12)

* feat: debug-log the deferred If-Match decision in the resolver (finding 12)

* feat: debug-log the descriptor If-Match decision (finding 12)

* refactor: guard finding-12 If-Match debug logs with IsEnabled(Debug) (finding 12)

* docs: clarify the resolver missing-target debug message (finding 12)

The currentState-null debug log said "precondition fails" unconditionally, but
the non-wildcard PUT arm returns UpdateFailureNotExists (404 not-exists), not a
412 precondition failure. Reword to "resolving missing-target outcome" so the
message is accurate across all three arms (Post conflict, wildcard-PUT 412,
non-wildcard-PUT 404).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: Improve XML comment; look for last `-` for future resilience

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
stephenfuqua added a commit that referenced this pull request Jul 8, 2026
…5, 8–12) (#1099)

* refactor: add EtagValue.TryParse and Separator constant

* refactor: give VariantKey a typed component parser/formatter (finding 5)

* refactor: rename VariantKey builder to FromComponents to avoid member shadow

* test: guard schema-epoch mismatch in the precondition checker (finding 9)

* refactor: EtagMatchProjection delegates to the typed wire parsers (finding 5)

* feat: add IServedEtagComposer service (finding 4)

* feat: add IIfMatchEvaluator service (finding 4)

* chore: register IServedEtagComposer and IIfMatchEvaluator

* refactor: route the precondition checker through the etag services (finding 4)

* refactor: route the write execution resolver through the etag services (finding 4)

* refactor: compose read/write-response etags via IServedEtagComposer (finding 4)

* refactor: route descriptor write compose/compare through the etag services (finding 4)

* fix: update integration read-materializer wrappers to IServedEtagComposer

Fallout from routing RelationalReadMaterializer through IServedEtagComposer;
these integration-test wrapper constructions use target-typed new(...) and were
not caught by the unit-project build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: descriptor served etag varies by readable profile (finding 2)

* feat: add ETagPreconditionFailureReason to precondition-failure results (finding 3)

* feat: tag precondition failures with concurrency vs target-missing reason (finding 3)

* feat: reason-specific 412 body factory (finding 3)

* feat: return reason-specific 412 detail from write handlers (finding 3)

* test: assert distinct 412 body for wildcard-missing target (finding 3)

Adds response-body assertions to etag.feature scenarios 10 and 11 (wildcard
If-Match on a non-existent PUT/DELETE target) asserting the new
target-does-not-exist 412 body. Concurrency scenarios 04/05 are unchanged
(that body stayed byte-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: capture raw quoted ETag and add {IfMatchQuoted} token (finding 8)

* test: quoted If-Match round-trip on PUT and DELETE (finding 8)

* test: profiled/unprofiled DELETE etag parity on PostgreSQL (finding 10)

* test: assert profiled etag changes on hidden-field bump before stale DELETE (finding 10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: profiled/unprofiled DELETE etag parity on SQL Server (finding 10)

* docs: correct stale legacy-content-hash comments on the read materializer (finding 11)

The EtagVariant XML docs on RelationalReadMaterializationRequest and
RelationalReadPageMaterializationRequest claimed a null value falls back to a
legacy content hash, but ComposeEtag throws on a null EtagVariant/MappingSet for
an external response. Reframe both as: required on external-response
materialization; absence is a caller wiring bug; no legacy content-hash path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: debug-log the If-Match decision in the precondition checker (finding 12)

* fix: sanitize the client If-Match tag in the precondition-checker debug log (finding 12)

* feat: debug-log the deferred If-Match decision in the resolver (finding 12)

* feat: debug-log the descriptor If-Match decision (finding 12)

* refactor: guard finding-12 If-Match debug logs with IsEnabled(Debug) (finding 12)

* docs: clarify the resolver missing-target debug message (finding 12)

The currentState-null debug log said "precondition fails" unconditionally, but
the non-wildcard PUT arm returns UpdateFailureNotExists (404 not-exists), not a
412 precondition failure. Reword to "resolving missing-target outcome" so the
message is accurate across all three arms (Post conflict, wildcard-PUT 412,
non-wildcard-PUT 404).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: Improve XML comment; look for last `-` for future resilience

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@stephenfuqua stephenfuqua force-pushed the etag-content-version branch from 473ade9 to caae22f Compare July 8, 2026 02:28
stephenfuqua added a commit that referenced this pull request Jul 8, 2026
…5, 8–12) (#1099)

* refactor: add EtagValue.TryParse and Separator constant

* refactor: give VariantKey a typed component parser/formatter (finding 5)

* refactor: rename VariantKey builder to FromComponents to avoid member shadow

* test: guard schema-epoch mismatch in the precondition checker (finding 9)

* refactor: EtagMatchProjection delegates to the typed wire parsers (finding 5)

* feat: add IServedEtagComposer service (finding 4)

* feat: add IIfMatchEvaluator service (finding 4)

* chore: register IServedEtagComposer and IIfMatchEvaluator

* refactor: route the precondition checker through the etag services (finding 4)

* refactor: route the write execution resolver through the etag services (finding 4)

* refactor: compose read/write-response etags via IServedEtagComposer (finding 4)

* refactor: route descriptor write compose/compare through the etag services (finding 4)

* fix: update integration read-materializer wrappers to IServedEtagComposer

Fallout from routing RelationalReadMaterializer through IServedEtagComposer;
these integration-test wrapper constructions use target-typed new(...) and were
not caught by the unit-project build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: descriptor served etag varies by readable profile (finding 2)

* feat: add ETagPreconditionFailureReason to precondition-failure results (finding 3)

* feat: tag precondition failures with concurrency vs target-missing reason (finding 3)

* feat: reason-specific 412 body factory (finding 3)

* feat: return reason-specific 412 detail from write handlers (finding 3)

* test: assert distinct 412 body for wildcard-missing target (finding 3)

Adds response-body assertions to etag.feature scenarios 10 and 11 (wildcard
If-Match on a non-existent PUT/DELETE target) asserting the new
target-does-not-exist 412 body. Concurrency scenarios 04/05 are unchanged
(that body stayed byte-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: capture raw quoted ETag and add {IfMatchQuoted} token (finding 8)

* test: quoted If-Match round-trip on PUT and DELETE (finding 8)

* test: profiled/unprofiled DELETE etag parity on PostgreSQL (finding 10)

* test: assert profiled etag changes on hidden-field bump before stale DELETE (finding 10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: profiled/unprofiled DELETE etag parity on SQL Server (finding 10)

* docs: correct stale legacy-content-hash comments on the read materializer (finding 11)

The EtagVariant XML docs on RelationalReadMaterializationRequest and
RelationalReadPageMaterializationRequest claimed a null value falls back to a
legacy content hash, but ComposeEtag throws on a null EtagVariant/MappingSet for
an external response. Reframe both as: required on external-response
materialization; absence is a caller wiring bug; no legacy content-hash path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: debug-log the If-Match decision in the precondition checker (finding 12)

* fix: sanitize the client If-Match tag in the precondition-checker debug log (finding 12)

* feat: debug-log the deferred If-Match decision in the resolver (finding 12)

* feat: debug-log the descriptor If-Match decision (finding 12)

* refactor: guard finding-12 If-Match debug logs with IsEnabled(Debug) (finding 12)

* docs: clarify the resolver missing-target debug message (finding 12)

The currentState-null debug log said "precondition fails" unconditionally, but
the non-wildcard PUT arm returns UpdateFailureNotExists (404 not-exists), not a
412 precondition failure. Reword to "resolving missing-target outcome" so the
message is accurate across all three arms (Post conflict, wildcard-PUT 412,
non-wildcard-PUT 404).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: Improve XML comment; look for last `-` for future resilience

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@stephenfuqua stephenfuqua force-pushed the etag-content-version branch from 1e7d974 to 13f5c47 Compare July 8, 2026 13:57
@stephenfuqua

Copy link
Copy Markdown
Contributor Author

I have addressed Brad's review comment, and all addressable comments in my own big Claude-guided reivew.

@bradbanister

Copy link
Copy Markdown
Contributor

Review

Findings

  • High: Descriptor write response ETags ignore the active profile. Descriptor POST/PUT success paths always compose with ProfileName: null in src/dms/backend/EdFi.DataManagementService.Backend/DescriptorWriteHandler.cs:1418, :1475, and :1587. The descriptor request contract has no profile field, and the repository descriptor branch does not forward BackendProfileWriteContext at src/dms/backend/EdFi.DataManagementService.Backend/RelationalDocumentStoreRepository.cs:111 or :245. Since descriptor profiles with WriteContentType exist, for example src/dms/tests/EdFi.DataManagementService.Tests.E2E/Profiles/ProfileDefinitions.cs:77, a profiled descriptor write can return an unprofiled ETag while a profiled descriptor GET returns a profile-coded ETag. Carry the profile name/context into DescriptorWriteRequest and use it for response ETag composition. If-Match comparison can still stay profile-insensitive per the ADR amendment.

  • High: Descriptor write ETag reads depend on fragile SQL result-set behavior. src/dms/backend/EdFi.DataManagementService.Backend/DescriptorWriteHandler.cs:1932 reads one ContentVersion row and returns without advancing result sets. For MSSQL insert, that row is produced by OUTPUT INSERTED.[ContentVersion] on the first insert at src/dms/backend/EdFi.DataManagementService.Backend/DescriptorWriteHandler.cs:2011, before the descriptor and referential identity inserts at :2017 and :2028. Another review also called out update/upsert SQL emitting UPDATE/MERGE before SELECT ContentVersion at src/dms/backend/EdFi.DataManagementService.Backend/DescriptorWriteHandler.cs:2048 and :2135; the helper should either advance to the result set containing ContentVersion, or the commands should be structured so the row-producing statement is unambiguous. Safer and simpler for insert: capture DocumentId/ContentVersion in a table variable, run all inserts, then make the final statement SELECT [ContentVersion].

  • Medium: Generic writes still do a per-write locked ContentVersion read. src/dms/backend/EdFi.DataManagementService.Backend/RelationalWriteNoProfilePersister.cs:107 reads ContentVersion after all mutations via src/dms/backend/EdFi.DataManagementService.Backend/RelationalDocumentLockCommandBuilder.cs:14. This is much cheaper than the old hydrate/hash readback, but it is still a per-write DB round trip and lock, which falls short of the Jira/ADR performance goal of deriving the ETag from values already returned by write DML. If trigger timing forces this, document it explicitly as the accepted compromise.

  • Medium: GET-by-id still returns no ETag header. src/dms/core/EdFi.DataManagementService.Core/Handler/GetByIdHandler.cs:123 creates success responses with Headers: [], so the frontend quoting logic never runs for GET. This appears tracked by linked DMS-1266, but it means DMS-1252’s “body and HTTP headers” acceptance is not complete in this branch.

  • Medium: Code and docs disagree on profileCode. src/dms/backend/EdFi.DataManagementService.Backend/Etag/ProfileVariantCode.cs:20 uses an 8-hex SHA-256 prefix of the profile name, while reference/design/backend-redesign/design-docs/update-tracking.md:200 and reference/adr-etag-from-content-version.md:147 still say stable compile-time index / no hashing. Pick one contract and align the docs/code.

  • Low: Malformed ETags with empty ignored components can match. src/dms/backend/EdFi.DataManagementService.Backend/Etag/VariantKey.cs:45 only checks for four dot-delimited parts. Since src/dms/backend/EdFi.DataManagementService.Backend/Etag/EtagMatchProjection.cs:36 compares only ContentVersion-schemaEpoch, a value like 5-a1b2c3d4... can project to the same value as a well-formed current ETag. Either validate non-empty/known components or update comments/tests to say malformed ignored components are tolerated.

  • Low: Dead/over-small ETag helpers can be collapsed. src/dms/backend/EdFi.DataManagementService.Backend/Etag/DescriptorVariantKey.cs:14 has no production callers now; rg shows only test references, and descriptor reads/writes use IServedEtagComposer. Its comment also says descriptors have no readable profile, which is stale. Similarly, src/dms/backend/EdFi.DataManagementService.Backend/Etag/EtagComposer.cs:11 is just EtagValue.Compose behind an interface used by ServedEtagComposer. Move the descriptor expected-value helper into tests and let ServedEtagComposer compose directly.

  • Low: RelationalCommittedRepresentationReader builds a fake JSON object just to extract _etag. src/dms/backend/EdFi.DataManagementService.Backend/RelationalCommittedRepresentationReader.cs:57 returns a JsonObject containing only _etag, and src/dms/backend/EdFi.DataManagementService.Backend/RelationalWriteExecutorResults.cs:346 immediately unwraps it. Since DMS-1252 intentionally removed committed document readback, this can be a Task<string> or small typed result instead of JSON allocation plus validation.

  • Low: Stale comments still describe hash-era/link-insensitive ETags. src/dms/backend/EdFi.DataManagementService.Backend.Plans/DocumentReconstituter.cs:43 says _etag is link-decoration-independent and mentions stripping before hashing, but this branch deliberately varies served ETags by link mode. src/dms/backend/EdFi.DataManagementService.Backend/Etag/ServedEtagComposer.cs:9 also says descriptor callers pass ProfileName: null, which is no longer true for profiled descriptor reads.

Simplicity Notes

  • The PG/MSSQL profile/link ETag integration tests are heavily mirrored. That may be acceptable given existing provider-specific test style, but it is duplicated surface area.
  • I treated the ADR amendments in this branch as authoritative, so profile-insensitive If-Match comparison is not listed as a Jira mismatch.

Review-of-review

I’d treat the first two as real blockers.

The strongest findings are:

  • Descriptor write ETags ignore the active profile. Confirmed. DescriptorWriteRequest does not carry BackendProfileWriteContext, and the descriptor repository branch drops it before calling the handler. The write success paths compose with ProfileName: null at DescriptorWriteHandler.cs, DescriptorWriteHandler.cs, and DescriptorWriteHandler.cs. I’d also add the no-op paths: they return currentEtag, which is composed with ProfileName: null at DescriptorWriteHandler.cs. This is especially valid because descriptor reads now do profile-sensitive composition.

  • Descriptor write SQL/result-set handling is fragile and likely wrong for updates. Confirmed. The helper reads the first available row and never advances result sets. The MSSQL insert emits OUTPUT INSERTED.[ContentVersion] before the descriptor and referential identity inserts, so early return/disposal depends on provider behavior. The update/upsert batches start with UPDATE/MERGE and only later SELECT ContentVersion; those should not rely on ReadAsync() landing on the final row-producing statement. Make the row-producing statement unambiguous, or make the helper advance to the result set containing ContentVersion.

I also agree with these, with nuance:

  • Generic writes still do a locked scalar read. True. It is not the old hydrate/hash readback, but it is still a per-write DB round trip and lock at RelationalWriteNoProfilePersister.cs. I’d keep it as a Jira/ADR performance caveat unless the team explicitly accepts trigger timing as the reason.

  • GET-by-id has no ETag header. True at GetByIdHandler.cs. Since DMS-1266 is linked, this may be a known split, but DMS-1252’s “body and HTTP headers” acceptance is not complete in this branch.

  • profileCode docs/code mismatch. True. Code uses an 8-hex SHA-256 prefix of the profile name, while the ADR/design docs still specify a stable compile-time index and “no hashing required.” Pick one contract.

  • Malformed ignored components can match. True, low severity. VariantKey.TryParseComponents only checks component count, so empty format/profile/link components can be projected away by EtagMatchProjection. Not a lost-update risk, but it contradicts the “malformed never matches” comments.

The remaining lows are fair cleanup notes rather than blockers: DescriptorVariantKey is now test-only/stale, EtagComposer is thin, RelationalCommittedRepresentationReader returns a fake JSON object just to unwrap _etag, and comments in DocumentReconstituter / ServedEtagComposer still describe the old link-insensitive/hash-era behavior.

One extra note: Jira DMS-1252 itself still contains older If-Match wording that includes profileCode; the amended ADR removes it. I agree with the reviewer’s choice to treat the ADR amendments as authoritative.

@bradbanister

bradbanister commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Complexity Review

The underlying feature is simple:

served ETag = ContentVersion + schema/profile/format/link variant
If-Match = compare ContentVersion + schema epoch

The branch implements that idea with more moving parts than the idea probably needs. Some size is justified by PG/MSSQL, descriptors, profiles, docs, and tests. But the complexity is showing up as real risk: descriptor write ETags drift from descriptor read ETags, SQL result-set ordering matters, and there are helper layers that no longer buy much.

The main over-complicated areas are:

  • The ETag helper stack is too fragmented: EtagComposer, ServedEtagComposer, DescriptorVariantKey, EtagValue, VariantKey, ProfileVariantCode, EtagMatchProjection, IfMatchEvaluator. Most of that could probably collapse into one small EtagService plus value/parser helpers.

  • Descriptors have a parallel path that almost duplicates generic write ETag behavior, but misses profile context. That is a classic sign the abstraction boundary is wrong. Descriptor writes should reuse the same “build served ETag from request context + ContentVersion” path as regular writes.

  • RelationalCommittedRepresentationReader still models an ETag-only result as a fake JSON response. That preserves the old readback shape after the branch removed readback, which adds indirection without value.

  • SQL commands are doing too much implicit result-set choreography. A final explicit SELECT ContentVersion after all writes would be less clever and easier to reason about.

  • The profile code contract is split between docs and implementation. That is not necessarily overengineering by itself, but it means the branch has too many places defining the same concept.

I would not call the whole implementation unreasonably large, because DMS has real cross-cutting behavior here. But I would call it more complicated than necessary. The safest simplification would be to centralize ETag composition/projection, make write paths return a typed ETag/string instead of JSON, remove dead descriptor-specific helpers, and standardize the “final ContentVersion” SQL pattern. That should reduce code while also addressing several correctness findings.

stephenfuqua added a commit that referenced this pull request Jul 8, 2026
An intermediate refactor dropped the response reader's SELECT and composed
the write-response etag from the persist-time (root INSERT ... RETURNING)
ContentVersion, per Option 4's "zero marginal cost" assumption. PR #1091
review found that value is stale once child-table deletes/upserts fire
stamp triggers that bump the owning root document's ContentVersion.

Add a 2026-07-08 amendment documenting the reason the final ContentVersion
read was restored and relocated into the persister (read after every table
mutation, returned as persistence metadata on RelationalWritePersistResult),
so the write etag matches a follow-up GET. The read is a lightweight lookup
in the correct layer, not the hydrate-materialize-hash readback the ADR
eliminated, and leaves a clear path to reclaiming the round trip later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@stephenfuqua

Copy link
Copy Markdown
Contributor Author

Follow-up on the review comments

Verified each point against the code. Six items landed; four I'm pushing back on with reasoning below.

Implemented

1. Profile-code descriptor write-response ETags (efaa4f5f, test 13f5c476)
Descriptor writes composed their response _etag with ProfileName: null, so a profiled descriptor write returned an unprofiled etag that wouldn't match a profiled GET of the same representation — asymmetric with the non-descriptor write path. Threaded ProfileName through DescriptorWriteRequestDescriptorWriteHandler at every success and current-etag site (including the no-op path). If-Match stays profile-insensitive: EtagMatchProjection projects profileCode out, so this is a served-representation fix only.

2. MSSQL descriptor INSERT surfaces ContentVersion via a trailing SELECT (4b498a8b)
Restructured the command so the row-producing statement is unambiguously last: insert-time ContentVersion is captured with OUTPUT INSERTED.[ContentVersion] INTO @insertedContentVersion, the remaining child inserts run, then a final SELECT [ContentVersion] FROM @insertedContentVersion. Removes reliance on implicit result-set ordering; matches the PG data-modifying-CTE shape.

3. ADR: why the final ContentVersion read was restored into the persister (4de2278a)
Documented that child-table stamp triggers bump the root ContentVersion, so the committed version is only knowable after all mutations. The restored read is a lightweight scalar lookup owned by the persister — not the hydrate/materialize/hash readback the ADR eliminated for performance.

4. ADR: profileCode is a SHA-256 prefix of the profile name (22ce3485)
Reconciled the ADR with the implementation: profileCode is the first 8 lowercase-hex chars of SHA-256(profileName) (a MappingSet exposes no enumerable catalog for stable ordinals). Clarified this doesn't violate the ADR's core decision — it hashes the profile name (a tiny static descriptor), never the representation JSON, so it can't reintroduce the readback cost.

5. Dropped the IEtagComposer DI seam (f0b4468d)
IEtagComposer/EtagComposer was an injectable interface wrapping a pure string-format helper that would never be substituted or mocked — the seam bought nothing. Removed the interface, the instance, and the DI registration. EtagComposer stays as a static helper because the (ContentVersion, VariantKey) → opaque wire value bridge can't move to Core: EtagValue (Core) can't reference the backend-only VariantKey type. ServedEtagComposer now delegates to the static and has no constructor dependency.

6. Write path returns the etag as a string, not a wrapped JsonNode (39a6bba0)
RelationalCommittedRepresentationReader composed the _etag string, boxed it into a { "_etag": … } JsonObject, returned Task<JsonNode> — and the sole consumer immediately unwrapped it back to a string via GetCommittedResponseEtag, whose validation could only fail if the code were inconsistent with itself. That was the old hydrate/readback response shape left behind after the readback was removed. Reader now returns Task<string>; result builders take the etag directly (guarded by RequireEtag); GetCommittedResponseEtag deleted. No behavior change.

Not changed, with reasoning

"Collapse the ~8-type helper stack into one EtagService" — overstated. The structure already matches the suggested target: ServedEtagComposer is the single compose service (all three call sites use it), and EtagValue/VariantKey are the value/parser helpers the review concedes are fine. IfMatchEvaluator + EtagMatchProjection are the comparison side; RFC 7232 deliberately separates strong-validator generation from If-Match matching (which projects out format/profile/link). Folding compose + match into one class yields two unrelated method groups sharing no state — grouping by noun, not cohesion.

"Descriptors should reuse the generic build-served-etag path" — they already do: DescriptorWriteHandler, the generic write reader, and DescriptorReadHandler all compose via ServedEtagComposer.Compose(ServedEtagContext). The "misses profile context" gap was item 1 above. What remains parallel is handler orchestration — descriptors persist into the shared dms.Descriptor table with distinct SQL — which is intrinsic, not a wrong abstraction.

"Standardize the final ContentVersion SQL pattern" — already in place (see items 2–3). MSSQL descriptor insert ends with SELECT [ContentVersion] FROM @insertedContentVersion; PG uses SELECT "ContentVersion" FROM new_doc; generic writes read it post-mutation in the persister. The explicit final-ContentVersion pattern is the pattern.

"Profile-code contract split between docs and impl" — not duplicate definitions. Single implementation (ProfileVariantCode.Of); the ADR describes it and was reconciled in item 4. Docs describing code isn't a second source of truth.

DescriptorVariantKey is production-dead but retained for the write-side tests, where a descriptor's represented etag genuinely is always profile-insensitive — a deliberate choice recorded in the ADR.

@bradbanister

Copy link
Copy Markdown
Contributor

Re-review

Findings

  • High: Descriptor update/upsert ETag reads still depend on first-result-set behavior. DescriptorWriteHandler.cs calls ReadAsync once and never advances result sets, but the update builders emit UPDATE/MERGE before the final SELECT ContentVersion at DescriptorWriteHandler.cs, DescriptorWriteHandler.cs, and DescriptorWriteHandler.cs. Make the helper scan result sets until it finds ContentVersion, or structure every command so the row-producing statement is the first exposed result set.

  • Medium: Non-descriptor DELETE If-Match still hydrates current state just to compare ETags. RelationalDocumentStoreRepository.cs already has the locked ContentVersion, but RelationalDocumentStoreRepository.cs / :585 calls the delete precondition checker with a full read plan, and RelationalCurrentEtagPreconditionChecker.cs / :132 locks and loads current state. DELETE only needs existence plus locked ContentVersion and schema epoch; RelationalWriteTargetLocking.cs already has the scalar helper.

  • Medium: Normative docs conflict with implementation. update-tracking.md still says no DB readback/no DB dependency, while the ADR and code now accept a final scalar ContentVersion read. update-tracking.md still says profileCode is a compile-time index, while code uses an 8-hex SHA-256 profile-name prefix. adr-etag-from-content-version.md still says descriptor write-response ETags remain unprofiled, contradicting the current fixed code.

  • Low: Malformed variant keys with empty ignored components can satisfy If-Match. VariantKey.cs only checks for four dot-delimited parts, so 5-a1b2c3d4... projects to the same 5-a1b2c3d4 as a well-formed current tag via EtagMatchProjection.cs. This is not a lost-update risk, but it contradicts the “malformed tag never matches” contract at EtagMatchProjection.cs. Validate non-empty/known components or document the tolerance.

  • Low: RelationalCommittedRepresentationReader no longer reads a committed representation. RelationalCommittedRepresentationReader.cs is now an async interface around ServedEtagComposer plus ResourceLinksOptions, returning Task.FromResult. Collapse it into the write executor/result builder path to remove the old readback-era abstraction.

  • Low: RelationalCurrentEtagPreconditionCheckResult.CurrentEtag is unused production state. RelationalCurrentEtagPreconditionChecker.cs returns it, but callers only use TargetContext, CurrentState, and IsMatch.

Simplicity Notes

One small remaining simplification: ProfileVariantCode.Of hashes the same profile name per document during query materialization through ServedEtagComposer.cs. It is cheap, but it could be precomputed per request or cached per profile name.

@stephenfuqua

Copy link
Copy Markdown
Contributor Author

Review follow-ups addressed

Worked through the review comments on this branch. Six commits, net −302 lines (24 files, +268/−570). All changes verified by build + tests; PostgreSQL integration tests run against a real database via a throwaway container.

ℹ️ These commits are local and not yet pushed — the SHAs below will change once the branch is pushed.

1. a34adacb — Descriptor write ContentVersion result-set scan

The single-ReadAsync helper relied on the trailing SELECT "ContentVersion" being the first exposed result set ahead of the batch UPDATE/INSERT/MERGE. Verified this already holds on both Npgsql and SqlClient (targeted PG integration test passed as-is), then added a defensive do { ReadAsync } while (NextResultAsync) scan so it no longer depends on driver result-set ordering. Behavior unchanged; the ordering assumption is gone.

2. 5240eabc — Non-descriptor DELETE If-Match no longer hydrates

The DELETE If-Match precondition re-locked the target and hydrated full current state just to read back a ContentVersion the caller had already captured when it locked the row. Replaced the async CheckAsync (read plan + lock + load) with a synchronous Evaluate that composes the etag from the locked ContentVersion — no re-lock, no hydration, no read plan. The now-dead null-return branch and the delete read-plan guard were removed (existence/concurrency are settled upstream by the resolve/lock). Guarded-session integration assertions now expect a single document lock (lock → authorize → delete).

3. 88dcc9b5 — Reconcile normative etag docs with the shipped code

  • update-tracking.md: "no DB readback" → "no document hashing / no representation readback," noting the write path's single lightweight scalar ContentVersion read.
  • update-tracking.md: profileCode "compile-time index" → the shipped _ or 8-hex SHA-256(profileName) prefix.
  • adr-etag-from-content-version.md: superseding note that descriptor write-response etags are now profile-sensitive (fix efaa4f5f).
  • transactions-and-concurrency.md: aligned the same "no readback" wording.

4. 1b3ef729 — Clarify EtagMatchProjection's malformed-tag contract

The doc claimed "a malformed tag never matches," but If-Match compares only ContentVersion + schemaEpoch (per the 2026-07-04 amendment) and does not validate the ignored positions — so a four-part tag with empty/unrecognized ignored components matches, with no lost-update risk. Documented the tolerance as intentional (validating would re-couple If-Match to the selectors the amendment decoupled) and pinned it with a test using the reviewer's example.

5. f1187029 — Collapse RelationalCommittedRepresentationReader

Once the readback was gone this was an async interface wrapping only IServedEtagComposer + ResourceLinksOptions, returning Task.FromResult. Inlined it into DefaultRelationalWriteExecutor as a synchronous private helper; deleted the interface, implementation, DI registration, and dedicated test; converted the executor tests to assert the real composed etag instead of stubbing the seam. Net −259 lines.

6. f39be352 — Drop unused CurrentEtag from the precondition result

The only production consumer reads TargetContext/CurrentState/IsMatch; the returned CurrentEtag was test-only. Removed the field (the checker still composes it locally as the If-Match evaluator input) and the corresponding test assertions.

Verification

  • Backend + all affected test projects build clean (0 warnings).
  • 1889/1889 backend unit tests pass; relevant Core/Frontend unit tests pass.
  • PostgreSQL integration (real DB): descriptor-write, delete-authorization/by-id, cascade, profiled/unprofiled/wildcard If-Match, and authoritative-sample smoke fixtures all pass. MSSQL integration compiles; equivalent assertions updated in lockstep (CI runs SQL Server).

Scope notes for reviewers

  • Commits 1 and 4 are hardening/documentation over behavior that was already correct — treated as clarifications rather than fixes, confirmed via tests.
  • Commit 2 removed the DELETE read-plan guard (a "resource has no read plan → UnknownFailure" canary). DELETE never reads, so this is intended — flag it if you'd rather keep the canary.

🤖 AI-assisted: substantial portions of these changes (notably the DefaultRelationalWriteExecutorTests conversion) were generated with Claude Code and verified by build/test. Human review before merge is recommended.

@stephenfuqua

Copy link
Copy Markdown
Contributor Author

Added E2E coverage for the served-ETag contract

Two E2E scenarios in Features/Resources/etag.feature, exercising the DMS-1252 contract at the API boundary rather than only asserting "some ETag exists." Both are tagged @e2e-ci-shard-1, so CI runs them.

ℹ️ The branch has now been pushed. The six earlier commits kept their SHAs (a plain push, no rebase), so the SHAs in my previous comment are valid on the remote. The two commits below are new.

a6baab48 — ETag shape + header/body parity (Scenario 14)

POST a resource, then assert:

  • the response header is a quoted strong validator (RFC 7232 §2.3);
  • the opaque value matches ^\d+-[0-9a-f]{8}\.j\.(_|[0-9a-f]{8})\.[ln]$, i.e. {ContentVersion}-{schemaEpoch}.{format}.{profileCode}.{linkFlag};
  • the unquoted body _etag from a follow-up GET equals the quote-stripped header value.

Adds a reusable Then the ETag value matches the pattern {string} step (regex kept in the feature file so the served shape is self-documenting). Pattern cross-checked against VariantKey/ServedEtagComposer and their unit tests.

65f2c453 — Child-collection mutation advances the ETag (Scenario 15)

Directly covers the ADR decision to restore the post-write ContentVersion read so the write response reflects the trigger-stamped version rather than a stale one:

  • POST a studentEducationOrganizationAssociation with one addresses element → capture the ETag;
  • PUT changing only a child-collection value (address city) → 204, then assert the served ETag advanced;
  • replay the now-stale ETag as If-Match412;
  • current ETag as If-Match204.

Uses the descriptor/student/school setup already proven in ArrayUniquenessValidation.feature, so the child collection is guaranteed populatable. Adds the ETag is stored in request variable / the ETag differs from request variable steps and teaches the existing PUT if-match step to resolve a stored-variable placeholder so a stale validator can be replayed.

Notes for reviewers

  • Verified against the handlers: POST and PUT (UpsertHandler, UpdateByIdHandler) emit the etag header, but GET-by-id does not (body _etag only). Scenario 15 therefore compares POST- vs PUT-response ETags directly instead of via GET — which also makes it a direct test of the write-response ContentVersion.
  • I could not reuse the equivalent header/variable steps in ProfileStepDefinitions: that class keeps its own _apiResponse and a separate _scenarioVariables instance, so it doesn't share state with the main step definitions.
  • Both scenarios build clean; I did not execute the full E2E stack locally (API + config service + auth + DB + Playwright) — they run in CI.

🤖 AI-assisted: these scenarios and step definitions were generated with Claude Code and verified by build + code inspection. Human review before merge is recommended.

stephenfuqua and others added 7 commits July 8, 2026 17:32
Adds the reviewer-approved superpowers implementation plan for deriving
_etag from ContentVersion + a representation variantKey, plus the source
ADR and staged design-doc edits it references. Plan and ADR are DRAFT
pending development-team review; no code changed.

Drafted with AI assistance (Claude Opus 4.8); human review required
before acting on the plan.

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

If-Match compares only the state-significant projection of the etag
(ContentVersion, schemaEpoch, profileCode); format and linkFlag are
excluded, so link/format differences no longer 412 while a cross-profile
If-Match still returns 412. Updates the ADR's "If-Match comparison"
section (now decided) and the matching companion design-doc wording.

Drafted with AI assistance (Claude Opus 4.8); human review required
before acting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve etags

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stephenfuqua and others added 29 commits July 8, 2026 17:32
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds 5 wildcard fixtures per engine (PUT/DELETE existing -> success,
PUT/DELETE missing -> 412, POST-insert -> 412). Running these revealed a
gap: a genuinely-missing PUT target resolves not-exists early in
RelationalDocumentStoreRepository.ResolveTargetContextAsync, before the
write executor runs, so it bypassed the B3 missing-target override and
returned 404 for a wildcard. Threads the write precondition into that
resolution and returns 412 for a wildcard, matching RFC 7232. Adds a fast
unit regression for the repository-level path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…design docs

Update transactions-and-concurrency.md and update-tracking.md to describe the
2026-07-05 amendments: bare unquoted If-Match: * as an RFC 7232 wildcard
existence precondition (412 on missing PUT/DELETE target and POST-insert), and
acceptance of unquoted If-Match values as equivalent to quoted (emission stays
quoted; W/ weak tags still rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RelationalWriteNoProfilePersister.PersistAsync now issues a trailing SELECT
of dms.Document.ContentVersion (via RelationalDocumentLockCommandBuilder)
after all deletes and upserts, and populates the previously-defaulted
ContentVersion field on RelationalWritePersistResult. Reader-side behavior
is unchanged; this only makes the value available on the result.
Mirrors the existing DocumentId <= 0 guard in RelationalWritePersistedTargetValidator
so a future persister change that forgets to set a positive ContentVersion fails loudly
instead of silently composing an etag from version 0.
…5, 8–12) (#1099)

* refactor: add EtagValue.TryParse and Separator constant

* refactor: give VariantKey a typed component parser/formatter (finding 5)

* refactor: rename VariantKey builder to FromComponents to avoid member shadow

* test: guard schema-epoch mismatch in the precondition checker (finding 9)

* refactor: EtagMatchProjection delegates to the typed wire parsers (finding 5)

* feat: add IServedEtagComposer service (finding 4)

* feat: add IIfMatchEvaluator service (finding 4)

* chore: register IServedEtagComposer and IIfMatchEvaluator

* refactor: route the precondition checker through the etag services (finding 4)

* refactor: route the write execution resolver through the etag services (finding 4)

* refactor: compose read/write-response etags via IServedEtagComposer (finding 4)

* refactor: route descriptor write compose/compare through the etag services (finding 4)

* fix: update integration read-materializer wrappers to IServedEtagComposer

Fallout from routing RelationalReadMaterializer through IServedEtagComposer;
these integration-test wrapper constructions use target-typed new(...) and were
not caught by the unit-project build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: descriptor served etag varies by readable profile (finding 2)

* feat: add ETagPreconditionFailureReason to precondition-failure results (finding 3)

* feat: tag precondition failures with concurrency vs target-missing reason (finding 3)

* feat: reason-specific 412 body factory (finding 3)

* feat: return reason-specific 412 detail from write handlers (finding 3)

* test: assert distinct 412 body for wildcard-missing target (finding 3)

Adds response-body assertions to etag.feature scenarios 10 and 11 (wildcard
If-Match on a non-existent PUT/DELETE target) asserting the new
target-does-not-exist 412 body. Concurrency scenarios 04/05 are unchanged
(that body stayed byte-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: capture raw quoted ETag and add {IfMatchQuoted} token (finding 8)

* test: quoted If-Match round-trip on PUT and DELETE (finding 8)

* test: profiled/unprofiled DELETE etag parity on PostgreSQL (finding 10)

* test: assert profiled etag changes on hidden-field bump before stale DELETE (finding 10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: profiled/unprofiled DELETE etag parity on SQL Server (finding 10)

* docs: correct stale legacy-content-hash comments on the read materializer (finding 11)

The EtagVariant XML docs on RelationalReadMaterializationRequest and
RelationalReadPageMaterializationRequest claimed a null value falls back to a
legacy content hash, but ComposeEtag throws on a null EtagVariant/MappingSet for
an external response. Reframe both as: required on external-response
materialization; absence is a caller wiring bug; no legacy content-hash path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: debug-log the If-Match decision in the precondition checker (finding 12)

* fix: sanitize the client If-Match tag in the precondition-checker debug log (finding 12)

* feat: debug-log the deferred If-Match decision in the resolver (finding 12)

* feat: debug-log the descriptor If-Match decision (finding 12)

* refactor: guard finding-12 If-Match debug logs with IsEnabled(Debug) (finding 12)

* docs: clarify the resolver missing-target debug message (finding 12)

The currentState-null debug log said "precondition fails" unconditionally, but
the non-wildcard PUT arm returns UpdateFailureNotExists (404 not-exists), not a
412 precondition failure. Reword to "resolving missing-target outcome" so the
message is accurate across all three arms (Post conflict, wildcard-PUT 412,
non-wildcard-PUT 404).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: Improve XML comment; look for last `-` for future resilience

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The e2e assertion required a profile-projected descriptor read's _etag to
equal the full (non-profile) etag. That expectation dates to the content-hash
era (#947) and contradicts the composed-etag contract on this branch, where a
profile-projected representation carries a distinct, profile-sensitive etag
(RFC 7232 strong-validator semantics). This mirrors the resource contract in
ProfileFiltering scenario 07 and is covered by DescriptorReadHandlerTests
(It_applies_readable_profile_projection_and_varies_the_etag_by_profile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Descriptor POST/PUT success paths (and the no-op current-etag path)
always composed the response ETag with ProfileName: null, so a profiled
descriptor write returned an unprofiled ETag while a profiled descriptor
GET returns a profile-coded one. The repository descriptor branches also
dropped BackendProfileWriteContext entirely.

Thread the profile name through DescriptorWriteRequest, forward it from
both repository descriptor branches, and compose every descriptor write
response ETag (insert, upsert-update, PUT update, and the no-op current
etag) with it. If-Match comparison stays profile-insensitive per the
2026-07-04 ADR amendment because EtagMatchProjection projects profileCode
out, so the current etag can safely carry the profile code.

Adds unit coverage for profile-coded insert/update/no-op ETags and
Postgresql + Mssql integration tests proving profiled write ETags match a
follow-up profiled GET and differ from the unprofiled representation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…iling SELECT

BuildMssqlInsertCommand put the row-producing statement first — OUTPUT
INSERTED.[ContentVersion] on the Document insert, followed by the
descriptor and referential-identity inserts. The reader read that first
result set and returned, relying on the whole batch executing server-side
after the OUTPUT row was consumed rather than on the row-producing
statement being unambiguous.

Capture the insert-time ContentVersion into a table variable via
OUTPUT ... INTO, run every insert, then return it with a trailing SELECT,
so the final statement is the one that produces the reader's single result
set — matching the PostgreSQL insert CTE and every UPDATE/upsert builder.
Behavior is unchanged; the descriptor write integration tests exercise the
insert path end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An intermediate refactor dropped the response reader's SELECT and composed
the write-response etag from the persist-time (root INSERT ... RETURNING)
ContentVersion, per Option 4's "zero marginal cost" assumption. PR #1091
review found that value is stale once child-table deletes/upserts fire
stamp triggers that bump the owning root document's ContentVersion.

Add a 2026-07-08 amendment documenting the reason the final ContentVersion
read was restored and relocated into the persister (read after every table
mutation, returned as persistence metadata on RelationalWritePersistResult),
so the write etag matches a follow-up GET. The read is a lightweight lookup
in the correct layer, not the hydrate-materialize-hash readback the ADR
eliminated, and leaves a clear path to reclaiming the round trip later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The variantKey spec described profileCode as a stable compile-time integer
index within the MappingSet, but the implementation (ProfileVariantCode.Of)
derives it as an 8-hex SHA-256 prefix of the readable profile name (or "_"
when unprofiled) — a MappingSet exposes no enumerable profile catalog for
stable ordinals.

Add a 2026-07-08 amendment plus an inline note on the variantKey encoding
recording the shipped form and affirming it upholds the ADR's core
decision: hashing the tiny, static profile-name descriptor is not hashing
the representation JSON. It needs no readback and no per-document work, so
it cannot reintroduce the write-path bottleneck the ADR removed; the etag's
state signal still comes entirely from ContentVersion, and profileCode is
projected out of If-Match anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the IEtagComposer interface, its injectable implementation, and its
DI registration. The interface existed only as a substitution seam over a pure
string-formatting helper that would never be mocked, so it bought nothing.

EtagComposer stays as a static helper because the (ContentVersion, VariantKey)
-> opaque wire value bridge cannot live in Core: EtagValue (Core) cannot
reference the backend-only VariantKey type. ServedEtagComposer now delegates to
the static and no longer takes a constructor dependency; all call sites updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…JsonNode

RelationalCommittedRepresentationReader composed the served _etag as a string,
boxed it into a { "_etag": ... } JsonObject, and returned Task<JsonNode>; the
sole consumer (RelationalWriteExecutorResults) immediately unwrapped it back to
a string via GetCommittedResponseEtag, whose validation could only fail if this
code were inconsistent with itself. That JsonNode round-trip was the old
hydrate/readback response shape left behind after the readback was removed.

The reader now returns Task<string> (the opaque etag). The result builders take
the etag directly and guard non-empty via RequireEtag; GetCommittedResponseEtag
is deleted. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The descriptor write etag helper read only the first result set, so it
relied on the trailing SELECT "ContentVersion" being the first result set
the driver exposes ahead of the batch UPDATE/INSERT/MERGE statements. That
holds for Npgsql and SqlClient today, but is an implicit dependency on
driver result-set ordering.

Scan result sets until the first row instead, stopping at the ContentVersion
the trailing SELECT produces, matching the do/while NextResultAsync pattern
already used in RelationalDeleteExecution. Behavior is unchanged on both
dialects; this only removes the ordering assumption.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DELETE If-Match precondition re-locked the target and hydrated its full
current state (with descriptor projection) only to read back a ContentVersion
the caller had already captured when it locked the row. DELETE serves no body
and applies no profile lens, so the current etag needs only that locked
ContentVersion plus the schema epoch.

Replace the delete precondition's async CheckAsync (read plan + lock + load)
with a synchronous Evaluate that composes the served etag from the locked
ContentVersion and evaluates If-Match — no re-lock, no hydration, no read plan.
Existence and concurrency are already settled by the repository's upfront
resolve/lock, so the result is never null; drop the now-dead null branch and
the delete read-plan preparation/guard.

The guarded-session integration assertions now expect a single document lock
(lock -> authorize -> delete) rather than a post-authorization second lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
update-tracking.md still described three things the code no longer does:

- "no database readback / no database dependency" for _etag — now clarified to
  "no document hashing and no representation readback," noting the write path
  reads a single lightweight scalar ContentVersion in the persistence layer
  (per the ADR's 2026-07-08 final-ContentVersion-read amendment). The eliminated
  hydrate-materialize-hash readback stays eliminated.
- profileCode as a "compile-time index" — corrected to the shipped encoding:
  "_" or the first 8 lowercase hex chars of SHA-256(UTF-8(profileName)), per
  ProfileVariantCode.Of and the ADR's 2026-07-08 profileCode-hash amendment.

adr-etag-from-content-version.md still said descriptor write-response ETags
"remain unprofiled." A superseding 2026-07-08 note records that
DescriptorWriteHandler now composes every success-response etag with the
request's ProfileName (fix efaa4f5), so a profiled write returns the same
profile-variant _etag a profiled GET serves; If-Match stays profile-insensitive.

transactions-and-concurrency.md carried the same "computed with no readback"
claim and is aligned to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EtagMatchProjection's summary claimed "a malformed tag yields a sentinel that
cannot equal any well-formed projection," but If-Match compares only
ContentVersion + schemaEpoch (2026-07-04 amendment) and does not validate the
three ignored variantKey positions. So a four-part tag with empty or
unrecognized ignored components (e.g. "5-a1b2c3d4...") projects to the same
"5-a1b2c3d4" as a well-formed tag and matches — contradicting the stated
contract, though with no lost-update risk.

Document the tolerance rather than validate the components: validation would
re-couple If-Match to the format/profile/link selectors the 2026-07-04
amendment deliberately decoupled (breaking cross-format/profile/link If-Match)
and would add no safety, since a match still requires the correct
ContentVersion and schemaEpoch.

- EtagMatchProjection: define "malformed" as structural only, and spell out
  that the ignored positions are intentionally not validated.
- VariantKey.TryParseComponents: note it validates part count, not values.
- Add a test pinning the tolerance (the reviewer's "5-a1b2c3d4..." example
  matches; a wrong epoch/version still does not).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rite executor

Once the etag readback was eliminated, IRelationalCommittedRepresentationReader
was a readback-era abstraction that no longer read anything: an async interface
whose implementation wrapped only IServedEtagComposer + ResourceLinksOptions and
returned Task.FromResult over a pure string composition.

Inline it into DefaultRelationalWriteExecutor as a synchronous private
ComposeCommittedEtag helper. The executor already had IServedEtagComposer; it now
also takes IOptions<ResourceLinksOptions> for the served linkFlag. Both write
success paths (applied write and guarded no-op) compose the etag directly from
the persisted/observed ContentVersion, with no dms.Document query, hydrate, or
hashing.

Delete the interface, its implementation, its DI registration, and its dedicated
test. Update the executor tests to assert the real composed etag instead of
stubbing the reader, and drop the three DI-assertion probes for the removed
service. Reconcile the ContentVersion ADR's 2026-07-08 amendment, which named the
now-removed reader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ionCheckResult

The sole production consumer of the result (RelationalWriteExecutionStateResolver)
reads only TargetContext, CurrentState, and IsMatch; the composed CurrentEtag was
returned but never used outside tests.

Remove the field from the record. The checker still composes the current served
etag locally — it is the input to the If-Match evaluation and the debug log — but
no longer surfaces it as dead result state. Drop the corresponding test assertions
and the now-unused currentEtag parameter/argument in the executor test helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scenario 14 verifies the API-boundary contract rather than merely that
some ETag exists: the response header is a quoted strong validator, the
opaque value matches "{ContentVersion}-{schemaEpoch}.{format}.{profileCode}.{linkFlag}",
and the unquoted body _etag from a follow-up GET equals the quote-stripped
header value. Adds a reusable "the ETag value matches the pattern" step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scenario 15 proves the ADR concern that the write response reflects the
post-write ContentVersion after a child-table mutation: it POSTs a
studentEducationOrganizationAssociation, changes only an address (child
collection) value via PUT, and asserts the served ETag advanced, that the
now-stale ETag is rejected as If-Match (412), and that the current ETag
still satisfies If-Match (204).

Adds "the ETag is stored in request variable" / "the ETag differs from
request variable" steps and teaches the PUT if-match step to resolve a
stored-variable placeholder so a stale validator can be replayed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@stephenfuqua stephenfuqua force-pushed the etag-content-version branch from 65f2c45 to 0bc8bd1 Compare July 8, 2026 22:33
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.

3 participants