Skip to content

fix(metadata-protocol): withhold caught driver text from the seed loader's errors[].message (#8442) - #8533

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-8442-seed-loader-errors-message
Aug 13, 2026
Merged

fix(metadata-protocol): withhold caught driver text from the seed loader's errors[].message (#8442)#8533
os-zhuang merged 5 commits into
mainfrom
claude/issue-8442-seed-loader-errors-message

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes#8442

The third field in the family, after #8333's error string and #8441's code.

The premise, reproduced on current main

Driven in process through the real SeedLoaderService with an engine failing the way a driver really does — Object.assign(new Error('SQLITE_ERROR: no such table: sys_metadata'), { code: 'SQLITE_ERROR', errno: 1 }):

Failed to write acct record #0 (name=acme): SQLITE_ERROR: no such table: sys_metadata

Verbatim the issue's shape. seedApplied rides a 200 publish response (applySeedBodies passes r.errors straight through), so no HTTP boundary's message withhold reaches it.

Which populations reach the catch — the measurement that decides the card

populationdeclares status?declares VALIDATION_FAILED?tail todayunder #8333's rule ALONEcorrect
raw driver error (SQLITE_ERROR, errno)nonodriver textwithheldwithhold
objectql ValidationErrornoyesauthoring feedbackwithheld — REGRESSIONkeep
validation-RULE vetonoyesauthoring feedbackwithheld — REGRESSIONkeep
declared 4xx refusal (403)yesnoauthored sentencekeptkeep
declared 5xx + ledger code (503)no (5xx)nodriver textwithheldwithhold
bare Errornonodriver textwithheldwithhold
real driver UNIQUE violation (SqliteError, SQLITE_CONSTRAINT_UNIQUE)nonofull SQL + valueswithheldwithhold

The last row was added under review — see "Q2" below. It is measured on a real driver, not constructed.

The rule, and why it is neither sibling's

errors[].message is free text, so no catalog bounds it: #8441's membership rule governs code because that field writes a closed union (ADR-0112 D4), and it does not transfer. This is #8333's question — did the producer AUTHOR this sentence for a caller?

But #8333's answer — a numeric 4xx status — is measurably insufficient here, because this sink receives a population protocol.ts's collectors never see: the data engine's validation layer. Measured from the real class, @objectstack/objectql's ValidationError has own properties [stack, message, code, name, fields]code: 'VALIDATION_FAILED' and deliberately no status, because (@objectstack/types, validation-failure.ts) "deciding it means 400 is the job of whichever boundary serves it". For the seed channel this loader is that boundary, and VALIDATION_FAILED_STATUS = 400 is the repo already stating such a throw is a 4xx client refusal missing only the property.

So a sentence is quoted when the error declared itself a refusal by either shape: a 4xx status, or the VALIDATION_FAILED shape that validationFailureDetails already recognises — imported, not re-spelled, so the seed channel and the HTTP boundaries cannot drift about what counts as one.

⚠️ Why that distinction IS the card, not a nuance

On this producer the structured keys do not carry the offending field: field is the literal '(write)', and targetField / attemptedValue name the record's EXTERNAL key — i.e. which ROW. "Which key was rejected and why" (plan, max_length) exists only inside the validation sentence. Applying the 4xx test alone would have blanked exactly the per-record authoring feedback errors[] exists for, trading an authoring surface for a disclosure — the trade #8441 refused. Filtered, never deleted.

After the fix

Driver text withheld at both payload producers (pass-1 write, pass-2 deferred back-fill); authoring feedback intact. Every structured key untouched — built from the seed declaration and the record, never from the caught error — and the authored prefix unchanged byte for byte (two runtime pins read it). The withheld line still reaches logger.error, marked Cause (withheld from the seed response), so the operator half is intact on both passes (see Q1).

Scope: exactly the two payload producers the issue named. The loader's other errors[] messages (unresolved / dropped references, dynamic-value failures) are authored and never quoted a driver; the Cause: interpolations are LOG lines and keep the raw text by design.

The positive control, green — and real

packages/objectql/src/seed-loader-authoring-feedback.test.ts: a real ObjectQL engine, a real object declaring maxLength: 4, a genuinely malformed seed record (plan: 'enterprise'). No error constructed by hand:

recordIndex: 1 · attemptedValue: 'bad_row'
Failed to write sd_acct record #1 (name=bad_row): Plan must be ≤ 4 characters (got 10)

Which record, which key, why — and the clean row still inserted. Blank the tail unconditionally and this goes red.

Anti-vacuity

  1. The ValidationError double's shape was measured from the real class, not guessed (objectql cannot be imported into metadata-protocol — it depends on that package; the import would close a cycle), so the real own-property set was printed from inside objectql first.
  2. Section 6 asserts the fixtures' own properties: the exact own-key set, status/statusCodeundefined, and that validationFailureDetails — the production recogniser, imported into the test — accepts the validation double and rejects the driver fault.
  3. The positive control runs the real validator through the built package, so it does not depend on the double being faithful at all.

Reverse verification — both directions predicted BEFORE running

(a) seed-loader.ts reverted to pre-#8442. Predicted 6 red / 4 green; measured 6 red / 4 green, red set exactly as predicted. Section 5 predicted RED deliberately — it asserts the payload as well as the log, the miss both #8333 and #8441 recorded for their own operator-half case.

(b) The over-broad "just blank the tail" variant. Predicted 3 red / 7 green; measured 3 red / 7 green, exactly section 2's three authoring-feedback cases. The positive control was predicted RED and measured RED.

No prediction missed in either direction. The fix was committed before the reverse runs, so each restore came out of a real commit, and the restored tree was confirmed byte-identical to the committed state.


Review round — three questions from the PM

Q1 — the pass-2 operator half was neither marked nor pinned. Fixed.

Measured: the pass-2 logger.error and the pass-2 withhold are sequential statements in the same catch block, so the log does fire on exactly the path that withholds, and it did already carry the raw cause. The PM's two objections were both correct anyway:

  • It lacked the marker. Pass-1 said Cause (withheld from the seed response); pass-2 said plain Cause:. An operator reading a pass-2 line had no signal the reporter received a different sentence. Both passes now share one vocabulary via seedCauseLabel(err), which marks the label exactly when quotableSeedFailureDetail withheld.
  • Nothing pinned it. Section 5 drives pass 1 only, and section 1's pass-2 case never asserted on its logger — payload pinned for both passes, operator half for one. Section 1's pass-2 case now asserts the driver sentence reaches logger.errorand that it carries the withheld marker.

Q2 — can driver text arrive wrapped as a validation failure? Measured: no.

Answered empirically rather than by reading, on the real stack — real SqlDriver (better-sqlite3, on disk), real ObjectQL, real SeedLoaderService, a duplicate on a unique column. The driver raises:

SqliteError name: 'SqliteError' code: 'SQLITE_CONSTRAINT_UNIQUE'
own properties: [stack, message, code] status: undefined
validationFailureDetails(...) -> undefined

It does not wear the validation shape, so the quoting limb never opens for it. Nothing converts it on the way up: between the driver and this catch there is only ObjectQL, whose own ValidationError throws are authored — reference_not_found built from the message catalog, and a re-wrap of already-authored fields. The engine's unique-violation branch is an autonumber resync that retries and rethrows the original untouched. The conversions that do exist (mapDataError, resolveThrownHttpError) live at HTTP boundaries that consume this loader's output; they are downstream of this producer and can never wrap the engine's throw on its way into it. Every other VALIDATION_FAILED-shaped producer in the repo was read and each carries a hardcoded authored sentence.

⚠️ Worth flagging: that raw message contains the full INSERT statement including the seeded values — a strictly worse disclosure than the table name the issue reported. It 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 (no insert into, no dup@example.com), plus non-vacuity assertions that the driver really rejected the duplicate and that the error really is not validation-shaped.

The predicate was not widened, so the authoring-feedback trade this card exists to protect is unchanged.

Q3 — the type-check ratchet drift. Attributed to me, and fixed — no ledger raised.

Prediction, recorded before measuring: mine — the drifted packages were exactly the ones I added test files to.

Measured, and stronger than a base comparison: every drifted error sits inside test files this PR creates, which cannot have existed at the merge base, and the per-file counts match the deltas exactly.

packageledgerdriftlocated inerror
metadata-protocolDEBT 63 → 65+2seed-loader-driver-text.test.tsTS2352 ×2
objectqlTEST_DEBT 355 → 356+1seed-loader-authoring-feedback.test.tsTS2554
runtimeTEST_DEBT 227 → 228+1the new real-driver pinTS2554

(runtime's +1 appeared after the CI run — it is the Q2 pin added in this round.)

Fixed rather than ledgered:

  • TS2352 ×2 — section 6 cast Error & {...} straight to Record<string, unknown>. Now cast through unknown, which is what the diagnostic itself prescribes.
  • TS2554 ×2registry.registerObject takes (schema, packageId, …). 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.

pnpm check:type-check-debt now: 33 ledger entries re-measured, 1969 raw errors total, none above its recorded number, surplus none.

The gate-derivation gap, stated rather than patched over

My earlier report listed check:type-check-coverage as union-green. That is the wrong invocation: CI's ratchet step is pnpm check:type-check-debt (lint.yml:1357), which is the same script with --re-measure. The union-of-two-lists rule could not catch this — both lists name a script, and what differed was the flag. Compounding it, --re-measure refuses to run without a fully built workspace closure, so it is the gate least likely to be run incidentally.

Filed as #8545: dispatch-gates.mjs:396 emits check:type-check-coverage while giving the ratchet as its rationale, so the command it names cannot reproduce the failure it warns about.

Verification

@objectstack/metadata-protocol 1245 tests green · @objectstack/objectql 3544 green · @objectstack/runtime 2320 green · downstream consumer @objectstack/rest 1870 green (consumer sweep used the prefix form ...@objectstack/metadata-protocol).

Gates re-derived with dispatch-gates.mjs against the 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-debt (the ratchet, this round), plus check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-engine-split-ratio. Control-byte self-scan over all changed files: clean.

check:objectui-pin-fresh is red, pre-existing and unrelated: .objectui-sha is untouched here and the gate fires identically on main.

Notes for the PM


Generated by Claude Code

@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 6:35pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
os-zhuangand others added 2 commits August 13, 2026 17:52
…e real-driver withhold (#8442)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
… drift this card introduced (#8442)
All four new tsc errors are inside test files this PR adds, so they cannot
have existed at the merge base. Fixed rather than ledgered:
- metadata-protocol (+2, TS2352): section 6's fixture inspection cast an
`Error & {...}` straight to `Record<string, unknown>`. Now cast through
`unknown`, which is what the diagnostic itself prescribes.
- objectql / runtime (+1 each, TS2554): `registry.registerObject` takes
`(schema, packageId, ...)`. 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 was raised. `check:type-check-debt` re-measures 33
entries with none above its recorded number, surplus none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 18:51
@os-zhuang
os-zhuang added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit d08ba50Aug 13, 2026
29 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8442-seed-loader-errors-message branch August 13, 2026 19:08
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

seed-loader interpolates driver text into errors[].message, which rides on seedApplied of a 200 publish response

1 participant

@os-zhuang