Repository navigation
Threads: entries cite KB articles via kb_refs (#978) - #982
Merged
Merged
Conversation
A message or checkpoint entry now carries kb_refs, the knowledge article
ids it acted on, so a review reads which KB decision each checkpoint
relied on as data instead of scraping ids out of a body.
- thread_entries.kb_refs uuid[] NOT NULL DEFAULT '{}', with CHECKs for
the bound (20) and the kinds (message, checkpoint).
- Each id must be an article of the tenant the writing key can read
(agent keys are visibility-scoped); anything else refuses the whole
write 422 unknown_kb_refs, naming the id. Checked before the story
lock, since it reads on AdminRepo.
- kb_refs is part of the replayed write and rides in the entry's
audit-chain event when non-empty.
- thread_get returns kb_refs per entry; the review payload renders each
cited article's title, category and status beside the entry, or
readable: false for one the reading key cannot see.
- MCP 2.116.0: thread_entry and thread_checkpoint accept kb_refs.
Runner-socket writes (#979) and recording a citation as an article use
(#980) are filed separately.
Owner
Author
|
Review round 1 (code) counted at |
- A resend is answered from its row: the citable-set read still runs before the story lock, but its verdict binds only a new write, so an article made private or deleted after the first write cannot turn a retry into a refusal of an entry or checkpoint that already landed. - An invalid entry changeset is refused before the lock. A kb_refs that failed its cast read as the default [], so a malformed resend matched a row citing nothing and was acknowledged. - :visibility_agent_id is required (nil = every article) whenever kb_refs names any, and by Reviews.payload/4: omitting it fails instead of reading unscoped. - cast_kb_refs takes only 36-character UUID text: [nil] and a 16-byte string no longer pass as ids the caller never sent. - Published system canonicals are citable and titled. Citing and the review's titles now use one query, Knowledge.article_refs/3, built on get_article/3's visible_article_scope. - OpenAPI and MCP describe the bound as 20 DISTINCT ids (repeats are dropped first) instead of a raw maxItems the server does not enforce. - The migration adds both CHECKs NOT VALID and validates them in their own statements, DDL transaction disabled, so the scans hold only SHARE UPDATE EXCLUSIVE and thread writes continue.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #978.
A
messageorcheckpointthread entry can now name the knowledge articles it acted on, as data, so a review reads which KB decision each checkpoint relied on instead of scraping ids out of a body.What changed
thread_entries.kb_refs uuid[] NOT NULL DEFAULT '{}'(catalog-only on PG 11+, no rewrite). Two CHECKs restate the shape rules so a write that bypasses the context cannot store refs on another kind or past the bound:thread_entries_kb_refs_bound(cardinality <= 20) andthread_entries_kb_refs_kinds(empty, or kind is message/checkpoint).Threads.Entry:kb_refsis cast canonical (lowercased UUIDs), de-duplicated keeping first-cited order, and bounded on DISTINCT ids, so repeats do not use up the bound. A kind other than message/checkpoint carrying refs is a changeset error.Threads.record_entry/4andrecord_checkpoint/3: every id is checked against the articles the writing key can read (Knowledge.article_refs/3: the tenant's articles under the KB visibility scope, plus published system canonicals) BEFORE the story lock or any write. One unknown id refuses the whole entry with422 unknown_kb_refsnaming it. Ids only are stored, never bodies. The audit-chain payload carrieskb_refswhen non-empty, so the references are chained with the entry.kb_refsjoins the replayed fields. A message resend with other refs is409 idempotency_key_reused; a checkpoint resend that SENDS refs must send the recorded ones (409 checkpoint_conflict), and one that omits them replays.thread_getreturnskb_refson each entry. The review payload (GET .../review) addskb_articlesbeside each entry:{id, readable: true, title, title_untrusted: true, category, status}for an article the reading key can see,{id, readable: false}for one it cannot.kb_refsfrom one@kb_refs_schemaattribute; the 409/422 descriptions name the new codes.thread_entryandthread_checkpointacceptkb_refs(array of article ids, client-side shape check);thread_getandthread_review_getdescriptions say what comes back. README rows and CHANGELOG updated.Defaults Mark may overrule
Entry.max_kb_refs/0, restated in the migration). Raising it is a new CHECK migration.get_article/3resolves: the tenant's own articles the key can read, plus published system canonicals.unknown_kb_refsis one answer for missing and unreadable, so the endpoint is not an existence oracle for another agent's private memory.readable: falsewith no title, even though the writer could see it.Filed separately (label
deferred-finding)kb_refs; that needs a runner contract bump, a separate change with its own compatibility window.record_referencedis bound to a recall, and the heat and importance-prior rules forbid ranking on a signal the system produces without a new signal class being decided.Tests
test/loopctl/threads/kb_refs_test.exs: canonicalisation and order, unknown id, tenant isolation, visibility, bound/shape refusals, de-duplicated bound, idempotent resend, audit payload, checkpoint path and replay, changeset kind rule, both DB constraints.kb_articles.mcp-server/test/threads_tool.test.jsfor the tool wiring and client-side shape check.Review round (
/code-review high)Fixed:
threads.exrecord_entry/record_checkpoint), so an article made private after the first write turned a retry into 422. The verdict is now computed before the lock and applied only to a new write.kb_refson a resend read as the default[]and replayed as the same write. An invalid changeset is now refused before the lock.:visibility_agent_iddefaulted to "unscoped" when omitted. It is now required (nil= every article) whenever refs are named, and byReviews.payload/4.[null]and a 16-byte string passedEcto.UUID's cast.cast_kb_refsnow takes only 36-character UUID text.maxItems: 20contradicted the distinct-id bound; spec and MCP text now say 20 DISTINCT.Knowledge.article_refs/3onvisible_article_scope), which also removes the duplicate lookup.NOT VALIDplus separateVALIDATEwith the DDL transaction disabled, as20260723120000does.Not fixed, false positive: "
thread_getreturning a cited private article's id is an existence oracle." An oracle answers a prober-chosen input. Here the reader cannot choose the id; it sees only ids a writer deliberately put in a shared thread (the same ids the audit chain payload carries). It getsreadable: falsewith no title, category or body. The write-side single answer for missing and unreadable articles still stands, and that is the only place a caller can probe arbitrary ids.