Uh oh!
There was an error while loading. Please reload this page.
fix(service-messaging): sourced maxLength on all 15 keyed text columns of the sys_notification_* objects - #13018
Conversation
…t columns of the sys_notification_* objects Every bound names its producer in the declaration (#11374 route A): id-family 255 (referenced physical id column), channel 64 (registered MessagingChannel.id machine vocabulary, sys_session.revoke_reason precedent), topic 200 (= sys_notification.topic), digest_key 331 (derived recipient|channel|window), principal 520 (widest declared selector arm owner_of), locale 16 (= sys_email_template.locale). Retires the 15 pending allowlist rows in check-keyed-text-bounds.mjs (the ledger is empty as a result) and adds the value-pin test for the relations the existence gate deliberately does not check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d7a8bf266f68bf6a693a843651f81370251bdcb6 && git checkout d7a8bf266f68bf6a693a843651f81370251bdcb6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 87042b5de78fdb327b6a744cff76dd23a6a7b54c 2dbd57f2bbf57da1283c075f7cc19a8993aa01af && git checkout -B drift-repro 87042b5de78fdb327b6a744cff76dd23a6a7b54c && git merge --no-ff 2dbd57f2bbf57da1283c075f7cc19a8993aa01af
node scripts/docs-audit/affected-docs.mjs --json 87042b5de78fdb327b6a744cff76dd23a6a7b54c |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12978
What this does
All 15 keyed text-family columns across the five
sys_notification_*objects inpackages/services/service-messagingnow declare a sourcedmaxLength, each with its producer named in the declaration (#11374 route A), and the 15pendingallowlist rows inscripts/check-keyed-text-bounds.mjsare retired in the same change — the ledger is empty as a result (gate summary at2dbd57f2bb: "148 keyed text-family columns judged, 148 bounded. Allowlist: 0 pending, 0 unboundable"). A value-pin test (notification-keyed-text-bounds.test.ts) pins the RELATIONS the existence gate deliberately does not check — the same division of labour as the plugin-audit pin.The dedup unit closes whole (ruling 1):
sys_notification_delivery's(notification_id, recipient_id, channel)UNIQUE is bounded on all three columns together — 255+255+64 = 574 chars = 2296 utf8mb4 bytes, inside InnoDB's 3072-byte per-key budget (in-tree measured boundary: varchar(768) keys, varchar(769) refuses), so on MySQL it becomes a DIRECT index instead of a #11627 hash-shadow carry, and the plain text-keyed indexes (refused on every boot today) become creatable.Per-column bound + producer (ruling 2 — each stated in the declaration)
No column stays
pending: every one of the 15 sourced honestly.Composite-key audit (utf8mb4, 4 B/char, 3072 B per key)
sys_notification_deliveryUNIQUE 574 chars / 2296 B — direct. All its plain indexes fit (digest_keycomposite: 331 + status select-width + numeric = under budget).sys_notification_receiptUNIQUE 574 chars — direct.(user_id, state)fits.sys_notification_template(topic, channel, locale)280 chars — direct.sys_notification_preferenceorg-scoped UNIQUE (255+200+64 + COALESCE(organization_id) 255 = 774 chars) andsys_notification_subscriptionorg-scoped UNIQUE (200+520+255 = 975 chars) exceed the budget on MySQL and stay carried by the landed 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 hash shadow — enforced, not skipped, with one caveat: the shadow drops the NULL-safe COALESCE key part, so NULL-organization rows are unconstrained under it. That is pre-existing, tracked as driver-sql (MySQL): the #11627 hash shadow drops the NULL-safe organization key part — a shadow-carried org-scoped UNIQUE reintroduces #5030's NULL-org zero-constraint #12998 (now carrying the p0, dispatched separately — its fix is not assumed here and not folded in). Trimming a sourced number to squeeze under the budget is exactly the failure route A exists to prevent, so the sourced numbers stand and the carrier is disclosed. The wider768+composite class itself remains 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/#11627 population re-measured after the A half: three additional members of the >768/unboundable keyed-text class (resource_id 1024, sys_verification [value], sys_account [issuer+account_id]) #11701's; those cards remain open and are out of scope here.Operational consequences on existing deployments — measured, not assumed
varchar(n); every declared index is created directly (the two wide org-scoped UNIQUEs above ride the shadow on MySQL).narrow_varcharrequires a physical varchar (isCharacterColumn, driver-sql: the string family ignoresmaxLengthtoo — a declared 1024 becomes varchar(255) and refuses legitimate writes #11431 deliberately excludes TEXT). Measured with duals (pure-function harness over exporteddiffManagedTable/diffManagedIndexesagainst this branch's built driver-sql, at2dbd57f2bb): A1 mysql bounded-over-TEXT ⇒ no entry; A2 dual varchar(65535) ⇒narrow_varchar@ destructive; A3 postgres ⇒ no entry; B1 missing UNIQUE ⇒create_index@ safe; B2 dual ⇒recreate_index@ destructive. SorunArtifactBootMigrationGate(refusesdestructiveonly) does not refuse over this change — the earlier boot-refusal framing was withdrawn on this measurement. What changes on these dialects is the write seam: over-bound values are now refused loudly instead of stored (declared = enforced; storage-owned ceilings, The shared identifier schemas declare no maximum length, so every cap on an identifier is a storage accident rather than a contract #12144).reportSyncFailureand boot continues — today's behaviour, not a new refusal. What this change adds is that the remedy becomes real:os migratehas no arm that rewrites TEXT tovarchar(n)and never truncates, so the operator route is a handALTER TABLE ... MODIFYof the named columns to their declared widths, after which the next boot creates every declared index directly. Take a backup; restateNOT NULL/DEFAULT(MySQLMODIFYdrops what you do not repeat); run underSTRICT_TRANS_TABLES(default), where an over-long stored value fails the ALTER withER_DATA_TOO_LONGinstead of truncating; pre-flight per column first, e.g.SELECT COUNT(*) FROM sys_notification_delivery WHERE CHAR_LENGTH(channel) > 64. No ObjectStack seam truncates; the only truncation risk is a non-default non-strictsql_mode, named here so it cannot be silent.The changeset carries the same disclosure in operator-facing language.
Verification (all at
2dbd57f2bbunless stated; heavy runs serialized throughos-verify-lock.sh)node scripts/check-keyed-text-bounds.mjs --self-testPASS · gate exit 0, judgment line: "148 keyed text-family columns judged, 148 bounded. Allowlist: 0 pending, 0 unboundable, all rows still real."sys_notification_delivery.error(text-family, unkeyed — must stay green in the shipped tree) flips the gate to exit 1 naming exactly that one column ("sys_notification_delivery.error [textarea] no maxLength", sole offender) — the sweep provably SEES the untouched column and the instrument returns non-zero against this tree. Mutation confirmed on disk by anchored greps (marker count 1) — not by editor exit; restore viagit checkout HEAD -- FILE, thengit diff HEADempty and marker grep 0. The gate resolves SOURCE*.object.ts(no dist in its path), so no rebuild leg applies to this instrument.@objectstack/service-messaging:pnpm test— 30 files, 301 passed (the package holds exactly 30 test files including the new pin test; the pin test also run individually: 6/6, verbose names in the run log).pnpm typecheckexit 0, andtsc --noEmit --listFilesshows both the new test file and the edited object files INSIDE the program (1 hit each) — coverage measured, not assumed.schema-drift.12732-varchar-emitter-parity,schema-drift.base-type-mismatch,schema-drift.legacy-unique-guard-attribution,schema-drift.nullability,schema-drift.unbounded-text-column,sql-driver-11627-hash-shadow-key,sql-driver-keyed-text-mysql): 7 files, 60 passed, 6 skipped — the skips are the opt-in live MySQL/Postgres cells (OS_TEST_MYSQL_URL/OS_TEST_POSTGRES_URL), unavailable on this seat.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, derivation banner confirming this repo at2dbd57f2bb, 8-path change set): every derived path-matched gate ran; all exit 0 except the one marked † below (check:agent-test-spelling,check:bash32-floor,check:changeset-gate-self-tests,check:cli-command-ids,check:cross-package-test-inputs,check:entry-guard,check:objectql-double-limit,check:objectui-changeset,check:page-declaration-shape,check:parse-guard,check:pm-half-states,check:pnpm-filter-targets,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:watch-hint-literal,check-adr-0087-registration,check-changeset-no-major,check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs,check-empty-changeset,check-keyed-text-bounds,check-plugin-teardown-shape,check-undeclared-dep-imports) plus the convention families (check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:i18n— after building the workspace closure exactly as lint.yml does, its first run was the gate's own PREREQUISITE NOT MET refusal —check:i18n-stale-fill,check:type-check-coverage,check:type-check-debton the built closure,bare-root-worklist --self-test,check:pm-dispatch-gates,release-rehearsal-clone --self-test, docs-auditcheck-affected-docs+check-drift-comment) — all exit 0. Editing the gate script also owes its own suite: that is the--self-testabove (the gate self-hosts its tests; no external*.test.tsnames it).node scripts/pm/check-half-states.mjs(half-state-patrol.yml; distinct from the greencheck:pm-half-statesabove) is NOT MEASURED here, not red: exit 3 = its own "PREREQUISITE NOT MET — no GitHub credential on this seat; nothing was swept, no reading at all". CI runs it with credentials (half-state-patrol.yml).check:nul-bytesexit 0, plus a control-character self-scan of every edited file: clean.Generated by Claude Code
Generated by Claude Code