Repository navigation
seed-loader interpolates driver text into errors[].message, which rides on seedApplied of a 200 publish response #8442
Description
Activity
- addedbugSomething isn't workingSomething isn't workingand removed
on Aug 13, 2026 Claiming this card.
- session:
session_012WMpuAfA2KSdDjGF6tm1bH - branch:
claude/issue-8442-seed-loader-errors-message
Plan: reproduce the premise on current
main(after #8333 / #8441 landed), enumerate the populations reaching eachseed-loader.tscatch, decide which of the two family rules (status-declared quote-back vs. catalog membership) this free-text sink asks for, then filter — keeping every structured per-record authoring key.
Generated by Claude Code
- session:
{ "issue": 8442, "status": "done", "branch": "claude/issue-8442-seed-loader-errors-message", "pr": "https://github.com/objectstack-ai/objectstack/pull/8533", "premise_still_valid": true, "summary": "Premise reproduced verbatim on main: a sys_metadata outage under a seed write answered `Failed to write acct record #0 (name=acme): SQLITE_ERROR: no such table: sys_metadata` on errors[].message, riding a 200 publish response. The rule is #8333's QUESTION (free text, no catalog bounds it, so #8441's membership rule does not transfer) but NOT #8333's ANSWER: this sink receives a population protocol.ts's collectors never see, the data engine's validation layer, whose ValidationError carries code VALIDATION_FAILED and deliberately NO status. Measured own-property set from the real class: [stack, message, code, name, fields]. So a sentence is quoted when the error declared a refusal by EITHER shape — a 4xx status, or the VALIDATION_FAILED shape that @objectstack/types' validationFailureDetails already recognises (imported, not re-spelled). That distinction is the card, not a nuance: on this producer `field` is the literal '(write)' and targetField/attemptedValue name the record's EXTERNAL key, so 'which key was rejected and why' exists ONLY in the validation sentence — the 4xx test alone would have blanked exactly the authoring surface the card's warning protects. Both payload producers fixed (pass-1 write, pass-2 deferred back-fill); structured keys and authored prefix untouched; withheld text still reaches logger.error marked as withheld. protocol.ts was NOT edited, so the 3-editor cap needed no sequencing.", "tests": "metadata-protocol 84 files / 1245 tests green; objectql 199 / 3544 green; runtime 151 / 2319 green; downstream consumer rest 113 / 1870 green (consumer sweep used the PREFIX form '...@objectstack/metadata-protocol'). New pins: packages/metadata-protocol/src/seed-loader-driver-text.test.ts (10 cases) + packages/objectql/src/seed-loader-authoring-feedback.test.ts (positive control, real ObjectQL engine). POSITIVE CONTROL, real not synthesized — a genuinely malformed seed record (plan 'enterprise' into maxLength 4) through the REAL validator: recordIndex 1, attemptedValue 'bad_row', message 'Failed to write sd_acct record #1 (name=bad_row): Plan must be <= 4 characters (got 10)', clean row still inserted (totalInserted 1). REVERSE VERIFICATION, both directions predicted BEFORE running: (a) seed-loader.ts reverted to pre-fix, predicted 6 red / 4 green, MEASURED 6 red / 4 green with the red set exactly as predicted — section 5 (operator half) predicted RED deliberately because it asserts the payload too, the miss #8333 and #8441 each recorded; (b) over-broad 'blank the tail always' variant, predicted 3 red / 7 green, MEASURED 3 red / 7 green, exactly section 2's three authoring-feedback cases, and the positive control predicted RED and measured RED. No prediction missed in either direction. ANTI-VACUITY: the ValidationError double's shape was MEASURED from the real class (printed from inside objectql, which metadata-protocol cannot import — it depends on that package and would close a cycle) rather than guessed; test section 6 asserts the exact own-key set, status/statusCode undefined, and that the production recogniser validationFailureDetails accepts the validation double and REJECTS the driver fault; and the positive control runs the real validator through the BUILT package so it does not depend on the double at all. Also verified validateRecord is NOT gated on isSystem, so the validation population is genuinely reachable on seed writes (only state-machine rules are seed-exempt). Gates re-derived with dispatch-gates.mjs against actual changed paths, union green: check:nul-bytes, check:error-code-casing, check:durability-log-level, check:engine-double-contract, check:cross-package-test-inputs, check:filter-alias-parity, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-engine-split-ratio, check-cross-package-test-inputs. check:objectui-pin-fresh red — pre-existing, .objectui-sha untouched, fires identically on main. Control-byte self-scan over all changed files: clean. Head SHA 0c985edd99c41fa43efd9b984170aa5a5b3ce7e8.", "open_questions": [], "out_of_scope_findings": [] }Two PM mechanism assumptions to correct:
seed-loader.ts, notprotocol.ts— confirmed. Both payload producers are inseed-loader.ts. Noprotocol.tsedit, no editor-cap sequencing needed.- "Both siblings are on
main" was FALSE at dispatch. A raw drivercode(SQLITE_ERROR) still reaches client-facing payloads through the batch verbs'codelimb — the sibling field #8333 did not cover #8441's PR fix(metadata-protocol): filter the batch verbs'failed[].codethrough the error catalog (#8441) #8505 was still open when this card started —maincarried only [finding]metadata-protocol's batch verbs still put caught error text on client-facing payloads — the 8 producers option C did not reach #8333 (f58b1a88). The premise was therefore measured against that tree. fix(metadata-protocol): filter the batch verbs'failed[].codethrough the error catalog (#8441) #8505 merged mid-run asc2c67bf551;origin/mainis merged into this branch and the full suites were re-run afterwards. The card was not spent either way: fix(metadata-protocol): filter the batch verbs'failed[].codethrough the error catalog (#8441) #8505 touches onlyprotocol.ts'scodelimb and never reaches this file.
Two toolchain notes worth carrying to the next card in this lane:
packages/objectql's test reads metadata-protocol's dist, so the positive control only means anything afterpnpm --filter @objectstack/metadata-protocol build; and an unbuilt closure makes@objectstack/runtimereport 124 failed files with 432 tests passing — the resolution-failure signature, not a regression.
Generated by Claude Code
{ "issue": 8442, "status": "done", "branch": "claude/issue-8442-seed-loader-errors-message", "pr": "https://github.com/objectstack-ai/objectstack/pull/8533", "premise_still_valid": true, "summary": "Review round complete; all three PM questions settled by measurement and pushed in one commit (head 44ab858bd354dd7259e0d38b3ecc4b8a5c6207b5). Q1 (pass-2 operator half): measured that the pass-2 logger.error and the pass-2 withhold are sequential statements in the SAME catch block, so the log does fire on the withholding path and did already carry the raw cause — but both PM objections held anyway. It lacked the withheld marker (pass-1 said 'Cause (withheld from the seed response)', pass-2 said plain 'Cause:'), and nothing pinned it: section 5 drives pass 1 only and section 1's pass-2 case never asserted on its logger. Both passes now share one vocabulary via seedCauseLabel(err), and section 1's pass-2 case asserts the driver sentence reaches logger.error AND carries the marker. Q2 (wrapped driver text): measured NO on the real stack — a real SqlDriver UNIQUE violation arrives as SqliteError, own properties [stack, message, code], code SQLITE_CONSTRAINT_UNIQUE, status undefined, and validationFailureDetails returns undefined, so the quoting limb never opens for it. Nothing converts it on the way up: between driver and catch there is only ObjectQL, whose ValidationError throws are authored (reference_not_found from the message catalog; a re-wrap of already-authored fields), and whose unique-violation branch is an autonumber resync that rethrows the original untouched. mapDataError and resolveThrownHttpError live at HTTP boundaries that CONSUME this loader's output — downstream of this producer, never between driver and loader. The predicate was NOT widened. Q3 (ratchet drift): attributed to me and fixed with no ledger raised. protocol.ts still untouched.", "tests": "Q2 revealed a WORSE disclosure than the issue reported and it is now pinned: the raw SqliteError message carries the full INSERT statement INCLUDING the seeded values, not just a table name. packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts asserts over the WHOLE payload (no 'insert into', no 'dup@example.com', no 'UNIQUE constraint failed'), plus non-vacuity assertions that the driver really rejected the duplicate and that the error really is not validation-shaped. Q3 ATTRIBUTION — predicted before measuring that the drift was mine because the drifted packages were exactly the ones I added test files to; measured stronger than a base comparison: every drifted error sits INSIDE test files this PR creates, which cannot have existed at merge base, and per-file counts match the deltas exactly. metadata-protocol DEBT 63->65 (+2, TS2352 x2, both in seed-loader-driver-text.test.ts section 6 casting Error & {...} straight to Record<string, unknown> — now cast through unknown as the diagnostic prescribes); objectql TEST_DEBT 355->356 (+1, TS2554 in seed-loader-authoring-feedback.test.ts); runtime TEST_DEBT 227->228 (+1, TS2554 in the new Q2 pin, which post-dates the CI run). Both TS2554s: registry.registerObject takes (schema, packageId, ...) and the one-argument form copied from neighbouring fixtures is ITSELF part of those packages' frozen TEST_DEBT, so it was not replicated — both calls now pass a package id. NO DEBT/TEST_DEBT entry raised: pnpm check:type-check-debt now reports 33 ledger entries re-measured, 1969 raw errors total, none above its recorded number, surplus none. Suites after the round: metadata-protocol 1245 green, objectql 3544 green, runtime 2320 green, downstream rest 1870 green. Gate union re-run green including check:type-check-debt this time; control-byte scan clean. check:objectui-pin-fresh red, pre-existing, .objectui-sha untouched.", "open_questions": [], "out_of_scope_findings": [ "filed as #8545: dispatch-gates.mjs:396 emits `check:type-check-coverage` while giving the RATCHET as its rationale, but the ratchet is `check:type-check-debt` (same script, --re-measure; lint.yml:1357) — so the command the derivation names cannot reproduce the failure it warns about" ] }On the gate-derivation miss — stated, not patched over. My earlier report listed
check:type-check-coverageas union-green. That was the wrong invocation. I ranpnpm check:type-check-coverage(self-test + plain run). CI's ratchet step ispnpm check:type-check-debt(lint.yml:1357) — the same script with--re-measure. The--self-testhalf I ran did pass (23+24+25+28+9 cases); the--re-measurehalf never ran locally at all.Your diagnosis of why the union rule could not catch it is exactly right, and I'd add one aggravating factor:
--re-measurerefuses to run without a fully built workspace closure (it throws rather than measure a different world, per #6376), so it is the gate a dev seat is least likely to have run incidentally — I had to run a full 70-package turbo build before it would produce a verdict at all.I do think the derivation should name the invocation rather than the script, and filed it as #8545 with that as the suggested shape, plus a note that where any one script has multiple CI invocations the same rule should apply — worth a sweep of
CHANGE_KIND_GATESto see whether this is a one-off or a class.One thing I did not do: I did not run the base-worktree comparison you specified in step 1. The per-file attribution made it unnecessary — a file that does not exist at the merge base cannot have contributed errors there, and all four errors are inside files this PR adds, with counts matching the deltas exactly. If you'd still like the 63/355/227 base numbers on the record I can measure them, but it would be confirming a conclusion the file paths already settle.
Generated by Claude Code
Landed —
d08ba506onorigin/main(PR #8533, squash-merged, 18:52:00Z). Confirmed by both readings: the merge event, and the commit present onorigin/main.Third in the family, after #8333's
errorstring and #8441'scode.This card's premise was understated, and the review round is what found it
The issue reported a leaked table name:
SQLITE_ERROR: no such table: sys_metadata. Driving a realSqlDriver(better-sqlite3, on disk) through a realObjectQLand a realSeedLoaderServicewith a duplicate on auniquecolumn, the rawSqliteErrormessage turns out to carry the full INSERT statement including the seeded values.So the disclosure was data, not just schema shape. That is now withheld and pinned in
packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts, with the assertion taken over the whole payload (noinsert into, nodup@example.com, noUNIQUE constraint failed) plus non-vacuity assertions that the driver really rejected the duplicate and that the error really is not validation-shaped.That measurement was made answering a review question aimed at something else — whether driver text could arrive wrapped as a validation failure. The answer to that was no (
SqliteErrorown properties[stack, message, code],code: SQLITE_CONSTRAINT_UNIQUE,statusundefined,validationFailureDetails→ undefined; and nothing converts it on the way up, becausemapDataError/resolveThrownHttpErrorlive at HTTP boundaries that consume this loader's output rather than sitting between driver and loader). The question found nothing where it was looking and something worse next to it.The rule, and why neither sibling's transfers
errors[].messageis free text, so no catalog bounds it — #8441's membership rule governscodebecause that field writes a closed union (ADR-0112 D4). This is #8333's question (did the producer author this sentence?) but measurably not #8333's answer, because this sink receives a populationprotocol.ts's collectors never see: the data engine's validation layer. An@objectstack/objectqlValidationErrorcarriescode: 'VALIDATION_FAILED'and deliberately nostatus— deciding it means 400 is "the job of whichever boundary serves it", and for the seed channel this loader is that boundary.So a sentence is quoted when the error declared itself a refusal by either shape: a 4xx
status, or theVALIDATION_FAILEDshape thatvalidationFailureDetailsalready recognises — imported, not re-spelled, so the seed channel and the HTTP boundaries cannot drift about what counts as one.That distinction is the card rather than a nuance: on this producer
fieldis the literal'(write)'andtargetField/attemptedValuename the record's external key — i.e. which row. "Which key was rejected and why" exists only inside the validation sentence, so the 4xx test alone would have blanked exactly the per-record authoring feedbackerrors[]exists for. Filtered, never deleted, and pinned through the real validator.Review round
- Pass-2's operator half was neither marked nor pinned. The log did fire on the withholding path (same catch block, measured) — but it said plain
Cause:where pass-1 saidCause (withheld from the seed response), and nothing asserted either. Both passes now share one vocabulary viaseedCauseLabel(err), and the pass-2 case asserts the log. - Type-check ratchet drift was attributed (every drifted error inside test files this PR creates, per-file counts matching the deltas) and fixed, with no ledger entry raised. Notable: the
registry.registerObjectone-argument form copied from neighbouring fixtures is itself part of those packages' frozen TEST_DEBT, so it was not replicated.
Residue
dispatch-gates.mjsnamescheck:type-check-coveragefor a rationale onlycheck:type-check-debtsatisfies — the ratchet half never runs locally #8545 —dispatch-gates.mjsnamescheck:type-check-coveragewhile giving the ratchet as its rationale; the ratchet ischeck:type-check-debt.- A green gate union proves nothing about the head it is reported against — nothing ties the run to a commit, so a mid-round union goes stale silently #8550 — a green gate union proves nothing about the head it is reported against; nothing ties a gate run to a commit.
Generated by Claude Code
- Pass-2's operator half was neither marked nor pinned. The log did fire on the withholding path (same catch block, measured) — but it said plain
- added a commit that references this issue
on Aug 17, 2026
Filed by the dev on #8333 while landing the P6–P13 producer sweep (PR #8436). Unassigned; nobody is on it. Outside that card's scope: #8333 enumerates eight producers in
protocol.ts, and this is a different file reached through one of them.The measurement
#8333's P9 is
applySeedBodies'errorfield, now withheld. But the same response object carries a second channel,errors[], which the seed loader fills itself — and that one still interpolates the caught driver text.Reproduced end to end (a
sys_metadataoutage under a realapplySeedBodiescall), the returnedseedApplied:{ "success": false, "inserted": 0, "updated": 0, "errors": [ { "sourceObject": "acct", "field": "(write)", "targetObject": "acct", "targetField": "name", "attemptedValue": null, "recordIndex": 0, "message": "Failed to write acct record #0 (name=): SQLITE_ERROR: no such table: sys_metadata" } ] }Producers, read from source in
packages/metadata-protocol/src/seed-loader.ts:message: `Failed to write ${objectName} record #${recordIndex} (${label}=${keyValue}): ${message}``Failed to write deferred reference: … ${err?.message ?? String(err)}`seedAppliedrides on a 200 publish response (publishMetaItem,publishPackageDrafts, and the runtime package door), so no HTTP boundary's 5xx message withhold reaches it — the same argument #8086 made for fixing option C at the producer.The tension a taker must resolve, and why this is not a mechanical repeat of #8333
errors[]is not theerrorstring. Its entries are structured, per-record authoring feedback — which object, which record index, which field, what value was attempted — and that is exactly what an author fixing a broken seed needs (build-probes.tstells the user to "check the publish response's seedApplied for the load error"). So the #8333 rule cannot simply be applied: the driver's sentence is the only part that must go, and the located structure must stay.The likely shape is to keep every structured key and replace only the interpolated tail, with the driver text going to the log — but that is a judgement about an authoring surface and should be measured per producer the way #8333's step 1 was, not assumed.
Related
#8333 (the eight
protocol.tsproducers, PR #8436) · #8136 · #8086Generated by Claude Code