Skip to content

fix(rag): say that embedding generation runs, and test it through context.db (#1128, #1272) - #1332

Merged
borisno2 merged 4 commits into
prisma-8from
claude/qa-1128-write-surface-live
Sep 8, 2026
Merged

fix(rag): say that embedding generation runs, and test it through context.db (#1128, #1272)#1332
borisno2 merged 4 commits into
prisma-8from
claude/qa-1128-write-surface-live

Conversation

@borisno2

@borisno2 borisno2 commented Sep 7, 2026

Copy link
Copy Markdown
Member

The RAG work in #1128 was built and documented while the secured write surface could not execute on the Prisma 8 collection. It executes now — #1281 landed the write pipeline — so the delivery's own statements about itself were inverted: for three rounds the docs oversold, and this round they undersold the one thing the spec exists to deliver.

Confirming the premise first

I did not take the write surface from a commit message. On the Test context (PGlite + pgvector, createTestDatabase, a pure-function fake provider) I drove the real surface and then deleted the probe:

Probe Result
context.db.Note.create/update/delete all execute; readback confirms title: 'after', then the row is gone
context.db.Article.create({ data: { content: 'red' } }) commits, then reads back {"vector":[1,0,0],"metadata":{…,"sourceHash":"2f0x"}}
update content 'red''blue' regenerates: vector:[0,1,0], new sourceHash — the re-entry gate does not wrongly short-circuit
.nearest('contentEmbedding', [1,0,0]) over plugin-written vectors red@1.00 reddish@0.80 blue@0.00

One probe failed first and was informative: Note with no declared operation access returned null from create, which is the documented silent failure, not a broken surface. With access declared it executes.

And the delivery's own the plugin's sudo write reaches the column — written to skip itself by name — now runs and passes. packages/rag reports 456 passed / 0 skipped.

1 — The four claims that said the feature was inert

  • .changeset/loud-comets-invent.md — the sharpest, because it is pending and publishes as the release note. "Embedding generation does not run in this release… the write throws on every invocation… Semantic search over that field returns nothing… There is no config change that works around it." Replaced with what generation actually does, including the sourceHash short-circuit.
  • .changeset/silver-moths-gather.md — the dimension-change recipe's "the plugin's write is inert on this branch, so nothing regenerates yet". Probe 2 regenerates on re-save.
  • packages/rag/CLAUDE.md:363-365 — the generation hook's Known limits.
  • packages/rag/CLAUDE.md:633-636 — "On the prisma-8 branch that re-save regenerates nothing".

allowManualWrites sweep. Nine sites across .changeset/, packages/rag/CLAUDE.md, docs/content/how-to/rag-advanced.md and docs/content/reference/rag.md. Exactly one offered it as a way around the broken write path — loud-comets-invent.md's "If you need vectors before then, use embedding({ allowManualWrites: true }) and write them yourself" — and it is gone. The other eight already frame it as the deliberate opt-out for an app that maintains its own vectors; unchanged.

Also corrected, same class: the Known limits entry "a nested record is never embedded". ADR-0050 made a nested spelling under a relationship key a NestedRelationInputError, verified against secured-write.test.ts's eight-spelling table — the row that limit describes cannot be created at all, so the hook's warning is a backstop rather than something a write reaches.

2 — The accommodations

  • isUnportedWriteSurface removed, with the reporter branch it gated. It matched findUnique is not a function / Unknown column "data"; findUnique is now a collection member (context/index.ts:1126), and the branch was shipping a console.error reading EMBEDDING GENERATION IS NOT RUNNING … No config change works around it; track #1127 to a user.
  • plugin.test.ts's reports a write that cannot execute as the standing defect it is removed — it manufactured new TypeError('model.findUnique is not a function') and was green coverage of an unreachable branch.
  • Two stale test file headers, the #1124/#1127 comment in plugin.ts, and two further #1127 comments in plugin.test.ts (one of which pinned an assertion "until Spec: MCP and the admin UI on the secured surface #1127 lands").
  • One stale assertion followed the removal: expect(said).not.toContain('#1127') no longer discriminates against anything.

Sweep, re-run and widened. grep -rn "1124\|#1127" over every .md/.ts/.tsx outside node_modules, dist and specs/ now returns zero hits. The two it previously left — docs/content/how-to/write-a-plugin.md and docs/content/reference/context-api.md, saying a hook's database work is not rolled back — were flagged as unsettleable on a mis-probe: _transactionOpener is set on the internal AccessContext and deliberately omitted from the returned StackContext, so its absence there proved nothing. They are settled and false, and corrected. Keying the sweep on #1205 as well found two more instances the first pass could not see: the pending .changeset/humble-handles-narrow.md and CONTEXT.md's ORM handle entry. A committed test now pins it: an afterOperation writing through ormHandle and then throwing leaves no row, with a control confirming it lands without the throw. No ctx.skip and no #1124/#1127 Known limits block survives in packages/rag or packages/core.

3 — Meeting the PRD's testing decision (#1272)

The PRD says a good test "seeds rows with real vectors through the secured surface". Every re-pointed suite now does.

  • embedding-write.test.ts — the write denial block drives context.db.Article.create() / .update() instead of hookPipeline. Rows are seeded by writeSource(content), which writes source text and lets the plugin produce the vector. allowManualWrites moved to the DB-backed describe, so it is asserted by reading the columns back rather than by inspecting resolved data. The sudo write's try/catch + ctx.skip is gone.
  • search.test.tsseedPalette/seedAxis write source text through context.db; the off-surface seed() helper and its withOrigin('unsafe') import are gone.
  • core's multi-column-read-write.test.ts — a new DB-backed describe drives the write-access gate through context.db over a field with a real contract (two columns), covering denied update, denied create, granted write, sudo() bypass, an ungated field and a null clear.
  • plugin.test.ts's embeds a source value a list-level resolveInput produced re-pointed into embedding-write.test.ts as the embedded text is the persisted source, not the caller's input, against a real row.
  • hookPipeline un-exported from @opensaas/stack-core/internal — nothing depends on it now (core's own tests import it by relative path). The export is unreleased (c0d443fb post-dates the last Version Packages touching packages/core/CHANGELOG.md), so the paragraph announcing it was removed from .changeset/brisk-columns-refuse.md rather than left to publish a lie.

Two rows kept off the surface, deliberately and commented: metadata-present-with-no-vector, which the generation hook cannot produce (it writes both columns or neither).

Mutations — each re-pointed test broken on purpose, restored after

Mutation (production code) Tests that failed
splitMultiColumnFields' denial → if (!canWrite && false) rag an ordinary create naming the embedding throws, an ordinary update naming the embedding throws, a create the operation gate admits throws; core THROWS when update access is denied, THROWS when create access is denied
the above plus filterWritableFields' split-column-owner throw disabled additionally rag the write the throw refused never reaches the columns
generation hook returns before its sudo write rag the two columns read back as one stored embedding, nearest ranks by the column the field declares, the embedded text is the persisted source, not the caller's input, the write the plugin owns still lands; 11 of 13 search.test.ts tests — 15 failures, not 14
allowManualWrites ignored (field always denies) rag allowManualWrites lets the same payload reach the columns
splitColumns' output not merged into the write core THROWS when update access is denied, writes both per-part columns, sudo bypasses the gate, a field WITHOUT field-level access writes
a null value dropped instead of split core clearing the field with null clears both columns

The third row is the one that matters for the PRD's decision: under the old withOrigin('unsafe') seeding, breaking generation moved nothing. It now takes down almost the whole search suite. The two survivors there assert the absence of an embedding and a missing id, correctly.

Two mutation results worth recording rather than hiding:

  • The single-gate mutation leaves the write the throw refused never reaches the columns green, because filterWritableFields is a second, defence-in-depth gate that throws a differently-worded message. The exact-message assertions catch it; that test's substring does not. Disabling both gates kills it.
  • Writing the Derived fixture surfaced a live data-corruption bug, since fixed rather than guarded — see section 5.

4 — The null dereference (packages/rag/CLAUDE.md:541)

const article = await …first(); const queryVector = article.contentEmbedding.vectorTS18047, and a TypeError at runtime whenever the Access Filter denies the row, which is the silent-failure contract the root CLAUDE.md makes a named Critical Pattern.

Harness, falsified before trusting it. A strict tsc project rooted inside packages/rag/ so @opensaas/stack-* resolve through the workspace to the built declarations. The original block reproduces TS18047: 'article' is possibly 'null' exactly; the corrected block is clean; injecting the dereference back into the corrected block reproduces TS18049. Checked against the package's real exported StoredEmbedding rather than a local copy of it — the guard narrows stored.vector to number[], which is what proves the declarations resolve rather than degrading to any.

Sweep. .first() / findUnique( / findFirst( across the whole RAG documentation surface: packages/rag/CLAUDE.md:540 is the only one, so within scope the class has one member and it is fixed.

Beyond scope, the same sweep over every markdown file in the repo found one more in a pending changeset.changeset/nine-otters-describe.md:32, const article = await …findFirst(); article.body — fixed here and compile-checked in the same harness, because it publishes.

It also found the class is widespread on the docs site, in files this delivery does not own: docs/content/concepts/field-types.md:632, docs/content/reference/fields-api.md:1663 and :1718 all dereference a findUnique result directly, and several more bind without guarding. SECURITY.md:177, docs/content/concepts/access-control.md:167 and docs/content/reference/context-api.md:859 get it right and are the model. Not fixed here — it is a docs-site sweep of its own, and worth a ticket.

5 — The silent data corruption, fixed (ADR-0066)

The Derived fixture was defending itself against a live bug, and the defence hid it. Removed, and fixed at the source.

The corruption, run. The plugin's escalated write was sudo().db.<list>.update() — an ordinary secured write, so it re-ran the list's whole hook pipeline carrying the embedding column and nothing else. A list-level resolveInput deriving one field from other input, the pattern the root CLAUDE.md documents, then recomputed that field from values that were not there.

content after create({ data: { title: 'red', body: 'hot' } }) list afterOperation calls
Before ' ' — the join of two undefineds, and the embedding is of ' ' ['after', 'after']
After 'red hot', vector [0,0,1], sourceHash: 'hvoym6' ['after']

Silent in both places: no error, and the stored vector is the vector of the destroyed text. Unreachable before #1281 made the write execute.

The mechanism, and why this one. Core now owns the write as a single field: writePluginOwnedField on @opensaas/stack-core/extend splits the value through the field's own splitColumns exactly as the Write Pipeline does, issues one id-scoped UPDATE marked with the engine origin, and runs no hook. It reaches no other field — a narrower capability than the escalated db update it replaces, which could write any column on the row.

The write is not an application update. It carries one column and completes a write the application already made, whose hooks have already run against the caller's real input; running them again over a payload naming one field lies to every one of them. Not only resolveInput: validate sees a mostly-absent record, the side-effect hooks double-fire, and the generation hook's own afterTransaction re-entered itself — which only the stored sourceHash was stopping. Re-entry is now structural rather than guarded.

Carrying the persisted row was considered and rejected, with a concrete reason: a field-level resolveInput that transforms rather than derives would re-transform its own stored output — password()'s hook hashes resolvedData[fieldKey], so handing back the stored hash double-hashes it. That trades one silent corruption for another. The other options and why they lost are in ADR-0066.

One wiring correction fell out. sudo() returns the public StackContext, which omits ormHandle — it reached Plugin.runtime through an as unknown as AccessContext cast, and a write over it found nothing. The write takes the AccessContext runtime receives as its first argument; a context with no handle is refused by name. The RAG plugin's runtime no longer takes sudo at all.

The tests, and the mutation

The fixture's guard is gone; Derived now carries the derivation an application would write. Three rag tests and six core tests pin the contract.

Mutation Tests that failed
the plugin's write restored to sudo().db.<list>.update() rag the embedded text is the persisted source, not the caller's input (expected ' ' to be 'red hot'), the generation write leaves the derived source it did not name intact (same), the generation write fires no second afterOperation (['after','after'] vs ['after'])
writePluginOwnedField ignores splitColumns core writes the field's own columns past its write denial, leaves a derived field the list's resolveInput owns untouched, fires no list hook of its own, clearing with null clears every column the field owns

Both restored after.

Gates

Gate Result
pnpm lint pass — 0 errors, 2 warnings, both pre-existing (examples/blog/test-singleton.ts, packages/cli/src/migration/generators/migration-generator.ts)
pnpm build (incl. the docs build) pass — 11/11
pnpm manypkg fix / pnpm format clean
packages/rag pass — 460 passed, 0 skipped, 20 files
packages/core pass — 1624 passed / 1 skipped, 83 files
packages/cli pass — 407 passed, 42 files

Core's suite gained 13 tests and rag's skip count went 2 → 0.

Out of scope, untouched

#1310 (storage/tiptap test coverage), #1311 and #1304 (the ollama example's seed script, its lowercase context.db key, the hand-rolled cosine scan, the suite running twice from src/ and dist/), and #1265, #1271, #1278, #1282, #1283, #1288, #1294, #1297, #1298, #1299, #1301, #1303, #1306, #1307, #1308, #1309, #1313, #1316, #1317, #1319, #1320, #1321, #1322. PR #1324 is untouched.

Refs #1128, #1272.

🤖 Generated with Claude Code

…text.db

The secured write surface executes on the Prisma 8 collection since #1281, so
automatic embedding generation runs end to end. Four places still told users and
agents it was inert, one of them a pending changeset that would have published
"the write throws on every invocation" as the release note for the feature.

- Correct `.changeset/loud-comets-invent.md`, `.changeset/silver-moths-gather.md`
  and `packages/rag/CLAUDE.md`. `allowManualWrites` is presented only as the
  deliberate opt-out for an app that maintains its own vectors, never as a way
  around a broken write path.
- Remove `isUnportedWriteSurface` and the reporter branch it gated, which was
  dead code logging a false "EMBEDDING GENERATION IS NOT RUNNING ... No config
  change works around it", plus the test that certified the unreachable branch.
- Re-point the write-denial and search tests at `context.db` (#1272): rows are
  seeded by writing source text through the secured surface, so every vector
  under assertion is the plugin's own output. Same for core's multi-column
  write-access gate, and for the derived-source case that drove `hookPipeline`.
- `hookPipeline` leaves `@opensaas/stack-core/internal`; nothing depends on it.
- Guard the possibly-null `.first()` in the "Find Similar Items" sample.

Refs #1128, #1272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7a05f35

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-core Minor
@opensaas/stack-rag Minor
@opensaas/stack-auth Minor
@opensaas/stack-cli Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-ui Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Sep 8, 2026 12:43am UTC

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 94.39% (🎯 65%) 3180 / 3369
🟢 Statements 93.05% (🎯 65%) 3563 / 3829
🟢 Functions 96.5% (🎯 62%) 690 / 715
🟢 Branches 88.33% (🎯 50%) 2438 / 2760
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/internal.ts 0% 0% 0% 0%
packages/core/src/context/plugin-field-write.ts 100% 100% 100% 100%
packages/core/src/context/write-pipeline.ts 97% 90.38% 100% 97.89% 286-287, 504
Generated in workflow #2117 for commit 7a05f35 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.45% 244 / 311
🔵 Statements 77.95% 251 / 322
🔵 Functions 69.81% 74 / 106
🔵 Branches 66.94% 160 / 239
File CoverageNo changed files found.
Generated in workflow #2117 for commit 7a05f35 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 71.57% 1662 / 2322
🔵 Statements 71.33% 1782 / 2498
🔵 Functions 79.12% 288 / 364
🔵 Branches 59.26% 828 / 1397
File CoverageNo changed files found.
Generated in workflow #2117 for commit 7a05f35 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 91.2% 280 / 307
🔵 Statements 89.94% 313 / 348
🔵 Functions 96.05% 73 / 76
🔵 Branches 82.38% 262 / 318
File CoverageNo changed files found.
Generated in workflow #2117 for commit 7a05f35 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 80.21% 227 / 283
🔵 Statements 81.61% 253 / 310
🔵 Functions 91.35% 74 / 81
🔵 Branches 77.46% 220 / 284
File CoverageNo changed files found.
Generated in workflow #2117 for commit 7a05f35 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 91.29% 556 / 609
🔵 Statements 90.58% 606 / 669
🔵 Functions 97.39% 112 / 115
🔵 Branches 84.26% 375 / 445
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/rag/src/config/generation-failure.ts 100% 90% 100% 100%
packages/rag/src/config/plugin.ts 93.92% 82.5% 93.1% 96.22% 253, 265, 275-281, 362, 381-385, 397, 464, 466, 504, 543
Generated in workflow #2117 for commit 7a05f35 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #2117 for commit 7a05f35 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #2117 for commit 7a05f35 by the Vitest Coverage Report Action

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES

(Posted as a Comment review — the GitHub identity here is the PR's own author, so a formal Request-Changes event is refused.)

Reviewed at b35f316, base prisma-8 (de80685a). Everything below was run, not read.


The premise holds — verified independently

I did not take it from the PR body. A fresh probe on the Test harness (PGlite, own fake provider, own list):

Probe Result
ctx.db.Doc.create({ data: { body: 'red' } }) commits, reads back {"vector":[1,0,0],"metadata":{…,"sourceHash":"2f0x"}}
update body 'red''blue' regenerates: vector:[0,1,0], new sourceHash
nearest('bodyEmbedding', [0,1,0]) returns ranked rows
ctx.db.Doc.delete(...) executes; row gone
packages/rag full suite 456 passed / 0 skipped / 20 files — matches the claim exactly

Mutations reproduced (not taken on trust):

  • splitMultiColumnFields' denial → if (!canWrite && false) → core THROWS when update access is denied…, THROWS when create access is denied… fail; rag an ordinary create naming the embedding throws, an ordinary update naming the embedding throws, a create the operation gate admits throws… fail. As reported.
  • generation hook returns before its sudo write → 15 failures: 11/13 search.test.ts plus 4 in embedding-write.test.ts. Under the old withOrigin('unsafe') seeding this mutation moved nothing, so the re-point is a genuine gain in discrimination.

The single-gate survivor framing is accurate, and if anything under-claims. the write the throw refused never reaches the columns stays green under the single-gate mutation because filterWritableFields still refuses with Validation failed: Cannot update "contentEmbedding" (via column "contentEmbedding"): field-level access denied. — which still contains the test's substring. That is not a blind test: it asserts an outcome (the columns stay null) that the second gate genuinely preserves. Calling it a limitation is honest and correct.

Also verified clean: isUnportedWriteSurface has zero remaining references anywhere outside dist/; removing its test removed coverage of nothing still live (the transient-classification arm is still tested). hookPipeline is genuinely unreleased — I unpacked published @opensaas/stack-core@0.42.2 and dist/internal.d.ts does not export it; the only remaining users are write-pipeline.ts and core's own tests by relative path. Removing the announcing paragraph from brisk-columns-refuse.md is correct, not erasure. NestedRelationInputError is genuinely thrown for a nested spelling (relationship-input.ts:158), so that corrected Known limits entry is true. No any, no casts (the unknown{} narrowing in storedMultiColumn is legitimate). eslint and tsc --noEmit clean on every changed file. Both changesets are minor.


Blockers

B1 — This PR found silent data corruption and buried it in a test fixture

embedding-write.test.ts's Derived fixture guards its resolveInput with typeof resolvedData.title === 'string', and the comment says why: the plugin's sudo write re-runs the whole hook pipeline carrying only the embedding column. The PR body records this under "worth recording rather than hiding". But it is not a fixture quirk — it is a live, silent data-loss bug in application code, and this PR's own premise is what makes it reachable for the first time.

Probe, using the exact list-level resolveInput pattern the root CLAUDE.md documents, unguarded:

CLOBBER content:   " "
CLOBBER embedding: {"vector":[1],"metadata":{...,"sourceHash":"w",...}}

The user's content is destroyed (join of two undefineds), and the stored embedding is the embedding of the clobbered text. Nothing is logged. Before #1281 this was unreachable because the sudo write never executed; it is reachable now.

Two consequences:

  1. eager-vectors-arrive.md announces "Automatic embedding generation runs" with no caveat. That release note ships this.
  2. The new test the embedded text is the persisted source, not the caller's input passes only because the fixture defends itself. It certifies the workaround, not the contract.

Needed before merge: a Known limits entry in packages/rag/CLAUDE.md, a warning paragraph in the changeset, and a tracking issue. (The real fix — the sudo write skipping the list-level resolveInput, or carrying the persisted row — is out of scope here.)

B2 — A pending changeset still publishes the stale claim this PR exists to delete, and it is false

.changeset/humble-handles-narrow.md:30:

The Write Pipeline rebinds ormHandle wherever it rebinds context.db, exactly as it did before — this rename changes nothing about when a hook's database work is transactional. (On prisma-8 no write currently opens a transaction at all: #1205.)

Pending, publishes as a release note for stack-core/stack-auth/stack-rag. The sweep was grep -rn "1124\|#1127", so every instance citing only #1205 was invisible to it — including this one, in the highest-consequence surface the PR names.

And the claim is false. Probe: an afterOperation hook writes a row through context.ormHandle and then throws; the enclosing create rejects and the hook's row is gone. Control (same probe, no throw): the row lands. Same result driving context.db from the hook.

B3 — "Could not settle" on the two remaining doc claims is not accurate; they are settled, and false

docs/content/how-to/write-a-plugin.md:266 and docs/content/reference/context-api.md:188 carry the same false statement, left on the stated grounds that "a probe on the Test harness showed _transactionOpener absent there, so the harness cannot decide it."

That probe measured the wrong object. getContext sets _transactionOpener on the internal AccessContext (packages/core/src/context/index.ts:635) — the one populateDbDelegate binds every db delegate to — and then returns a separate StackContext literal (:1088-1100) that deliberately omits it. Its absence on the returned context is by design and proves nothing either way.

Both the harness (packages/core/src/testing/context.ts:450) and a real application (generated getContext, packages/cli/src/generator/context.ts:255) pass the Prisma 8 client as the 8th positional argument, so transactionOpenerFor returns an opener and writes open a transaction. Rollback works — B2's probe is the proof.

Flagging rather than asserting was the right instinct; the reason given for it is wrong, so the flag reads as "unknowable" when it was one probe away. Either fix all three sites, or restate the flag accurately.


Follow-ups (non-blocking)

  • F1 — delete() leaks the un-assembled multi-column shape. My probe's ctx.db.Doc.delete(...) returned { bodyEmbedding: [0,1,0], bodyEmbeddingMetadata: {…} }, while first() on the same row returns { vector, metadata } and hides the sibling column. Pre-existing, not this PR's — but it is the same assembly contract the new tests assert on the read path, and no test here covers delete's return value. Worth a ticket.
  • F2 — the stack-core bump in eager-vectors-arrive.md. Core's only change is a test file plus removal of an unreleased /internal export. The core paragraph of that release note describes test coverage, which is not a user-visible change. Accurate prose, over-claiming as a minor.
  • F3 — toEqualtoMatchObject in the two columns read back as one stored embedding. Necessary (dynamic sourceHash/generatedAt), but it drops the "no extra keys" guarantee the old assertion carried — the very leak the next line checks for on the row. Consider pinning Object.keys(stored.contentEmbedding) to ['vector','metadata'].
  • F4 — test('the plugin’s sudo write reaches the column') escapes the apostrophe while its neighbours in the same file use the literal .
  • F5 — the mutation table undercounts row 3: that mutation also takes down the embedded text is the persisted source, not the caller's input (15 failures, not 14). Immaterial to the conclusion, but the table is presented as evidence.

Summary. The premise is real and I verified it myself; the deletions are safe; the re-point onto context.db is a genuine and measurable improvement in discrimination; the hookPipeline removal is correct on every ground claimed. Blocking on B1 (a live data-corruption path this PR discovered, guarded in a fixture, and announced as working with no caveat) and B2/B3 (the pattern this round exists to end, surviving in a pending changeset and two docs pages because the sweep was keyed on the wrong issue numbers and the "cannot decide" verdict rested on a mis-probe).

…066)

The RAG plugin wrote its generated embedding through `sudo().db.<list>.update()`.
That is an ordinary secured write, so it re-ran the list's whole hook pipeline
carrying the embedding column and nothing else — and a list-level `resolveInput`
deriving one field from other input, the pattern the root CLAUDE.md documents,
then recomputed that field from values that were not there. Measured unguarded:
`create({ data: { title: 'red', body: 'hot' } })` committed `content: 'red hot'`
and then overwrote it with `' '`, and embedded that. Silent, in the database and
in the index. Unreachable until #1281 made the write execute.

Core now owns the write as one field: `writePluginOwnedField` on
`@opensaas/stack-core/extend` splits the value through the field's own
`splitColumns` and issues one id-scoped UPDATE marked with the engine origin,
running no hook and reaching no other field — a narrower capability than the
escalated `db` update it replaces. It takes the AccessContext `Plugin.runtime`
receives; the StackContext `getContext` returns carries no ORM handle and is
refused by name.

The `Derived` fixture's guard against the corruption is deleted. Three rag tests
and six core tests pin the contract, and all fail against the write this
replaces.

Also settles the two documentation claims the previous round flagged as
unsettled. An `afterOperation` writing through `ormHandle` and then throwing
leaves no row, with a control confirming it lands without the throw; the earlier
probe read `_transactionOpener` off the returned StackContext, which omits it by
design. `write-a-plugin.md`, `context-api.md`, CONTEXT.md and the pending
`humble-handles-narrow.md` are corrected — the last was invisible to the previous
sweep, which was keyed on #1124/#1127 and missed every instance citing #1205.

Restores `toEqual` where the round had loosened it to `toMatchObject`, pinning
the assembled embedding to exactly its two keys.

Refs #1128, #1272.

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

borisno2 commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Addressed at 8500712. Taking B1 as a production bug rather than a documentation gap — the fix is a behaviour change with an ADR and a changeset, not a Known limits entry.

B1 — fixed, not documented

Ran the corruption first, unguarded, with the exact list-level resolveInput the root CLAUDE.md documents:

content after create({ data: { title: 'red', body: 'hot' } }) list afterOperation calls
Before ' ' ['after', 'after']
After 'red hot', vector [0,0,1], sourceHash: 'hvoym6' ['after']

You measured the same thing, and the second column is the half neither of us had counted: the write also double-fires every side-effect hook for one logical change. And validate runs on that pass over a mostly-absent record, so it can fail a write that already committed.

The mechanism. Core now owns the write as a single field: writePluginOwnedField on @opensaas/stack-core/extend splits the value through the field's own splitColumns exactly as the Write Pipeline does, issues one id-scoped UPDATE marked with the engine origin, and runs no hook. It reaches no other field — a narrower capability than the escalated db update it replaces, which could write any column on the row.

The reasoning: the plugin's write is not an application update. It carries one column and completes a write the application already made, whose hooks have already run against the caller's real input. Running them again over a payload that names one field lies to every one of them.

Carrying the persisted row — your other candidate — I rejected with a reason I can show. A field-level resolveInput that transforms rather than derives would re-transform its own stored output: password()'s hook hashes resolvedData[fieldKey], so handing it back the stored hash double-hashes it. That trades one silent corruption for another. Skipping only the list-level resolveInput leaves validate, the double-fire and the re-entry; a skipHooks flag puts a hook-skipping switch on the surface application code holds. All four are recorded in ADR-0066.

One wiring correction fell out of it. sudo() returns the public StackContext, which omits ormHandle — it reaches Plugin.runtime through an as unknown as AccessContext cast, and a write over it finds nothing. The write takes the AccessContext runtime receives as its first argument, and a context with no handle is refused by name rather than failing as an undefined property inside the write. The RAG plugin's runtime no longer takes sudo at all. This is the same omission your B3 identified, met from the other side.

The fixture's guard is deleted and Derived now carries the unguarded derivation. Mutation, restoring the plugin's write to sudo().db.<list>.update():

  • rag the embedded text is the persisted source, not the caller's inputexpected ' ' to be 'red hot'
  • rag the generation write leaves the derived source it did not name intact → same
  • rag the generation write fires no second afterOperation['after','after'] vs ['after']

And on core's own four, mutating writePluginOwnedField to ignore splitColumns fails all four. Both restored.

The re-entry guard's job narrows accordingly: sourceHash now only stops an update that leaves the source text alone from paying for a second provider call. Re-entry is structural.

B2 — fixed, and the sweep found one more

.changeset/humble-handles-narrow.md:30 corrected. You are right about why it survived: the sweep was keyed on 1124|#1127, so every instance citing only #1205 was invisible. Re-keyed on 1205 and on "does not / cannot / no longer works" phrasing about the write path, across .changeset/, docs/content/, packages/*/CLAUDE.md and CONTEXT.md.

That found one site beyond the one you named: CONTEXT.md:86, the ORM handle glossary entry — "no write currently opens a transaction, so a hook's database work through it does not roll back with the write (#1205)". Same false claim, in the domain vocabulary rather than a release note.

Four sites in total: the changeset, CONTEXT.md, and B3's two.

The related class sweep also turned up three descriptions of the write as happening "under sudo" — eager-vectors-arrive.md, loud-comets-invent.md (×2), packages/rag/CLAUDE.md, docs/content/how-to/rag-advanced.md — which the B1 fix makes wrong in a second way. All corrected.

B3 — settled, and I verified it rather than taking it

You are right that the probe measured the wrong object. Verified independently rather than on your word, and made it a committed test (write-transaction.test.ts): an afterOperation hook writing through context.ormHandle and then throwing leaves no row, with a control confirming the same hook without the throw lands both rows. write-a-plugin.md:266 and context-api.md:188 corrected; the flag is out of the PR body.

Follow-ups

  • F3 — toEqual restored. You were right that the looser assertion drops the guarantee the next line checks for. Pinned to exactly { vector, metadata } with expect.any(String) for generatedAt and the literal sourceHash: '2f0x', rather than to Object.keys — that keeps the metadata's own shape under assertion too.
  • F5 — mutation table corrected to 15 failures, naming the fourth embedding-write.test.ts test.
  • F4 — the escaped apostrophe is gone with the test's rename (the plugin's escalated write reaches the column).
  • F1 (delete()'s un-assembled shape) and F2 are not touched here; F1 is being filed separately.

Gates

Gate Result
pnpm lint pass — 0 errors, 2 warnings, both pre-existing
pnpm build (incl. docs) pass — 11/11
pnpm manypkg fix / pnpm format clean
packages/rag pass — 460 passed, 0 skipped, 20 files (456 → 460)
packages/core pass — 1624 passed / 1 skipped, 83 files (1617 → 1624)
packages/cli pass — 407 passed, 42 files

Both new changeset entries are minor. Every code sample in the new changeset was compiled against the built declarations in a scratch strict project rooted in packages/rag/, falsified first — injecting context: null reproduces TS2322: Type 'null' is not assignable to type 'AccessContext', which is also what proves the declarations resolve rather than degrading to any.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review — REQUEST CHANGES

Commit under review: 85007129 ("fix(core,rag): a plugin's write of its own column runs no hook (ADR-0066)") only. Earlier commits on this branch were reviewed in previous rounds and are not re-reviewed here.

Posted as a Comment review, not a formal REQUEST_CHANGES event: the reviewing GitHub identity (borisno2) is this PR's own author, and GitHub refuses a formal review event on your own PR. Treat the verdict line above as the review state.

The central claim holds. I reproduced the corruption on the parent and confirmed it is gone here, and I reproduced both halves of the bug and one of the reported mutations independently. What blocks is accuracy in the two permanent artefacts this commit adds — an ADR whose load-bearing rejection is false as written, and a how-to page that still teaches the deleted pattern 16 lines below the prose that deletes it.


Verified independently

The corruption, on b35f3168 (parent). I deleted the Derived fixture's typeof resolvedData.title === 'string' guard, added the unguarded derivation and an afterOperation counter, and ran:

PROBE content = " "                       (expected "red hot")
PROBE afterOperations = ["after","after"] (expected ["after"])

On 85007129 the same fixture — now committed unguarded — passes: content: 'red hot', vector [0,0,1], ['after']. packages/rag is 460 passed / 0 skipped / 20 files.

Both halves of the second defect (item 4), on the parent. Adding a list-level validate that records Object.keys(resolvedData):

PROBE validate saw = ["[\"title\",\"body\",\"content\"]","[\"contentEmbedding\"]"]

It ran twice, the second time over a record naming one field, and threw. One correction to how the PR body and ADR phrase this: it did not fail the caller's write — the generation hook's try/catch swallowed it and reported it through reportGenerationFailure as a transient provider failure, advising "If the cause has cleared, retry by writing the source field again." Silently no embedding, plus actively wrong advice. Worse in kind than "fails a committed write", and the ADR's phrasing should say what actually happens. Both halves pass on this commit (validate runs once).

The mechanism. updateFirst really is withOrigin('engine', …) (secured/write.ts:132-139), so the engine-origin claim is true. getContext's returned StackContext genuinely omits ormHandle, so the HandlelessPluginFieldWriteError guard is live rather than dead code. AccessContext.ormHandle is readonly and the Write Pipeline builds new context objects rather than mutating, and _sharedPlugins skips re-running runtimes on transaction rebind — so the handle the writer closes over is the top-level non-transactional one, which is correct for a write draining after afterTransaction.

Mutation reproduced. Replacing the splitColumns branch with { [fieldName]: value } fails exactly the four reported core tests (Unknown column "avatar" in table "Owned"). Restored after.

Same shape elsewhere — swept, none found. sudo().db writes across all packages: auth's two uses are findUnique reads only, and its better-auth adapter works off the raw ORM, so neither re-enters hooks. The only other context.db[…].update( in core is mcp/handler.ts:480, a caller-driven application write. This defect had one instance.

Gates. eslint clean on all six changed source files; tsc --noEmit clean on packages/core and packages/rag; changed core suites 34/34. No any and no casts in the production code added. Changeset is minor/minor — correct for a new public export — and its usage example matches the shipped five-argument signature.


Blockers

1. ADR-0066's load-bearing rejection is factually false

"password()'s hook hashes resolvedData[fieldKey], so handing it back the stored hash double-hashes it."

Both halves are wrong. packages/core/src/fields/index.ts:822-836 reads inputData[fieldKey], not resolvedData[fieldKey], and carries an explicit guard:

// Idempotent: skip re-hashing a value that's already a hash.
if (isHashedPassword(inputValue)) {
  return inputValue
}

isHashedPassword matches /^\$2[aby]\$\d{2}\$.{53}$/, which is exactly what hashPassword emits. Verified empirically: hashing 'hunter2' and feeding the result back returns it byte-identical. password() would not double-hash.

The decision survives — the generic argument holds for any non-idempotent transforming hook an application writes (slugify-and-append, counter increment), and the ADR's other two objections in the same bullet ("turns a one-column write into a whole-row write", "only ever works for hooks that are pure functions of the row") are independently sufficient. But an ADR is a permanent record that later readers reason from, and this one cites a verified-false example as the reason for its central rejection. Replace the password() example with one that is actually true of this codebase, or drop it and lean on the two objections that hold.

The other rejections check out: skipHooks on context.db (correct — wrong surface for the capability), skip-only-resolveInput (correct — I measured validate and afterOperation failing too), plugin-writes-columns-itself (correct — duplicates ADR-0049's layout), and document-and-ship (correct — the failure is silent and unworkaroundable). The ADR otherwise matches what was built.

2. docs/content/how-to/rag-advanced.md:79 still teaches the pattern this commit deletes

This commit rewrote lines 44-63 of that page to say the write goes through writePluginOwnedField and runs no hook. Sixteen lines later the "Simplified hook implementation" block is untouched:

await writeUnderSudo(listName, item.id, fieldName, {

Old name, old four-argument signature, and the page contradicts itself within one screen. This is the how-to for plugin authors — the audience for the new export — and a reader who copies the block reintroduces the exact corruption ADR-0066 exists to close.


Should fix before merge

3. The new public API's undefined semantics diverge by field shape

PluginOwnedFieldWrite.value is documented as "the field's logical value, or null to clear it". Passing undefined:

  • with splitColumns (embedding: isStoredEmbedding(value) ? value : null) → { vector: null, metadata: null } — a silent wipe;
  • without{ [fieldName]: undefined } — an ORM no-op.

Same call, opposite outcomes, decided by a field-shape detail the caller isn't looking at. On a brand-new public export, reject undefined by name or normalise it to null before the split.

4. Two standing write errors are now misreported as transient

The previous commit removed isUnportedWriteSurface, which was generation-failure.ts's only standing-write branch. This commit adds two errors reachable from that same catch — HandlelessPluginFieldWriteError (new here) and WriteCollectionMissingError (whose own message says "Re-run opensaas generate"). Both fail identically on every row until wiring is fixed, and both now land in:

"…failed for a reason that is not a standing defect — … If the cause has cleared, retry by writing the source field again."

per row, forever. That is precisely the shape the module docblock says the classification exists to prevent, and it defeats the "refused by name" property the ADR and changeset advertise: the one place HandlelessPluginFieldWriteError can actually fire is the one place that swallows it into retry-later advice. I observed this class live while probing the parent (a ValidationError reported as a provider failure).

5. The as unknown as AccessContext cast survives — at the line the ADR diagnoses

ADR-0066's Context names it, but the commit only stops RAG from using sudo. packages/core/src/context/index.ts:652 is unchanged:

context.plugins[plugin.name] = plugin.runtime(
  context,
  () => sudo() as unknown as AccessContext,
)

Plugin['runtime']'s sudo parameter is still declared () => AccessContext while delivering a StackContext with no ormHandle, _isSudo, _resolveOutputChain or _transactionOpener. The trap stays armed for the next plugin author. It also explains why plugin-field-write.ts:78's context.ormHandle === undefined guard is unreachable to the type checker (AccessContext.ormHandle is readonly ormHandle: OrmClient, non-optional) and why its own test has to cast to fire it. Narrowing that parameter's declared type is the real fix; the cast is the last thread of the bug you diagnosed.


Follow-ups (non-blocking)

6. "It reaches no other field" is caller discipline, not enforcement. Nothing validates fieldName against the list, or that fieldConfig is that field's config. Verified against a real database:

writePluginOwnedField({ context, listName: 'Thing', id, fieldName: 'secret', fieldConfig: {}, value: 'PWNED' })
// → secret === 'PWNED'

This is not a privilege escalation — a plugin already holds context.ormHandle, which CONTEXT.md says "bypasses as much as unsafe does" — and the payload-shape narrowing is real per call. But the changeset and ADR both phrase a convention as a capability boundary ("It reaches no other field", "cannot be aimed at anything else"). One sentence in the docblock saying the field is the caller's assertion would settle it.

7. packages/rag/src/config/plugin.ts:534 still throws "…so a generated embedding has no sudo write to reach its write-denied column through." There is no sudo write any more.


On scope

Keep the ADR and the export here. You cannot fix the corruption without moving the write into core, so the new export is the fix, and the ADR is the record superseding ADR-0045's spelling of that write. Splitting them would park a live silent-data-corruption fix behind a second PR.

What should have been separated is the #1205 documentation sweepCONTEXT.md, write-a-plugin.md, context-api.md, humble-handles-narrow.md and the new write-transaction.test.ts case. It is unrelated to ADR-0066, and it is the third distinct thing in a commit already carrying two. (The test itself is good: the control case confirms the rollback assertion isn't passing because the write never reached the database.)


Blockers 1 and 2 are text corrections. 3, 4 and 5 are small code changes. None of them undermines the design — the fix is at the right layer, the fixture's guard is genuinely gone, and the mutations bite.

…rrect ADR-0066

Review follow-up on #1332.

ADR-0066 rejected "carry the persisted row" on the grounds that `password()`'s
field hook would re-hash an already-hashed value. It would not: that hook reads
`inputData[fieldKey]` and returns an already-hashed value unchanged via
`isHashedPassword`. `storage`'s `file()`/`image()` hooks are idempotent for the
same reason (ADR-0006), so no shipped field type would corrupt. The rejection
now rests on what survives a whole-row payload: `validate` over a record no
caller submitted, the double-fired side-effect hooks, and the generation hook's
re-entry — none of which carrying the row removes.

`docs/content/how-to/rag-advanced.md` still called the write by its old name and
four-argument signature, sixteen lines below the prose that replaced it. Swept
the class across every file: that was the only remaining call. The sweep also
found `examples/rag-ollama-demo/README.md` still describing the write as
happening "under sudo", and the stale "sudo write" string in the RAG plugin's
own error.

`writePluginOwnedField` now resolves the field against the config the context
carries — `AccessContext` gains `_config` — instead of taking a layout from its
caller, so "it reaches no other field" is enforced rather than asserted. A list
or field the config does not declare, a context with no config, and `undefined`
as the value (which wiped a multi-column field and no-opped a single-column one)
are each refused by name. The RAG failure reporter routes those refusals, and
`WriteCollectionMissingError`, to the standing-defect path rather than advising
a retry that can never work.

`Plugin['runtime']`'s `sudo` parameter is declared `StackContext`, which is what
it always returned, removing the `as unknown as AccessContext` cast ADR-0066
diagnoses.

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

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

Deployment failed for project stack-docs with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/open-saas?upgradeToPro=build-rate-limit

@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Addressed at 4456be4c.

1 — ADR-0066's rejection was false, and is corrected

You are right, and I traced it rather than taking it. packages/core/src/fields/index.tspassword()'s resolveInput reads inputData[fieldKey], not resolvedData[fieldKey], and returns an already-hashed value unchanged (isHashedPassword, /^\$2[aby]\$\d{2}\$.{53}$/, exactly what hashPassword emits). It would not double-hash.

I also checked the only other transforming field hooks this repo ships: storage's file() and image() read resolvedData[fieldKey] but return an existing metadata value untouched — "an existing metadata value is AUTHORITATIVE and must never re-upload", ADR-0006. So no field type this repository ships would corrupt under that option, which is stronger than "the example was wrong", and the ADR now says so in those words.

Does correcting it weaken the case? Slightly, and the ADR says that too. What is left of that objection is an application-authored non-idempotent hook, which the framework cannot detect — real, but weaker than a shipped example.

What carries the decision instead is a fact I had recorded but not used: carrying the persisted row fixes resolveInput and leaves the other three failures exactly where they werevalidate over a record no caller submitted, beforeOperation/afterOperation double-firing, and the generation hook re-entering — while making them harder to see, because the payload now looks like a real application update. Those are the same three the "skip only resolveInput" option is rejected for, and they are measured, not argued. That, plus the whole-row write and the pure-function-of-the-row limit, is the rejection now.

Also corrected in the same record, from your previous round: the ADR said validate "can fail a write that already committed". It does not — the generation hook's try/catch swallows the ValidationError and reportGenerationFailure reported it as a transient provider failure with retry advice. The Context bullet now says that, since it is worse in kind and item 4 below is the fix.

2 — the sample, and what the class sweep found

rag-advanced.md:79 now calls writePluginOwnedField with the shipped signature, and the paragraph under it says which context (the first argument of runtime, not sudo()).

Swept the class — every call of the replaced write, every file, every form (writeUnderSudo(, writeEmbedding(, sudo().db.*.update(, and prose describing the write as happening "under sudo"), across docs/, .changeset/, examples/, packages/*/CLAUDE.md, CONTEXT.md and specs/.

Beyond the line you named it found three things:

  1. examples/rag-ollama-demo/README.md:220 — "Writes the vector and its metadata under sudo". Same stale description, in a user-facing example README; it survived last round's "three descriptions" sweep. Corrected.
  2. packages/rag/src/config/plugin.ts:534 — the stale "no sudo write to reach its write-denied column through" error string you flagged as item 6's tail. Corrected.
  3. The signature itself, in .changeset/quiet-columns-settle.md:46 and ADR-0066's Decisions bullet — both carrying the fieldConfig argument that item 6 removes. Corrected with the change.

Everything else that matched is a historical record that is correct as history: ADR-0045's own text (superseded by ADR-0066 as to spelling, which 0066 states), and specs/prisma-8/architecture-spec.md. docs/content/reference/context-api.md's sudo() examples are ordinary application deletes and reads, not this write. No other call in any form.

3 — undefined is refused by name

UndefinedPluginFieldWriteError. null still clears on either shape; undefined is the one value whose meaning would be decided by a field-shape detail the caller is not looking at, so it is refused rather than resolved.

4 — the standing errors are routed to the standing path

isRefusedWrite in generation-failure.ts classifies HandlelessPluginFieldWriteError, UnknownPluginFieldWriteError, UndefinedPluginFieldWriteError and WriteCollectionMissingError as standing, with its own headline ("core then refused the write that stores what it returned, by name … retrying the source write will not clear it"). Matched on Error.name rather than instanceof, so a second copy of stack-core on the tree cannot silently drop them back into the transient arm.

5 — the cast is gone

Plugin['runtime']'s sudo parameter is now declared () => StackContext, which is what getContext's sudo() has always returned, so context/index.ts:652 is plugin.runtime(context, sudo) with no cast. The param doc says the two differ and which one core surfaces take. auth's two sudo().db[...].findUnique uses are unaffected — db is the same surface on both. A plugin reaching ormHandle off sudo() was already getting undefined at runtime and now fails to compile.

6 — the property is enforced, not claimed

You were right that it was caller discipline: fieldConfig: {} wrote an unrelated column because the layout came from the argument.

The caller no longer passes a layout at all. writePluginOwnedField({ context, listName, id, fieldName, value }) resolves the field against the config the context was built from — AccessContext gains _config, set at both places core builds one (getContext, bindContextToTransaction) — and writes whatever that field's own splitColumns returns. A list the config does not declare, a field the list does not declare, and a context carrying no config are each refused by name (UnknownPluginFieldWriteError).

Verified in code, not assumed: _config is optional on the interface because AccessContext is a public type a test double may build (there are 6 such literals in-tree), and the write refuses a context without it rather than falling back to the caller's word — so optionality buys compatibility, not a bypass. The docblock, ADR and changeset now also state the limit plainly: this is a narrowing against mistake, not a privilege boundary, since a plugin holding context.ormHandle can already write any column on any row.

Your reproduction is now covered directly: fieldName: 'nowhere' throws list "Owned" declares no field "nowhere" and the row is unchanged.

Not touched

delete()'s un-assembled shape (yours to file), the null-dereference class (#1333), and the #1205 documentation sweep — agreed it should have been its own PR; splitting it now costs more than it saves.

Mutations

Mutation Fails
drop the value === undefined guard refuses undefined by name rather than wiping the field with it (1 failure)
ownedField returns a bare { type: 'text' } instead of refusing an undeclared field refuses a field the list does not declare, and writes nothing (1)
writePluginOwnedField ignores splitColumns ({ [fieldName]: value }) 5 failures, incl. writes the field's own columns past its write denial, clearing with null clears every column the field owns
remove the isRefusedWrite branch from the reporter all 4 new reports a write core refused by name — … — as a standing defect cases

All restored after.

Gates

Gate Result
pnpm lint pass — 0 errors, 2 warnings, both pre-existing
pnpm build (incl. docs) pass — 11/11
pnpm format / pnpm manypkg fix clean
packages/core pass — 1629 passed / 1 skipped, 83 files (1624 → 1629, +5 new)
packages/rag pass — 468 passed, 20 files (460 → 468, +4 cases × 2 projects)
packages/cli pass — 407 passed, 42 files

Both code samples I touched (the changeset's runtime snippet and rag-advanced.md's hook) were compiled against the built declarations in a scratch strict project under packages/rag/, harness falsified first: context: null reproduces TS2322: Type 'null' is not assignable to type 'AccessContext', and the old fieldConfig argument reproduces TS2353: … 'fieldConfig' does not exist in type 'PluginOwnedFieldWrite' — which is also what proves the declarations resolve rather than degrading to any. Changesets stay minor.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review — REQUEST CHANGES

Scope: commit 4456be4c only ("fix(core,rag): enforce the plugin write's field, refuse undefined, correct ADR-0066"). Earlier commits were reviewed in the previous two rounds and are not re-reviewed here.

Posted as a Comment review: the GitHub identity available to me is borisno2, the PR's own author, so GitHub refuses a formal REQUEST_CHANGES event. The verdict above is the review's verdict.

Verification performed: typechecked packages/core, packages/rag, packages/auth at 4456be4c (clean); ran packages/rag/src/config/plugin.test.ts (48 passed) and packages/core/src/access/multi-column-read-write.test.ts (33 passed); drove writePluginOwnedField directly against a stub ORM handle to probe the new enforcement; read every field-level resolveInput in the tree.


Blockers

1. The new enforcement is defeated by a prototype-chain key — packages/core/src/context/plugin-field-write.ts:78-96

ownedField resolves with bare index access (config.lists[listName], list.fields[fieldName]), so any key inherited from Object.prototype walks straight past the two === undefined guards. Probed at this commit against a stub handle:

call result
fieldName: 'constructor' on a list whose fields are { label } not refused — reaches updateFirst with { constructor: 'PWNED' }
fieldName: 'toString' not refused — reaches updateFirst with { toString: 'PWNED' }
listName: 'constructor' bare TypeError: Cannot read properties of undefined (reading 'label')
listName: '__proto__' same bare TypeError

Both halves matter, and both land on claims this commit makes:

  • The first two rows are exactly "write a column the named field does not own". list.fields['constructor'] resolves to the Object constructor, which is not undefined, so it is returned as the FieldConfig; its splitColumns is undefined, so the column set falls back to { [fieldName]: value } and the write is issued. "It reaches no field but fieldName, and that is enforced rather than left to the caller" (plugin-field-write.ts:123), "enforced, since the caller supplies no column layout" (CONTEXT.md:114), "The narrowing is enforced, not asserted" (ADR-0066) and the changeset's "enforced rather than asked of the caller" are all false for these inputs.
  • The last two rows defeat the error classification this same commit added. A bare TypeError has name === 'TypeError', so isRefusedWrite misses it and createGenerationFailureReporter routes it to the transient arm — printing "retry by writing the source field again" for a wiring defect that will never clear. That is precisely the misclassification section 5 of this PR exists to fix, reintroduced through the new code path.

This is not a privilege boundary and the docblock is right to say so — a plugin holding ormHandle can write anything. It is a correctness-of-claim defect, and the fix is two lines with an in-repo precedent the RAG plugin itself already uses for the identical hazard (packages/rag/src/config/plugin.ts:136-143, "Object.hasOwn rather than…"), alongside contract/derive.ts:67, config/label.ts:44 and validation/field-names.ts:105:

if (!Object.hasOwn(config.lists, listName)) return refuse(`the config declares no list "${listName}"`)
const list = config.lists[listName]
if (!Object.hasOwn(list.fields, fieldName)) return refuse(`list "${listName}" declares no field "${fieldName}"`)

Worth one test row each in multi-column-read-write.test.ts's new block, since the existing 'nowhere' case cannot catch this.

2. docs/content/how-to/rag-advanced.md:76-102 — the rewritten snippet contradicts the caption this commit added directly beneath it

The snippet passes the afterTransaction hook's own destructured context into writePluginOwnedField. The paragraph added sixteen lines below says "context here is the AccessContext Plugin.runtime receives as its first argument". Those are two different objects, and the page's own prose thirty lines above the snippet already describes the real mechanism correctly ("reached by way of a module-private symbol").

The shipped plugin deliberately does not do what the snippet shows: plugin.ts:288 reaches the writer through embeddingWriter(args.context), which only looks up context.plugins.rag — the writer itself closes over the runtime-time context (plugin.ts:506-514). That indirection is load-bearing. For a source write issued inside context.transaction(...), bindContextToTransaction binds ormHandle to the transaction client, plugin runtimes are not re-run (_sharedPlugins), and afterTransaction is drained by the owner registry after that transaction settles — so a plugin author copying this snippet issues the escalated UPDATE on a settled transaction handle, a failure that only appears under context.transaction(). The changeset's own sample (quiet-columns-settle.md:44-47) gets this right and is the model.

Either show the runtime-captured-context indirection, or drop the caption's claim. Given this PR's premise is that the delivery's documentation made false statements about itself, a snippet that contradicts its own caption is in scope.


Verified and handled — not re-raised

  • _config and the optional-member question. Every AccessContext core builds sets it: getContext (context/index.ts:637), bindContextToTransaction (write-pipeline.ts:259, from args.config), and field-visibility.ts:124 carries it through its spread. The only consumer refuses on absence. StackBaseContext genuinely omits it, so sudo()/getContext() return contexts that are refused by name rather than silently proceeding — the "compatibility, not a bypass" distinction holds.
  • The cast is gone, not relocated. No as unknown as AccessContext survives in production code; the remainder are pre-existing test doubles (including multi-column-read-write.test.ts:557, unchanged by this commit). auth's runtime: (context, sudo) only reaches sudo().db, identical on both types; packages/auth typechecks clean.
  • ADR-0066's correction is factually right, and the decision still holds. password()'s resolveInput does read inputData[fieldKey] and short-circuits on isHashedPassword (fields/index.ts:822-836); file()/image() pass an existing metadata object straight through (storage/src/fields/index.ts:314, :452). The survey is complete in effect — the tree ships exactly four field-level resolveInput hooks, and the fourth, calendarDay() (fields/index.ts:655), is also idempotent (value instanceof Date returns unchanged), so the "no shipped field type would corrupt" conclusion stands even though the enumeration omits it. Of the three replacement arguments, the double-fired beforeOperation/afterOperation and the generation hook's re-entry are decisive and untouched by carrying the row; validate is materially weaker under a whole-row payload than under the one-field one, which the record is honest about. Rejecting the option on what remains is sound.
  • Error classification. All four refusals set this.name explicitly (three in plugin-field-write.ts, WriteCollectionMissingError at secured/write.ts:67), so the name match is real rather than inheriting 'Error'. The name-over-instanceof reasoning is right for a duplicated stack-core copy, and the new it.each block constructs the genuine core classes, so a rename in core fails the rag test rather than silently regressing — the usual weakness of name matching is closed. embeddingWriter's missing-plugin throw sits outside the try, so it propagates loudly instead of being misreported as transient. The only gap is the TypeError in blocker 1.
  • The sweep. OwnedFieldLayout is gone everywhere; writeUnderSudo has no occurrences; no call site retains the four-argument form; no stale "under sudo" description of this write survives in packages/rag, docs/content/**/rag*, examples/rag-* or .changeset/. I found no fourth. The two remaining occurrences are docs/adr/0045 (immutable record, whose spelling ADR-0066 explicitly supersedes) and specs/prisma-8/architecture-spec.md:99,196 ("writing under sudo") — the latter is outside the PR's stated sweep scope and is a historical spec, but it is now the only living document that still describes the mechanism the wrong way.
  • Changeset. quiet-columns-settle.md is @opensaas/stack-core: minor / @opensaas/stack-rag: minor — correct for a new public export plus a behaviour change on both. Body matches the code, including the undefined refusal and the sudo retype.
  • No any, no new casts in the commit's production code; the three error types follow the repo's <Condition>Error + explicit .name + TSDoc-on-the-class convention.

Fix blocker 1, resolve blocker 2 one way or the other, and this is good to merge.

🤖 Generated with Claude Code

`ownedField` resolved the list and the field with a bare index into
config-derived records, so every Object.prototype key walked past both
`=== undefined` guards. `fieldName: 'constructor'` was not refused: the
lookup returned the Object constructor, whose `splitColumns` is
undefined, so the column set fell back to `{ constructor: value }` and
the write was issued — the write of a column the named field does not
own, which is the thing the docblock, ADR-0066 and the changeset all
claim is enforced. `listName: 'constructor'` threw a bare TypeError,
whose name is not one of the refusals, so it missed `isRefusedWrite` and
was reported through the transient arm with "retry by writing the source
field again" — the misclassification this PR exists to fix, reintroduced
through the new code path.

Both lookups are now gated by `Object.hasOwn`, as `ragPlugin`'s
`providerIsDeclared` already does for the identical hazard. Six cases
cover it, three per lookup, asserting the refusal by name — the name
being what routes it to a consumer's standing arm — and that the row is
untouched. A rag case pins the other half: a bare TypeError is reported
transient and always would be, so core refusing by name is the only
thing keeping a wiring defect out of that arm.

`rag-advanced.md`'s snippet passed the `afterTransaction` hook's own
context to `writePluginOwnedField`, contradicting the caption beneath it
and the plugin that ships. It now shows the indirection: the writer
closes over the context `runtime` was handed, and the hook uses its own
context only to look that writer up. The indirection is load-bearing —
inside `context.transaction(...)` the hook's context is bound to the
transaction client and `afterTransaction` drains after it settles, so
the collapsed form writes on a settled handle — and the prose now says
so, since a reader who does not know that will simplify it back.

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

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Addressed at 7a05f357. Both blockers fixed, nothing else touched.

1 — the prototype-chain key, reproduced then closed

I reproduced your probe first, at 4456be4c, as six failing tests against the real database rather than a stub — which made it worse than the stub showed:

call at 4456be4c
fieldName: 'constructor' not refused — reached the ORM, which rejected it as StructuredError: Unknown column "function Object() { [native code] }"
fieldName: 'toString' not refused — same, Unknown column "function toString()..."
fieldName: '__proto__' not refused — same, Unknown column "[object Object]"
listName: 'constructor' / 'toString' / '__proto__' bare TypeError: Cannot read properties of undefined (reading 'label')

The field rows confirm your reading exactly: the column name reaches updateFirst and appears in the database's own rejection, so the write is issued carrying a column the named field does not own. A schema that happened to have such a column would take it. And the error a real database raises here is StructuredError, whose name is not in REFUSED_WRITE_ERRORS either — so all six rows, not just the four you listed, land in the transient arm and print "retry by writing the source field again".

Both lookups are now gated by Object.hasOwn, in the form your precedent uses (plugin.ts:136-143):

const list = Object.hasOwn(config.lists, listName) ? config.lists[listName] : undefined
if (list === undefined) return refuse(`the config declares no list "${listName}"`)

const field: FieldConfig | undefined = Object.hasOwn(list.fields, fieldName)
  ? list.fields[fieldName]
  : undefined
if (field === undefined) return refuse(`list "${listName}" declares no field "${fieldName}"`)

Gating the existing guard rather than adding a second one beside it, so there is one refusal and one message per lookup, and an explicitly-undefined value is still caught rather than reaching .splitColumns.

Tests. Two it.each blocks in multi-column-read-write.test.ts, three names each, asserting UnknownPluginFieldWriteError, the message, and .name — the name being what routes it to a consumer's standing arm — plus that the row is untouched. The 'nowhere' case genuinely could not catch these, as you said.

The routing half needed one more test than that, because the core assertion only proves the name is right. plugin.test.ts now pins the other side: a bare TypeError is reported transient, and always will be — a dropped connection throws one too, so nothing downstream can classify it. That makes core refusing by name the only thing keeping this class out of the transient arm, which is the property the fix actually rests on.

Mutations (each applied alone, restored after):

Mutation Fails
field lookup back to a bare index 3 — refuses constructor/toString/__proto__, a field name the list inherits rather than declares
list lookup back to a bare index 3 — refuses constructor/toString/__proto__, a list name the config inherits rather than declares

Class sweep — bare index access into a config-derived record. Swept every added line under packages/core/src and packages/rag/src in this PR's diff against prisma-8. Beyond the two you named: nothing in production code. The full set of dynamic-key hits in added lines is five, and the other three are not the shape:

  • { [fieldName]: value } (plugin-field-write.ts:154) — a computed key in a fresh literal, which creates an own property even for __proto__, and is now unreachable for an inherited name anyway.
  • VECTORS[input] ?? DERIVED[input] and db[model] — both in test files, over test-local literals with test-local keys.

One adjacent instance worth recording, pre-existing and not touched here: context/index.ts:651, context.plugins[plugin.name] = plugin.runtime(context, sudo), writing into a {}. A plugin named __proto__ would set the prototype rather than register a member, and one named constructor would be found by every context.plugins.constructor reader. This PR only removed the cast from that line's right-hand side, and on your own scope note I have left it alone rather than making this a fourth thing. Happy to file it.

2 — the snippet now shows what ships

rag-advanced.md shows the indirection, modelled on the changeset's sample as you suggested: runtime: (context) => ({ [WRITE_EMBEDDING]: ... writePluginOwnedField({ context, ... }) }), and the hook reaching it with const write = embeddingWriter(context) — its own context used to find the writer, never to write with.

The caption is rewritten to say why rather than just what, since the failure is invisible until someone uses context.transaction(): the hook's context is a different object, bindContextToTransaction binds its ormHandle to the transaction client (verified — write-pipeline.ts:250, carrying plugins: context.plugins unchanged, which is why the writer found on it still holds the runtime-time context), and afterTransaction drains after that transaction settles, so the collapsed form issues the escalated UPDATE on a closed handle.

Compile. The corrected snippet compiles clean against the built declarations in a scratch strict project under packages/rag/ (moduleResolution: bundler, noEmit), harness falsified three ways first:

Mutation Error
context: null TS2322: Type 'null' is not assignable to type 'AccessContext'
the old fieldConfig: {} argument TS2353: 'fieldConfig' does not exist in type 'PluginOwnedFieldWrite'
a second argument to embeddingWriter(context) TS2554: Expected 1 arguments, but got 2

The first two also prove the declarations resolve rather than degrading to any. Scratch project removed.

Not touched

docs/adr/0045 and specs/prisma-8/architecture-spec.md — your observations, not blockers, and both are records that are correct as history.

Changeset

quiet-columns-settle.md stays minor on both packages. One clause added: the "refused by name" claim now says explicitly that it covers a name inherited from Object.prototype, since that is precisely where it was false.

Gates

Gate Result
pnpm lint pass — 0 errors, 2 warnings, both pre-existing
pnpm build (incl. docs) pass — 11/11
pnpm format / pnpm manypkg fix clean
packages/core pass — 1635 passed / 1 skipped, 83 files (1629 → 1635, +6)
packages/rag pass — 470 passed, 20 files (468 → 470, +1 case × 2 projects)
packages/cli pass — 407 passed, 42 files

🤖 Generated with Claude Code

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Code review — commit 7a05f357 only (/code-review, effort high)

Verdict: APPROVE. No blockers. Three follow-ups below, none of them introduced by this commit.

Posted as a Comment review rather than a formal Approve event: the reviewing GitHub identity is borisno2, the PR's own author, and GitHub refuses a formal review event on your own PR.

Scope is 7a05f357 (fix(core,rag): gate the plugin write's lookups with Object.hasOwn). Earlier commits were reviewed three times and are re-examined only where this one changes their meaning.


1. The gate — verified by reproduction, not by report

Both lookups in ownedField are gated, and I tried to defeat them again rather than trusting the six cases in the diff. Probing twelve Object.prototype names — constructor, toString, toLocaleString, valueOf, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, __proto__, __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__ — against both the list lookup and the field lookup, over a real PGlite database: all 24 refuse with UnknownPluginFieldWriteError, and all 24 return true from packages/rag's isRefusedWrite, i.e. every one routes to the standing-defect arm rather than "retry by writing the source field again". The gate is exact and the classification is by name, as claimed.

The commit message's account of the pre-fix behaviour checks out, including the part the previous review missed. Reverting each gate independently:

  • field gate removed → the three field cases fail with StructuredError: Unknown column "function…" / Unknown column "[object …". So the fallback { [fieldName]: value } did issue the write, and what came back was the database's own error typeStructuredError, which is not one of the four names in REFUSED_WRITE_ERRORS. Those three cases were therefore misclassified as transient too, not merely wrong writes. Six, not four, confirmed.
  • list gate removed → the three list cases fail with a bare TypeError, also unclassifiable.

The tests genuinely discriminate. I reproduced two mutations rather than trusting the report: dropping the field gate fails exactly the three field cases (36/39 pass); dropping the list gate fails exactly the three list cases. And the new rag case discriminates in the other direction — adding 'TypeError' to REFUSED_WRITE_ERRORS fails reports a bare TypeError as transient, which is why core refuses by name and nothing else. Suites green unmutated: core multi-column-read-write.test.ts 39/39, rag plugin.test.ts 49/49.

2. The docs snippet — accurate, and the explanation is load-bearing for the right reason

Every claim in the rewritten prose is verified against the shipping code, not just against the plugin:

  • getContext skips plugin runtimes when _sharedPlugins is passed (context/index.ts:644), and the transaction rebind passes context.plugins (context/index.ts:1049). So the writer found on a transaction-bound context is still the one closing over the runtime-time context. ✅
  • The transaction child context is built over opened.ormHandle — the transaction client — and runAfterTransactionForList is invoked from a closure capturing that context (transaction-boundary.ts:335-339), drained by settleTransactionOwner only after await settled. So the collapsed form would issue the escalated UPDATE on a settled handle. ✅
  • getContext's returned StackContext carries no ormHandle. ✅

The snippet now matches what ships (await write(listName, item.id, fieldName, {…}), writer closing over the runtime context). A reader following it would not write on a settled handle.

3. Gates

No any, no casts, no non-null assertions in the added production code. Changeset is accurate — the new clause about inherited keys matches the shipped behaviour — and minor is right for both packages (this changeset publishes writePluginOwnedField as a new @opensaas/stack-core/extend export).


Follow-ups (none blocking this commit)

F1 — the absence still matters, for exactly one shape: virtual(). (packages/core/src/context/plugin-field-write.ts:154, from 85007129)

The task's question — does the database error type's absence from the refusal list still matter anywhere else — has one surviving answer, and I reproduced it rather than reasoning about it. A declared virtual() field passes both Object.hasOwn gates, has no splitColumns, falls to { [fieldName]: value }, and issues an UPDATE naming a column that does not exist:

DECLARED PROBE shout: StructuredError | isRefusedWrite=false | Unknown column "shout" in table "Owned"

isRefusedWrite is false, so the RAG reporter tells the reader to "retry by writing the source field again" — the precise misclassification this commit exists to eliminate, for a wiring defect that fails identically on every row. Impact is a misleading log, not data loss. The fix is one more refusal in ownedField: a field with no splitColumns and no scalar column of its own name is a mistake the docblock already claims to refuse.

I also probed relationship() on suspicion it had the same shape. It does notwritePluginOwnedField({ fieldName: 'owner', value: otherId }) correctly moves ownerId from null to the target id. Recording that so nobody chases it.

F2 — embeddingWriter sits outside the try it is classified by. (packages/rag/src/config/plugin.ts:288, from 85007129)

This commit's own framing is "core refusing by name is the only thing keeping a wiring defect out of that arm" — which is what makes this worth naming: there is one wiring defect that never reaches the arm at all. const write = embeddingWriter(args.context) is one line above the try, and embeddingWriter throws a plain Error when context.plugins.rag is absent. That throw propagates out of afterTransaction, is collected by runAfterTransactionForList, and is rethrown as AfterTransactionError from the caller's create/update — failing a write whose row already committed, which directly contradicts the hook's own Known-limits block ("a provider failure is logged, not thrown … reporting it as a failure would invite a retry that duplicates the row"). Reachable when getContext catches a throw from any plugin's runtime (context/index.ts:652 logs and continues, leaving plugins.rag unset). Low reachability, wrong contract when reached. Move the lookup inside the try.

F3 — nit: the third lookup on the same call chain is still ungated. writeCollection(context.ormHandle, listName) indexes ormHandle[listName] bare (secured/write.ts:72). It is currently unreachable via this path — listName is now constrained to declared lists, and isWriteCollection's typeof === 'object' catches function-valued prototype keys into WriteCollectionMissingError, which is in the refusal set. Defence-in-depth only.

(One thing I checked and am not raising: Object.hasOwn(config.lists, …) throwing on a config with no lists. OpenSaasConfig.lists and ListConfig.fields are both non-optional, so a typed caller cannot reach it.)


Nothing in this commit introduces a fresh instance of the class it fixes. The one lookup it left ungated (F3) is closed by the gate it added.

@borisno2
borisno2 merged commit 3138401 into prisma-8 Sep 8, 2026
6 checks passed
@borisno2
borisno2 deleted the claude/qa-1128-write-surface-live branch September 8, 2026 01:02
borisno2 added a commit that referenced this pull request Sep 8, 2026
The base moved again during verification (#1332). Three conflicts, all
purely additive: `_config` and `_rowLock` are separate optional members that
each side appended to `AccessContext`, to `getContext`'s context literal and
to `bindContextToTransaction`'s. Both are kept in each.

`deriveResolveOutputContext` needed nothing — it spreads the context, so it
carries both new members already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
borisno2 added a commit that referenced this pull request Sep 8, 2026
…-junction-edge-create

Brings in #1332, which appends an optional `_config` member to the shared
`AccessContext` type and to the context literals in `getContext` and
`bindContextToTransaction`. This branch edits a different region of
`packages/core/src/context/index.ts` (`ServerActionProps` and the
`addRelated` handler), so git resolves both sides without a textual
conflict; the merged tree is verified to carry `_config` on every path
that builds a context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
borisno2 pushed a commit that referenced this pull request Sep 8, 2026
The check's fixtures are keyed `file:line`, and #1318 (de80685) and #1332
(3138401) rewrote the documents it reads. Merging origin/prisma-8 shifted
every line in rag-advanced.md and packages/rag/CLAUDE.md, and brought in
.changeset/swift-pandas-listen.md, which the check had been skipping because
the file did not yet exist on this branch.

The orphan arm did its job: 14 of the 27 keys matched no block and were
reported, so no shifted key silently excused a different block. The 13 that
still matched were re-read against their current diagnostics rather than
assumed, and each still describes the block it lands on.

Every entry here is re-derived from the block's actual diagnostic. Two
classifications crossed over in rag-advanced.md and would have been wrong had
the keys been moved mechanically: the block now at 523 is one entry of a
`lists:` object, not the `lists:` property its old key described, and the block
now at 880 is the `lists:` property, not the `fields:` one. Three more reasons
named things that are no longer true — the plugin hook at 66 closes over
`writePluginOwnedField` and `embeddingWriter`, not `writeUnderSudo`; the
`stored.vector` failure at CLAUDE.md:546 is a `{}` narrowed from `unknown`,
not `unknown` itself; and the `NearestMatch` mismatch at 910 is stated as the
diagnostic reports it.

The block redeclaring `EmbeddingProvider` with an optional `embedBatch` no
longer fails: #1318 made it required, so it now agrees with the shipped type
and needs no entry.

97 blocks: 70 compile, 27 classified fragments, 2 redeclare an exported type
and are checked against it. No failures, none stale, none orphaned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant