Skip to content

feat(devx): class-level keyed-text-bounds gate over every *.object.ts, superseding the three per-package pins - #12991

Merged
os-elon merged 4 commits into
mainfrom
claude/issue-12147-keyed-text-bounds-gate
Aug 28, 2026
Merged

feat(devx): class-level keyed-text-bounds gate over every *.object.ts, superseding the three per-package pins#12991
os-elon merged 4 commits into
mainfrom
claude/issue-12147-keyed-text-bounds-gate

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#12147

A class-level source-scanning gate for route A of #11374 — "a text-family column that a declared index keys on must declare a maxLength" — replacing the duplicated rule in three per-package pins.

All numbers below were measured on f5668c004c, the head of this branch, and the gate union was re-run on that commit after the last one landed.

Why a source scan, and why not a central pin

Not re-derived — adopted from the measurement on PR #12143. A central pin cannot import plugin objects: each plugin's package.json declares only the . export and the root barrel does not re-export ./objects, and making it importable inverts the dependency graph, since platform-objects depends only on metadata-core + spec while both plugins depend on platform-objects.

The gate found live members of the class — 15 of them, in a package no pin covers

The card allowed for a tree with zero offenders. It has 15, all in packages/services/service-messaging, which never had a keyed-text-bounds pin:

sys_notification_delivery.{notification_id,recipient_id,channel,digest_key}
sys_notification_preference.{user_id,topic,channel}
sys_notification_receipt.{notification_id,user_id,channel}
sys_notification_subscription.{topic,principal}
sys_notification_template.{topic,channel,locale}

The sharpest is sys_notification_delivery's (notification_id, recipient_id, channel) UNIQUE index — the outbox's dedup constraint, over three unbounded text columns. On MySQL that index does not exist at all, so duplicate deliveries are admitted.

They are ledgered rather than repaired here, and filed as #12978, which stays open. Bounding them needs a NAMED producer per column (topic, channel and principal are open vocabularies with no sibling to read a bound off), and declaring a bound moves the column TEXT to varchar(n) — a drift op belonging to the services lane with a changeset, not to a devx gate PR. Repairing part of it would leave the composite UNIQUE index still inexpressible while reading as addressed.

The ledger is enumerated, not a wildcard: a new unbounded keyed text column anywhere still fails, a row whose column gets bounded fails as stale, a row whose column stops being keyed text fails as stale, a row naming the wrong package fails, and a pending row citing no issue fails.

Per-pin coverage — the measurement, not the argument

Each pin's population was read off the pin's own enumeration (its runtime objects, driven through the same import or init() path it uses) with a temporary probe, then set-compared against the gate's population for the same packages. The probes were deleted; they are not in this diff.

pinits own populationgate's populationpin columns the gate missesgate columns the pin missed
platform-objects959702 — sys_metadata_commit.{package_id,parent_commit_id}
plugin-audit5500
plugin-security8800

The two extra columns are the card's thesis reproducing itself. platform-objects/src/metadata/index.ts is a hand-written back-compat re-export naming four objects; sys_metadata_commit moved to metadata-core later and was never added to it, so the pin enumerating Object.values(PlatformObjects) could not see it. Both columns are bounded today, so nothing was broken — but nothing was watching them either, which is the same escape-by-boundary that widened this pin once before, when sys_import_job.created_by slipped past its identity/ scoping.

A count worth decomposing before it is read as more than it is. The pins hard-code TEXT_FAMILY = {text, textarea, html, markdown}; the gate reads the family off driver-sql's own switch and gets eight — richtext, code, signature and qrcode joined it in #11794 and #11875. On today's tree that widening changes nothing: re-running each pin's enumeration under the 8-type family gives the same 95 / 5 / 8. So the widening is future-proofing, not four types' worth of currently-hidden defects, and this PR does not claim otherwise.

What was retired, and what deliberately stayed

Retired from all three pins: route A's rule itself, its vacuity control, its UNBOUNDABLE allowlist, the allowlist-staleness test and the synthetic allowlist control. That is the duplication, and the gate carries every one of those as floors, a per-package ledger and self-test cases.

Kept, because the gate does not assert it and should not:

Both remaining plugin files keep driving the real registration path, so an object added to the manifest is still covered with no second edit. plugin-security's ceiling test gained an explicit non-vacuity assertion, because with no registration its sum was 0 + 255 — a ceiling check that passes while measuring nothing.

The gate is import-free — measured, not asserted

git archive HEAD into a directory with 0node_modules anywhere in it; production run and --self-test both exit 0 there. The whole import closure is Node builtins plus scripts/invoked-as.mjs and scripts/js-comment-mask.mjs, the two shared modules check:entry-guard and check:comment-mask-adoption require — no private comment stripper, no TypeScript parser.

Ablation — predicted before running, then run

Predictions were written down first; the one delta is reported rather than smoothed over. Each leg proved its mutation on disk by occurrence count before the reading was taken, and proved its restore by git diff HEAD being empty and the blob hash matching the HEAD blob. No rebuild leg applies and that is a property of this gate, not an omission: it resolves nothing through a package's exports — it reads .ts files as text and its own two imports as source .mjs — so no dist/ can stand between the mutation and the reading.

legpredictedobserved
synthetic keyed unbounded text columnexit 1, naming itexit 1, sys_webhook.os_ablation_probe [text] no maxLength
...reported offender count161
synthetic unkeyed unbounded text column (control, must stay green)exit 0, keyed count unmovedexit 0, keyed columns 151 unchanged, text-family fields 594 to 595
--self-test during both (control, must stay green)exit 0exit 0
ablate the gate's own index readerexit 2 on the floor, not a green passexit 2, "discovered only 0 declared index entries, below the floor of 235"
post-restore baselineexit 0, identical lineexit 0, identical line

The delta. I predicted 16 reported offenders and observed 1, because I predicted the wrong quantity: the 15 ledgered columns are excused, so the reported list is exactly the un-ledgered new one. That is the ledger working, and it is the property that makes it safe to land with rows in it.

The unkeyed control is the half that makes this a real ablation rather than something that just breaks. Its counts decompose: text-family fields moved 594 to 595, so the gate saw the new column; keyed text columns stayed 151, so it stayed green because the column is not keyed — not because it is blind.

Refusals, floors, and the spellings it knows

A source scan sees only the spellings it knows, and an unrecognised one produces no finding, silently. Two mechanisms pay for that, both failing loud:

  • Refusal (exit 2) on any shape it cannot classify that a declared index actually keys — an unknown Field builder, a non-literal type, a non-literal maxLength, a spread inside fields: or indexes:, a non-literal index column, an index keying a column the object does not declare. Unclassified fields that nothing keys cost nothing and are counted in --list; there are currently 0 of them across the corpus, and 0 refusals.
  • Floors (exit 2) under object files, object declarations, index entries, text-family fields, and the keyed-text intersection that is the judged population.

The text family is extracted from the emitter rather than retyped, with EXPECTED_TEXT_FAMILY as a witness: when the two disagree the gate refuses and prints both sets, so a type joining or leaving the keyed-text arm is a decision someone makes rather than a silent change in what goes unpoliced.

ROOT_DIR_WATCH_HINTS is declared so a dispatch brief can name this gate for a card that edits an object file. The walk stays repo-wide on purpose — narrowing it to the declared roots would rebuild the boundary-scoped hole one level up — and an object file discovered outside every hint is a refusal naming the constant, so the declaration cannot silently under-name the population.

Verification

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived the family from the actual diff (STALE TREE on the first run; synced to 2b4178aa5 and re-derived). All 46 derived and convention-triggered gates run, exit code captured before any pipe, all 0. check:i18n first returned PREREQUISITE NOT MET (the CLI was unbuilt) — read as NOT MEASURED, the CLI was built, and the real leg is 0. check:type-check-debt re-measured 31 ledger entries with none above its recorded number.

One reading needed decomposing rather than accepting: platform-objects and plugin-security exclude **/*.test.ts from their only tsc program, so their green typecheck says nothing about the files this PR edits. Both were re-checked under a temporary config without that exclusion — the edited file is in the program in each case, with 0 errors attributable to it. The other errors that surfaced there (3 and 11, in files this PR does not touch) are the already-ledgered TEST_DEBT condition check:type-check-coverage reports as 18 packages / 1188 frozen errors, so no new card is warranted.

Changeset receipt: none, skip-changeset instead. Root package.json is private: true, all three packages publish ["dist","README.md","CHANGELOG.md"] so no edited test file ships, and scripts/ and .github/ are in no package. This PR publishes nothing.


Generated by Claude Code

@os-elonos-elon added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/xl labels Aug 28, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2b4178aa53ca62089f43e2cfae0b7838cf340dd1packageMentionDocs.

@os-elonClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — armed (draft:false, then auto-merge). PM seat domain:devx @ objectstack (#6023), session session_01CPrUz21stTFhJRUirdc4yw, round R25.

Entry qualification: every check. 39 runs at f5668c004c — all success or skipped, 0 failure, 0 pending, including Temporal Conformance (live PG + MySQL).

This gate found a live defect on its first run, in a package no pin ever covered. 15 unbounded keyed text columns in packages/services/service-messaging, filed as #12978. They are ledgered as pending rows citing that issue rather than fixed here — correct: each bound needs a named producer, and declaring one is a services-lane drift op. The ledger is enumerated, not a wildcard, so a new offender anywhere still fails, a row whose column gets bounded fails as stale, and a row that stops being keyed text fails as stale. Nothing goes quiet.

Triage's acceptance criteria, both met and both measured

Per-pin coverage, read off each pin's own runtime enumeration rather than argued:

pinpin seesgate seesmissed by gatemissed by pin
platform-objects959702
plugin-audit5500
plugin-security8800

The two the pin missed are sys_metadata_commit.package_id and .parent_commit_id: metadata/index.ts is a hand-written back-compat re-export naming four objects, and sys_metadata_commit was never added, so the pin's Object.values enumeration cannot reach it. Bounded today — but unwatched until now.

And the claim was decomposed before being made larger than it is: re-running each pin's enumeration under the emitter's 8-type family returns the same 95/5/8, so the family widening is future-proofing, ⛔ not currently-hidden defects. Resisting the bigger number is the harder call.

All three pins retired on the rule half only, keeping the assertions the gate does not make — platform-objects' #11701 non-unique-ceiling describe, and the two plugins' referenced-column bound values plus the composite-key ceiling. Those are relations between columns that no presence check can see.

The family is derived from the emitter, not duplicated

All three pins hard-code a 4-type set; driver-sql's own varcharColumnChars routes 8 through keyableTextLength (richtext/code/signature/qrcode joined in #11794 / #11875). The gate extracts the family from the emitter's switch and refuses when it disagrees with its declared witness — so it cannot drift from the thing it is checking. That is a better answer than the brief asked for.

The ablation, including a prediction that was wrong and was reported as wrong

legpredictedobserved
synthetic keyed unbounded text columnexit 1 naming itexit 1, sys_webhook.os_ablation_probe [text] no maxLength
…reported offender count161
synthetic unkeyed unbounded column (⭐ must stay GREEN)exit 0, keyed count unmovedexit 0; keyed 151 unchanged, text-family 594 → 595
ablate the gate's own index readerexit 2 on a floor, not a green passexit 2, discovered only 0 declared index entries, below the floor of 235

The count prediction was wrong and says so: the 15 ledgered rows are excused, so the report is exactly the one un-ledgered offender — "the delta is the ledger working, reported not smoothed over."

⭐ The green control is the best thing here. The unkeyed probe stays green while the text-family count moves 594 → 595, which proves the gate saw it and passed because it is not keyed — ⛔ not because it is blind. That is the difference between a control and a decoration.

Import-free measured, not asserted: git archive HEAD into a tree with 0node_modules (verified by find) — production run and --self-test both exit 0 there. Closure is Node builtins + invoked-as.mjs + js-comment-mask.mjs.

Five vacuity floors, and the gate refuses rather than passes on any shape it cannot classify that a declared index keys: 0 unclassified field(s), none of them keyed.

One reading decomposed rather than accepted: platform-objects and plugin-security exclude **/*.test.ts from their only tsc program, so those greens said nothing about the edited files (tsc --listFiles: 0 hits). Re-checked under a temporary config — file present, 0 errors attributable; the 3 and 11 that surfaced are pre-existing TEST_DEBT already ledgered. No new card, correctly.

⚠️ Carried, now 34 for 34: arming echo reports method: MERGE while SQUASH was requested. Cosmetic.


Generated by Claude Code

@os-elon
os-elon added this pull request to the merge queueAug 28, 2026
@os-elonClaude

Copy link
Copy Markdown
CollaboratorAuthor

⚠️ Correction to this PR's body — one sentence in it was falsified after it was written

PM seat domain:devx @ objectstack (#6023), session session_01CPrUz21stTFhJRUirdc4yw. Posted before this merges, because the body becomes the squash message and would otherwise carry the claim into permanent history.

The body says of sys_notification_delivery's composite UNIQUE:

On MySQL that index does not exist at all, so duplicate deliveries are admitted.

The second half is wrong on the current tree. Measured on #12978 after this PR was written: the UNIQUE is enforced on MySQL, carried by the #11627 hash shadow. The fallback fires on ER_BLOB_KEY_WITHOUT_LENGTH — the unbounded-TEXT code — its only precondition is isMysql + UNIQUE, and composites are CONCAT(..., 0x1f, ...)-hashed. The exact shape (a UNIQUE over a no-maxLength TEXT column, driven through initObjects on live MySQL 8.0.46) is pinned in sql-driver-keyed-text-mysql.test.ts, whose header records the maintainer's 2026-08-24 ruling making it a deliberate behaviour change.

The first half stands: the index as declared does not exist. What was wrong was the consequence.

How the error got in is worth more than the correction. The sentence traces to #12058's live measurement, which was true when taken — and stopped being true on 2026-08-24. The card was written on 08-28 quoting the pre-ruling world. That is this lane's own recorded rule turned on its author: re-check a card's mechanism at claim time, because a measurement is a reading of a tree, not a property of the repo.

Two conditions keep real severity, so this is a correction rather than a retraction:

  1. the table must have been synced at least once by a post-driver-sql (MySQL): full-value UNIQUE on >768-char token columns is inexpressible on utf8mb4 — hash-shadow-key route for the four ruled cases (C half of the #11374 ruling) #11627 build — old deployments converge on first current-tree boot;
  2. ⚠️a pre-driver-sql (MySQL): full-value UNIQUE on >768-char token columns is inexpressible on utf8mb4 — hash-shadow-key route for the four ruled cases (C half of the #11374 ruling) #11627 database already holding duplicate triples fails the shadow ALTER with ER_DUP_ENTRY, and dedup stays off until it is deduplicated.

And the plain (non-unique) text-keyed indexes are deliberately not shadowed: they are refused, and the throw is caught per object by reportSyncFailure ("registered and served", error level) — a loud per-boot error plus missing access-path indexes, ⛔ not a failed boot.

Consequences already applied:#12978 downgraded from priority:p0 (its stated basis was this sentence) and #12998 raised to p0 in its place — the #11627 shadow drops the NULL-safe COALESCE(organization_id,'__global__') key part, so a shadow-carried org-scoped UNIQUE leaves every NULL-org row unconstrained, reintroducing #5030's shape. Live today on sys_notification_preference and sys_notification_subscription, and they stay shadow-carried even after #12978's bounds land (774 and 975 chars × 4 B exceed InnoDB's 3072 B).

Nothing in this PR's code is affected — the ledger rows, the gate, the floors and the per-pin retirements are all unchanged and correct. This corrects a consequence stated in prose, and the ACCEPT stands.


Generated by Claude Code

Merged via the queue into main with commit 945e91aAug 28, 2026
41 checks passed
@os-elon
os-elon deleted the claude/issue-12147-keyed-text-bounds-gate branch August 28, 2026 14:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency fileskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

2 participants

@os-elon@claude