ADR 0044: A chunk id is derived, not supplied
Status
Accepted.
Amends 0038 the chunk's vector lives on the chunk,
which stated content addressing as prose on ChunkWriter.upsert_many and left
its executable half open as BACKLOG B97. Closes B97.
Context
chunks/adapters/postgres.py writes term rows ON CONFLICT DO NOTHING, and
its _ON_CONFLICT clause for the chunk row itself omits doc_length and
embedding from the SET list. Both omissions are justified the same way: a
chunk id is content-addressed over (source_id, text), via
chunk_id(source_id, text), so a write that reuses an id is assumed to be
writing the same text, and neither derived column can ever legitimately need
updating on conflict.
That argument held only for callers who built ids with chunk_id themselves.
Nothing enforced that they did โ StoredChunk.id was a caller-supplied
str, and ChunkWriter.upsert_many promised unqualified last-write-wins on
(tenant_id, id). A caller reusing an id for different text got, from
InMemoryChunkStore, ranking over the new text (it tokenizes at query
time), and from PostgresChunkStore, ranking over the old text and the
old doc_length โ the two adapters silently disagreeing about the same
row, .claude/rules/recurring-defects.md ยง1's shape exactly. That gap is
BACKLOG B97.
Decision
StoredChunk.id is a computed_field, not a stored field. It is defined
as a @computed_field @property returning `chunk_id(self.source_id,
self.text)`, so the id cannot be anything other than the hash of the fields
it names. model_config = ConfigDict(extra="forbid") means a caller who
tries to pass id= the old way gets a loud rejection rather than a value
that is silently overwritten or silently ignored.
**A supplied id is not simply rejected โ it is checked, and a matching one
is accepted.** extra="forbid" alone breaks event-log replay:
DocumentChunked carries list[StoredChunk], and model_dump() includes
the computed id, so every already-stored event became un-deserialisable
the moment id stopped being a plain field. A
model_validator(mode="before") handles the three cases a round trip can
produce: id absent โ derive it, the ordinary construction path; id
present and equal to the derived value โ pop it and accept, which is
what replaying a stored event does; id present and unequal โ raise,
naming both ids, because the only way a payload's id can disagree with its
own (source_id, text) is if one of them was edited after the id was
computed. This is the trap to know about before re-deriving any field that a
serialised payload already carries: extra="forbid" plus a computed field is
not enough on its own, and the failure mode is not "rejects a stale id", it
is "cannot read back anything you already wrote".
With this in place, the two adapters' ON CONFLICT reasoning is a property
of the type rather than an assumption about callers: a write that reuses an
id is necessarily writing the same text, because there is no longer a way
to construct a StoredChunk where that isn't true.
Rejected
Last-write-wins on the derived columns instead. This was the alternative
B97 itself proposed โ treat a same-id-different-text write as within the
contract and have both adapters update doc_length, the term index and
embedding on conflict. It contradicts the identity this subsystem already
ships (0023's content addressing, 0038's reuse of it for the vector
column) rather than merely leaving it partly enforced, and on Postgres it
needs an unsafe same-statement DELETE-then-INSERT of the term rows โ a row
deleted and reinserted by the same statement is a same-statement double
modification, which _TERMS_ON_CONFLICT's docstring already argues against
for a different reason.
A validator instead of a computed field. Rejecting a mismatched
caller-supplied id at construction is strictly weaker than not letting a
caller supply one at all: every caller still has to compute
chunk_id(source_id, text) themselves and pass it, and only finds out
afterwards whether they got it right. A computed field removes the
computation from the caller's responsibility entirely, rather than checking
their homework.
**A compliance case asserting the two adapters agree after a
same-id-different-text write.** This was B97's "thorough fix" as originally
filed. It is no longer a meaningful test: that state cannot be built through
StoredChunk any more, so the case would have to bypass the type โ at which
point it is testing something no caller of this library can do, not a
behaviour of the port.
Consequences
Breaking for any caller that self-assigned ids. A construction that
supplied an id unrelated to (source_id, text) now raises at construction
instead of being accepted and later disagreeing between adapters. No
production call site in this repository ever did this โ every one of the 25
initial test failures when this landed was a test fixture passing a
placeholder id, not a real caller relying on self-assigned identity. B97 was
a latent contract hole in this codebase, not an active defect.
model_dump()'s shape is unchanged. id still appears in a dumped
StoredChunk, computed rather than stored, so DocumentChunked payloads โ
past and future โ serialise and deserialise the same way.
**A legacy row written before this change, if one carried a non-derived id,
reads back under its derived id.** ChunkReader methods return
StoredChunk instances built from stored columns, and construction now
always computes id from source_id and text rather than trusting a
stored id column. This repository has no such rows, per the point above,
but an adapter storing rows written by an older version of this library
would see their addressable identity change on the next read.