Skip to content

fix(service-messaging): sourced maxLength on all 15 keyed text columns of the sys_notification_* objects - #13018

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-12978-notification-keyed-text-bounds
Aug 29, 2026
Merged

fix(service-messaging): sourced maxLength on all 15 keyed text columns of the sys_notification_* objects#13018
os-elon merged 1 commit into
mainfrom
claude/issue-12978-notification-keyed-text-bounds

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#12978

What this does

All 15 keyed text-family columns across the five sys_notification_* objects in packages/services/service-messaging now declare a sourcedmaxLength, each with its producer named in the declaration (#11374 route A), and the 15 pending allowlist rows in scripts/check-keyed-text-bounds.mjs are retired in the same change — the ledger is empty as a result (gate summary at 2dbd57f2bb: "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.

⚠️Card basis, corrected on measurement (the measurement comment on #12978, accepted — the card was de-escalated from p0): the dedup constraint is NOT unenforced on MySQL today — it is carried by the #11627 hash shadow (verdict (b)). What this PR fixes is the defect that remains: declared indexes not expressible AS DECLARED — five plain access-path indexes missing with a loud schema-sync error every boot, UNIQUEs riding shadows instead of their declared form.

Per-column bound + producer (ruling 2 — each stated in the declaration)

columnboundproducer (named, sourced)
sys_notification_delivery.notification_id255FK -> sys_notification.id; physical id column is table.string('id').primary() = varchar(255) (SqlDriver.DEFAULT_STRING_VARCHAR_CHARS). Same transitivity the plugin-audit record-id pins assert by value.
sys_notification_delivery.recipient_id255Resolved recipient: sys_user.id (physical varchar(255)); RecipientResolver.resolveOne() may keep an email-shaped value verbatim (#9807) - RFC 5321 caps an address at 254, sys_user.email stores one in a string-family varchar(255).
sys_notification_delivery.channel64MessagingChannel.id machine vocabulary (registerChannel: inbox/email/sms today; spec NotificationChannelSchema widest member 'webhook' = 7). 64 per the landed machine-vocabulary precedent (sys_session.revoke_reason maxLength 64; adopted by sys_device_code.status).
sys_notification_delivery.digest_key331Derived from its one producer, enqueueDeliveries' `${recipient}
sys_notification_preference.user_id255sys_user.id (physical varchar(255)); the literal '*' is 1 char.
sys_notification_preference.topic200sys_notification.topic (maxLength: 200) - the event topic these rows are matched against (preference-resolver keys `${user}
sys_notification_preference.channel64As delivery.channel; '*' is 1 char.
sys_notification_receipt.notification_id255FK -> sys_notification.id (as above).
sys_notification_receipt.user_id255sys_user.id (as above).
sys_notification_receipt.channel64As delivery.channel.
sys_notification_subscription.topic200sys_notification.topic (as preference.topic).
sys_notification_subscription.principal520Derived over the declared selector grammar (#9807): widest arm owner_of:OBJECT:ID = 9 + 255 (object API name, storage-owned by sys_metadata.name maxLength 255, #12144) + 1 + 255 (record id, physical varchar(255)). Every other arm is narrower (email at most 254; 'user:' + id = 260).
sys_notification_template.topic200sys_notification.topic (as above).
sys_notification_template.channel64As delivery.channel.
sys_notification_template.locale16Sibling declaration sys_email_template.locale (BCP-47 tag, maxLength 16, same best-matching-locale resolution role).

No column stays pending: every one of the 15 sourced honestly.

Composite-key audit (utf8mb4, 4 B/char, 3072 B per key)

Operational consequences on existing deployments — measured, not assumed

  • New databases (all dialects): columns are created varchar(n); every declared index is created directly (the two wide org-scoped UNIQUEs above ride the shadow on MySQL).
  • Existing databases, Postgres/SQLite: the declared indexes always existed (the refusal is MySQL-only) and no drift op is emitted for a bounded text field over a physical TEXT column — narrow_varchar requires a physical varchar (isCharacterColumn, driver-sql: the string family ignores maxLength too — a declared 1024 becomes varchar(255) and refuses legitimate writes #11431 deliberately excludes TEXT). Measured with duals (pure-function harness over exported diffManagedTable / diffManagedIndexes against this branch's built driver-sql, at 2dbd57f2bb): 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. So runArtifactBootMigrationGate (refuses destructive only) 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).
  • Existing databases, MySQL: columns stay TEXT (additive sync never rewrites). Boot keeps re-attempting the declared indexes: UNIQUEs stay shadow-carried; each plain text-keyed index is still refused, logged at error level by reportSyncFailure and boot continues — today's behaviour, not a new refusal. What this change adds is that the remedy becomes real: os migrate has no arm that rewrites TEXT to varchar(n) and never truncates, so the operator route is a hand ALTER TABLE ... MODIFY of the named columns to their declared widths, after which the next boot creates every declared index directly. Take a backup; restate NOT NULL/DEFAULT (MySQL MODIFY drops what you do not repeat); run under STRICT_TRANS_TABLES (default), where an over-long stored value fails the ALTER with ER_DATA_TOO_LONG instead 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-strict sql_mode, named here so it cannot be silent.

The changeset carries the same disclosure in operator-facing language.

Verification (all at 2dbd57f2bb unless stated; heavy runs serialized through os-verify-lock.sh)

  • node scripts/check-keyed-text-bounds.mjs --self-test PASS · gate exit 0, judgment line: "148 keyed text-family columns judged, 148 bounded. Allowlist: 0 pending, 0 unboundable, all rows still real."
  • Ratchet measured in both directions. Leg A (bounds declared, rows still present): exit 1, exactly 15 stale rows, each echoing the declared bound (e.g. "sys_notification_delivery.channel ... now declares maxLength 64 -- remove the row"). Green control (predicted before running): a scratch index over 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 via git checkout HEAD -- FILE, then git diff HEAD empty 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 typecheck exit 0, and tsc --noEmit --listFiles shows both the new test file and the edited object files INSIDE the program (1 hit each) — coverage measured, not assumed.
  • driver-sql schema-drift + driver-sql: the platform-objects schema does not sync onto MySQL — unbounded string fields become TEXT, which MySQL refuses to index #11374/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 suites (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.
  • Derived families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, derivation banner confirming this repo at 2dbd57f2bb, 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-debt on the built closure, bare-root-worklist --self-test, check:pm-dispatch-gates, release-rehearsal-clone --self-test, docs-audit check-affected-docs + check-drift-comment) — all exit 0. Editing the gate script also owes its own suite: that is the --self-test above (the gate self-hosts its tests; no external *.test.ts names it).
  • † the one exception: the sibling node scripts/pm/check-half-states.mjs (half-state-patrol.yml; distinct from the green check:pm-half-states above) 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-bytes exit 0, plus a control-character self-scan of every edited file: clean.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 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 — 4 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 87042b5de78fdb327b6a744cff76dd23a6a7b54cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d7a8bf266f68bf6a693a843651f81370251bdcb6 — the merge of head 2dbd57f2bbf57da1283c075f7cc19a8993aa01af into base 87042b5de78fdb327b6a744cff76dd23a6a7b54c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 28, 2026
@os-elon
os-elon marked this pull request as ready for review August 29, 2026 01:12
@os-elon
os-elon enabled auto-merge August 29, 2026 01:12
@os-elon
os-elon added this pull request to the merge queueAug 29, 2026
Merged via the queue into main with commit e4902d2Aug 29, 2026
34 checks passed
@os-elon
os-elon deleted the claude/issue-12978-notification-keyed-text-bounds branch August 29, 2026 01:27
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-messaging: 15 keyed text columns declare no maxLength — five sys_notification_* objects land registered-but-broken on MySQL

2 participants

@os-elon@claude