Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-audit,plugin-security): declare sourced bounds on the four keyed text columns that break MySQL schema-sync - #12143
Conversation
…ed text columns Four text columns that a declared index keys on carried no `maxLength`, so driver-sql emitted them TEXT. MySQL refuses a TEXT/BLOB column in a key without a key length (ER_BLOB_KEY_WITHOUT_LENGTH): CREATE TABLE succeeds, ADD INDEX fails, and the object lands registered-but-broken with its declared index silently absent. Each bound is derived from a named producer, stated in the declaration so it is vetoable in review (route A): sys_activity.record_id 255 physical `id` column sys_audit_log.record_id 255 physical `id` column sys_audience_binding_suggestion.package_id 255 sys_permission_set.package_id sys_audience_binding_suggestion.permission_set_name 100 sys_permission_set.name None narrows anything storable: a record id cannot exceed the varchar(255) the id itself lives in, and a permission set name over 100 is already refused at the write seam today (measured: "API Name must be <= 100 characters (got 101)"). Each plugin also gains a keyed-text-bounds pin driven through its own registration path rather than a hand-written object list — the platform-objects pin enumerates only that package's exports, which is why these columns escaped route A's sweep after ADR-0029 K2 moved the objects out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 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 c4c388bebc57a878111c6a7b1378544a4840b487 && git checkout c4c388bebc57a878111c6a7b1378544a4840b487
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 487e3614341379860d66554b8037840c093cb590 24a331cc9e42d9c6a1d7ad6d62b70294ceec22de && git checkout -B drift-repro 487e3614341379860d66554b8037840c093cb590 && git merge --no-ff 24a331cc9e42d9c6a1d7ad6d62b70294ceec22de
node scripts/docs-audit/affected-docs.mjs --json 487e3614341379860d66554b8037840c093cb590
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12059
Four text columns that a declared index keys on carried no
maxLength, sodriver-sqlemitted themTEXT. MySQL refuses a TEXT/BLOB column in a keywithout a key length (
ER_BLOB_KEY_WITHOUT_LENGTH):CREATE TABLEsucceeds,ALTER TABLE … ADD INDEXfails, and the object lands registered-but-broken withits declared index silently absent.
The four bounds, and the producer each is derived from
Route A's shape: every bound derived from a named producer and stated in the
declaration so it is vetoable in review (PR #12058 is the worked precedent).
sys_activityrecord_ididcolumn —driver-sqlcreates every primary key astable.string('id').primary(), knex'svarchar(255), spelledDEFAULT_STRING_VARCHAR_CHARS = 255sys_audit_logrecord_idsys_audience_binding_suggestionpackage_idsys_permission_set.package_id(255) — the same boot pass writes the same value into it;sys_capability,sys_metadataandsys_metadata_commitagree at 255sys_audience_binding_suggestionpermission_set_namesys_permission_set.name(100) — the column this value resolves against at confirm timeThe transitivity for the suggestion pair is not an analogy — it is the confirm
path itself.
confirmAudienceBindingSuggestionresolves the row byfind('sys_permission_set', { name: row.permission_set_name })and, on a miss,materializes it via
upsertPackagePermissionSet(ql, declared.set, row.package_id),which writes these two values into
sys_permission_set.nameandsys_permission_set.package_id. A suggestion is confirmable exactly when its twokey values fit the columns
sys_permission_setdeclares.Clause-② reverse-verification: no legitimately storable value is refused
The fork condition asked, per column, whether a longer value can legitimately
reach it today. Answered by measurement rather than by reading the schema:
record_id(both objects) — the writers (audit-writers.tsrecord_id: recordId,read-audit.ts,auth-event-audit.ts, plugin-auth'sadmin endpoints) all stamp a real record id of a stored row, and the physical
column that id lives in is
varchar(255). The seed path cannot widen iteither:
referenceViaresolution "refuses loudly, never stores verbatim" anunresolvable pointer, so a raw natural key never lands in the column.
package_id— the same value is written by the same pass intosys_permission_set.package_id(255). Longest real reverse-domain package idmeasured in this repo: 57 characters.
permission_set_name— a name over 100 is already refused at the writeseam today, independent of this PR. Measured on a real
ObjectQLengine overa real
SqlDriver:So no permission set with such a name can exist, and a suggestion naming one
could never be confirmed (
SuggestionStateError: "Permission set '…' is notmaterialized in sys_permission_set"). Bounding at 100 refuses nothing storable.
PermissionSetSchema.nameisSnakeCaseIdentifierSchema, which carries.min(2)and no.max()— the spec does not bound identifier length atall, and every cap on this value class comes from the columns that store it.
Filed as The shared identifier schemas declare no maximum length, so every cap on an identifier is a storage accident rather than a contract #12144.
Existing deployments are not rewritten. A physical
TEXTcolumn isdeliberately not diffed against
maxLength(#11431), so noALTERis plannedand no value at rest is truncated. The repair takes effect where the decision is
makeable at all — at
CREATE TABLE— because no dialect turns a TEXT column intoa keyable one afterwards.
Verification record
All results below were produced through the shared verify lock; every exit code
was captured before any pipe, and each gate's own verdict line is quoted.
Final commit
24a331cc9;git status --porcelainwas empty at commit time, sothe gate-tested working tree is that commit byte-for-byte.
Ablation — direction predicted in writing before running: RED (a non-empty
offenders list), not "more diagnostics" and not a reversal.
Both new pins were run against the unfixed tree first.
plugin-audit, pre-fix:plugin-security, pre-fix:After the fix:
plugin-audit4/4 passed,plugin-security5/5 passed.One prediction was wrong and was corrected against the measurement rather than
the other way round: I guessed the audit vacuity floor at ≥6 keyed text
columns; the real surface is 5 (
sys_audit_log.{object_name,record_id},sys_activity.{object_name,record_id},sys_comment.thread_id). The floor inthe pin is now the measured 5, with a comment saying so.
Mutation confirmed on disk before any conclusion was drawn:
git diff -U0showed exactly four added
maxLength:lines and zero deletions(102 insertions total). No
dist/ablation leg applies — both pins resolve theplugin from source inside their own package.
The card's count is confirmed.#12059 recorded its "4 unbounded keyed text
columns" as unverified, because the plugins' export surface cannot reach the
objects. Driving each plugin's real registration path enumerates the full
surface, and the offenders are exactly those four — no more, no fewer.
Driver-level type mapping (no live MySQL in this container — see NOT
MEASURED below). Shipped declaration vs. the same declaration with the bounds
stripped, read back off a real table:
id -> varchar(255)in every table is the transitivity premise for the tworecord_idbounds, read off a real table rather than assumed. The composite keyarithmetic also checks out against the utf8mb4 index-key ceiling:
255 + 100 + 255 = 610 ≤ 768 characters.
Suites and gates (each line is the tool's own verdict):
Gate union re-derived on the final commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(16 path-matched families,
committed 6, working tree 0, untracked 0); all run,all exit 0:
check:changeset-gate-self-tests·check:cross-package-test-inputs·check:objectui-changeset·check:published-files·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check-adr-0087-registration·check-changeset-no-major·check-ci-filter-parity·check-empty-changeset·check-plugin-teardown-shape·release-rehearsal-clone --self-test· plus the convention-triggeredcheck:query-options-erasure·check:engine-double-contract·check:where-matcher·check:i18n-stale-fill·check:type-check-coverage·check:nul-bytes.Two gates deserve their own line because their first result was not a pass:
check:i18nfirst exited 1 with "PREREQUISITE NOT MET — the workspace CLI isnot built … Nothing was checked". That is NOT MEASURED, not a red. After
building the CLI it reports:
check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys),with
plugins/plugin-auditandplugins/plugin-securityeach namedin sync (4 bundle(s)).check:type-check-debt(--re-measure) first refused: one dependency(
@objectstack/service-knowledge) had no built type entry point, and the gatestates plainly that measuring from there would measure a different world.
After building exactly that closure:
check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 296.1s, 1897 raw tsc error(s) total, none above its recorded number.This matters here because
@objectstack/plugin-securitycarries aTEST_DEBTentry (its tests sit outside the tsc program its own
typecheckruns), so anew test file in that package is exactly the shape that could move the ratchet.
It did not.
The run also reports a pre-existing
-1surplus on@objectstack/plugin-approvals(records 348, measures 347). Untouched by this PR and left alone deliberately —
out of scope: platform-objects' keyed-text-bounds pin cannot reach plugin/service packages — four keyed unbounded text columns in plugin-audit and plugin-security still break MySQL schema-sync #12059 is not addressed by lowering another package's ledger.
NOT MEASURED
The live MySQL leg. No MySQL is reachable from this container (no Docker
daemon;
OS_TEST_MYSQL_URLunset), soER_BLOB_KEY_WITHOUT_LENGTHwas notreproduced before the fix nor its absence confirmed after. The type-mapping half
above is measured; the index-refusal half is the mechanism #12058 measured live
on MySQL 8.0.46 on this exact code path. Note the SQLite readings above create
the index in both columns — SQLite keys a TEXT column happily, which is
precisely why this defect is MySQL-only and invisible on SQLite and Postgres.
Pin-lift feasibility (reported, deliberately NOT implemented — now tracked as #12147)
The class-level fix — making the
platform-objectskeyed-text-bounds pin walkevery package that ships platform objects — is engine-lane surface and is not in
this PR. What this card measured about its feasibility:
package.jsondeclares only the
.export and the root barrel does not re-export./objects. This is not a formality: it is why the card could not verify itsown counts.
platform-objectsdepends on@objectstack/metadata-coreand@objectstack/speconly;plugin-auditandplugin-securityboth depend onplatform-objects. A pin there that imported the plugins would create acycle, so "lift the existing pin" is not available as written.
*.object.tsin the repo, intersect each object'sindexeswith itstext-family fields, fail on an unbounded keyed column. That is the idiom the
repo already blesses for exactly this reason ("a detector with no dependencies
cannot itself fail to resolve in CI", per
check:cross-package-test-inputs),it has no dependency-direction problem, and it is how platform-objects' keyed-text-bounds pin cannot reach plugin/service packages — four keyed unbounded text columns in plugin-audit and plugin-security still break MySQL schema-sync #12059's own evidence
was produced. Its known cost is the documented weakness of source scans — it
sees only the spellings it knows — so it would need the vacuity control the
existing pins already carry.
Recommendation: a
scripts/check-keyed-text-bounds.mjsclass-level walk with aper-package allowlist, superseding the three per-package pins. Until it exists,
each shipping package carries its own copy — which is what this PR adds for the
two plugins, driven through each plugin's own registration path
(
init()→manifest.register({ objects })) rather than a hand-written objectlist, so an object added to a plugin is policed the moment it is added.
Scope
Three object declaration files plus one pin beside each plugin, and one
changeset covering both published plugins (
patch).bootstrap-platform-admin.ts(#11959's surface) is untouched.
Generated by Claude Code