Skip to main content

tom_core_server 1.3.0 copy "tom_core_server: ^1.3.0" to clipboard
tom_core_server: ^1.3.0 copied to clipboard

Server library for the TOM framework.

1.3.0 #

This release is built from the repository at the state the entries below describe (an Unreleased section included); its own section is in the full history linked at the end.

Unreleased #

Added #

  • OpenTelemetry tracing (tracing module, new; server, configuration, datasources, transactions, messaging, metrics modules; kernel TomTracing seam). TomOtelTracer({processors, resource, samplePercent, exportClient}) implements the kernel's TomTracer over package:opentelemetry (^0.18.11, imported by this module only), sampling parent-based with new traces at samplePercent decided from the trace id; TomOtelTracer.otlp({endpoint, headers, resource, samplePercent, exportInterval, client}) exports OTLP/HTTP protobuf to <endpoint>/v1/traces, batched every 5 s; fromConfiguration(configuration, {serviceName}); flush, shutdown and close wait for the exports in flight. TomUntracedClient (idle; close lets calls in flight finish) and TomInMemorySpanExporter (spans, named, childrenOf, attributeOf). TomTracingException, error keys tracing.config.no_endpoint and tracing.config.invalid_sample_percent. Configuration tracing (TRACING, default false) and tracingSamplePercent (TRACING_SAMPLE_PERCENT, default 100); spans go to otlpEndpoint with otlpHeaders. TomServer.configureTracing(), called by start() right after configureHttpClients(), installs the tracer as TomServer.tracer and registers it with the shutdown cleanup. Spans: the request's server span in tomRequestContextMiddleware, named <METHOD> <route>, continuing the caller's traceparent and answered as the response's; each SQL statement a client span named after its first keyword with db.system.name (TomSqlDatasource.dbSystemName: mariadb, postgresql), db.operation.name, db.query.text (placeholders, never bound values) and tom.datasource, withLink taking sqlText; TomTransaction.span (transaction, with id, read-only flag, outcome and participant count) — statements and transactions only inside traced work (kernel TomTracing.start(childOnly:)) — and runInSpan, used by runInTransaction and transactional endpoints; TomMessageOutbox.send a producer span outbox send writing traceparent / tracestate into TomMessage.headers, continued by the consumer span outbox deliver; TomWebhookTransport no longer posts the message's trace headers. The metrics push runs untraced. doc/tracing.md, doc/api/api_summary_tracing.md, example/tracing/tracing_example.dart; tests in test/tracing_test.dart, test/tracing_db_test.dart (db) and test/tracing_otel_test.dart (otel, run by tool/run_otel_tests.sh, whose collector configuration gains a traces pipeline).

  • Export charts, the email channel, and the export register with quota and retention (export, db_migration modules). TomTabularChart({type, categoryColumn, valueColumns, title}) with TomChartType (bar, line, pie) names columns of its section, every row a point; TomTabularSection and its ofRows, ofMaps and ofEntities take charts, checked by validateAgainst (error keys export.chart.unknown_column, export.chart.no_series, export.chart.pie_series, export.chart.not_numeric). TomPdfRenderer draws each chart below its section's table with package:pdf's chart widgets (constructor argument chartHeight, default 180), skipping null cells and pie slices of zero or less; TomXlsxRenderer writes native barChart / lineChart / pieChart parts under xl/charts/ with one drawing per charted sheet, anchored right of the table, the series bound to the sheet's cells by absolute references; CSV leaves charts out. TomExportEmailDelivery({store, outbox, linkFor, from, maxAttachmentBytes}) (defaultMaxAttachmentBytes, 10 MiB) sends a stored export with send(export, {to, subject, text, fileName, from, mode}) as a link or an attachment read back from the store (TomExportEmailMode: link, attach, auto), queued through TomMessageOutbox; error keys export.email.no_recipient, export.email.too_large, export.email.no_link. TomExportRegister (record, find, ownedBy, usageOf, storedBefore, remove) with TomExportRecord and TomExportUsage, in memory (TomMemoryExportRegister) or in the table tom_export (TomSqlExportRegister, exportRegisterTable, exportRegisterTableDdl, defaultExportRegisterTable, rows TomExportRecordRow and TomExportUsageRow), listed by frameworkOwnedTables under the exportRegister argument. TomExportService({formats, store, register, quota}) records what storeAs(result, format, {key, owner}) stores and enforces TomExportQuota({maxCount, maxBytes}) before rendering and after storing (error keys export.quota.count_exceeded, export.quota.bytes_exceeded; a quota without a register is an ArgumentError); deleteStored(key) removes the export and its record. TomExportRetention({store, register, maxAge, batchSize}) deletes exports older than maxAge with purge({now}), or as the job jobDefinition({name = 'tom.export.purge', schedule}) run by a TomInlineJobDispatcher. Reference migration example/export/migrations/main/tomcore/[1]-create_tom_export.sql and its PostgreSQL form; doc/export.md § Charts, § The Email Channel, § The Export Register, § Quota, § Retention and § Error Keys; example/export/export_email.dart, example/export/export_retention.dart and steps 7 and 8 of example/export/export_example.dart; tests in test/export_test.dart, test/export_register_test.dart, test/export_register_db_test.dart, test/framework_schema_test.dart and test/reference_migrations_test.dart.

  • Preference defaults and the bulk preference write (preferences module). TomPreferenceDefault (table tom_preference_default, createTableSql), TomPreferenceDefaultScope (deployment, role), TomPreferenceDefaultRepository and the administrative service TomPreferenceDefaults (resolve, maxRoleLength, setForDeployment, setForRole(role, key, value, {precedence}), removeForDeployment, removeForRole, all, effectiveFor(roles), effectiveValue(key, roles); error key preferences.default.invalid_role; no endpoint). A principal reads its own value, else the default of a role it holds (highest precedence, then role name in code-unit order), else the deployment's: TomUserPreferences( repository, {defaults}) — falling back to the TomPreferenceDefaults bean — merges them into all() (ordered by key, the page window cut in memory, TomPageRequest.sort not applied) and read(), with TomUserPreferenceDto.origin naming the source. setAllEncoded(writes) writes a batch all or none in one transaction, the ceiling counting the batch whole; the endpoint writePreferences (POST /preferences) serves it, and TomUserPreferencesErrorHandler answers preferences.bulk.malformed with a 400. Every preference write goes through saveByKey on (principalId, preferenceKey). Reference migration example/preferences/migrations/main/tomcore/[2]-create_tom_preference_default.sql and its PostgreSQL form; doc/preferences.md § Defaults Beneath a Principal's Own Values, § Writing Several Preferences at Once; step 13 of example/preferences/preferences_example.dart; tests in test/preferences_defaults_db_test.dart, test/preferences_endpoints_test.dart and test/reference_migrations_test.dart.

  • Single-statement upsert (object_persistence, datasources modules). TomSqlDatasourceRepository.upsert(entity, {conflictFields}) inserts or overwrites the row holding the same unique-key values in one statement, with no read before it, and returns the entity carrying the written row's key; upsertBlocker names why an entity cannot be upserted (column access rights, file-reference columns, encrypted columns, a version column, soft delete, entity or change listeners or history, an audit policy), refused as crud_repository.upsert.unsupported, and a conflict key omitting the @TomDbScope or tenant field is refused as crud_repository.upsert.unscoped_conflict_key. saveByKey(entity, {conflictFields}) upserts where allowed and otherwise finds the row by the key and saves it. The dialect fragment TomStandardSqlDialect.upsert(table, values, {conflictKey, updateColumns, keyColumn}) renders MariaDB's INSERT … ON DUPLICATE KEY UPDATE c = VALUES(c), key = LAST_INSERT_ID(key); PostgresSqlDialect.upsert renders INSERT … ON CONFLICT (k) DO UPDATE SET c = EXCLUDED.c RETURNING key. doc/object_persistence.md § Upserting by a unique key, doc/datasources.md § Upserting in one statement; tests in test/postgres_dialect_test.dart and test/preferences_defaults_db_test.dart.

  • Job priority, pauses, checkpoints and the run archive (scheduling, db_migration modules). TomSqlJobStore implements the kernel's priority, pause and checkpoint contract: tom_job_run carries priority INT NOT NULL DEFAULT 0 and checkpoint LONGTEXT NULL, the claim scan reads ORDER BY priority DESC, seq and leaves out paused jobs, and saveCheckpoint is an UPDATE fenced on the claim. Paused jobs are rows of tom_job_pause (jobPauseTable, jobPauseTableDdl, defaultJobPauseTable, TomJobPauseRow; constructor argument pauseTable); pauseJob keeps the first pause's instant. purgeFinished(before, {now}) moves finished runs into tom_job_run_archive (jobRunArchiveTable, jobRunArchiveTableDdl, defaultJobRunArchiveTable; constructor argument archiveTable, null for a purge that deletes) in one transaction — an INSERT … SELECT, then a DELETE of exactly the archived runs; archivedRuns({jobName, limit}) reads it newest first as TomArchivedJobRuns and purgeArchive(before) trims it. deleteAll empties all three tables, TomJobRunRow carries priority and checkpoint, TomJobRunArchiveRow maps an archived run, and frameworkOwnedTables lists both tables (arguments jobPause, jobRunArchive). Reference migrations example/scheduling/migrations/main/tomcore/[1]-create_tom_job_run.sql (the two columns), [4]-create_tom_job_pause.sql, [5]-create_tom_job_run_archive.sql and their PostgreSQL forms; doc/scheduling.md § Priority, Pausing and Checkpoints and § The Archive; step 11 of example/scheduling/scheduling_example.dart; tests in test/scheduling_db_test.dart, test/scheduling_schema_test.dart and test/framework_schema_test.dart.

  • Recovery of prepared two-phase branches (transactions, datasources, server modules). Every TomTransaction has an id (tx_<process tag>_<counter>). Between the phases the coordinator records the prepared branches of its TomPreparedBranchHolder participants under that id in TomTransactionManager.decisionLog (a TomTransactionDecisionLog), outside the transaction's scope; a record that cannot be written rolls the transaction back, and the record is forgotten once every branch committed and kept when one broke its promise. TomSqlDecisionLog(datasourceName, {table, createSchema}) keeps decisions in tom_transaction_decision (decisionTable, decisionTableDdl, defaultDecisionTable, TomDecisionRow) of a one-phase data source, TomMemoryDecisionLog in memory. TomTransactionRecovery.recover({sources, log, now}) lists the prepared branches of every TomTwoPhaseRecoverable data source, passes over the branches it does not own and those younger than TomTransactionRecovery.minimumAge (one minute), commits those a decision names, rolls back the rest, forgets the decisions none of whose branches is left, and returns a TomRecoveryReport (committed, rolledBack, tooYoung, failed, forgotten); a branch that fails to resolve is reported and the rest are still resolved. MariaDbXADatasource (XA RECOVER / XA COMMIT / XA ROLLBACK) and PostgresXADatasource (pg_prepared_xacts of the current database / COMMIT PREPARED / ROLLBACK PREPARED) mix in TomSqlTwoPhaseRecovery, whose four statements a driver supplies; tomSqlOwnsBranch and tomSqlBranchOpenedAt read a branch id's data source and opening time, and an id of other characters is refused with error.datasource.invalid_branch_id. The transactions configuration key (decisionLog, decisionTable, createSchema, recoverOnStart, recoveryMinimumAgeSeconds) is applied by TomServer.recoverTransactions, which start() calls after configurePrivacy; a malformed section is transactions.recovery.config.invalid, an unregistered log data source transactions.recovery.no_datasource (TomTransactionRecoveryException). Reference migration example/transactions/migrations/main/tomcore/[2]-create_tom_transaction_decision.sql and its PostgreSQL form; example/transactions/transaction_recovery_example.dart.

  • Down migrations and rollback (db_migration module). A migration may have a down script beside it, named after its basename plus .down (TomDbMigrations.downSuffix): [3]-add_column.down.sql undoes [3]-add_column.sql, [3]-x@dev.down.sql undoes [3]-x@dev.sql. TomDbMigrations.isDownMigration recognises one, collectDownMigrationFiles collects them keyed by the migration each undoes (two for one migration are db_migration.migration.duplicate_migration_basename), and collectMigrationFiles skips them, so neither the forward run nor the convergence check sees them. TomDbMigrations.rollback(directory, {datasource, schema, toVersion}) and rollbackDataSource(migrator, dsInfo, dataSourceDirectory, schema, {toVersion}) undo the migrations applied above toVersion (-1: all) newest first by installation order, under the migration lock, and answer the versions undone; a target below -1 is db_migration.rollback.invalid_target. TomDbMigrator.rollback(dsi, schema, toVersion, scripts) takes the tree's scripts as TomMigrationScript( description, up, [down]). Before anything runs, every migration to undo must have a down script (<apply>.rollback_migration.no_down_script) and be recorded with the description and up-script checksum the tree holds (<apply>.rollback_migration.script_changed); a recorded failure still refuses the schema. Each down script runs, then its record is deleted, so a later forward run applies the migration again; environment tags do not filter a rollback. TomSqlMigrationAdaptor.revertScript (protected, per database) and forgetMigration do the work: PostgreSQL runs the down script and the deletion in one transaction, MariaDB statement by statement, leaving the migration recorded when a down script fails part-way; either failure is <apply>.rollback_migration.failed. TomDbMigrations.rollbackFromArgs(args, directory) reads --rollback-migrations=<datasource>/<schema>/<version> (rollbackMigrationsOption) and refuses a malformed request with db_migration.rollback.invalid_arguments; the four tom_core_samples servers run it beside the repair and exit. example/db_migration/db_migration_repair_example.dart shows the connection-free half; test/db_migration_rollback_db_test.dart covers the rollback against MariaDB and PostgreSQL.

  • A migration adaptor serves several data-source types (db_migration module). @TomDbMigrationAdaptor(type, {alsoServes}) lists further types; one instance per adaptor class is registered under each, and a type claimed twice is still db_migration.initialization.found_more_than_one_migration_adapter. MariadbMigrationAdaptor serves mariadb and mariadb_xa, PostgresMigrationAdaptor postgresql and postgresql_xa.

  • Query sentences filter on associations; subqueries in the builder (object_persistence and datasources modules, subquery.dart). A predicate term may name a field of a @TomReference association — find by tags.name = :n — or test it, tags is empty / tags is not empty. The term compiles to a correlated EXISTS (SELECT 1 FROM <join>, <target> WHERE …) over the association's leftSideColumns and rightSideColumns, a semi-join that answers an owning row once however many associated rows match; it works in find, count and delete sentences and under a leading not. tags.name <> :n means "has a tag whose name is not n"; "has no tag named n" is not tags.name = :n. Refusals are TomQuerySyntaxException keyed query_sentence_compiler.association.no_field, .unknown_field, .context_columns and .self_reference. TomSubquery(types, {where, projection}) renders SELECT <projection or 1> FROM <tables> WHERE …, adding the live-row test of every @TomSoftDelete type unless TomSoftDelete.includingDeleted is on; TomExists and TomInSubquery test it, built with TomQueryBuilder.exists, notExists, isInSubquery and isNotInSubquery. A subquery is opaque to the walks over the enclosing tree and contributes its parameters in place. TomStandardSqlDialect gains exists, inSubquery and subquery. test/query_associations_test.dart and test/query_associations_db_test.dart cover them without and with a server.

  • Soft delete, history tables, lifecycle hooks and change events (object_persistence and datasources modules, entity_lifecycle.dart). @TomSoftDelete({field = 'deletedAt'}) makes every repository delete — delete, deleteById, deleteAllById, deleteByQuerySentence, deleteByQuery — an UPDATE setting a nullable DateTime column, and ANDs <deleted> IS NULL into the repository's scope, so every read, count, existence check and write guard leaves deleted rows out; TomSoftDelete.includingDeleted(body) shows them, restoreById(id) clears the deletion time and purgeById(id) deletes for good. A versioned delete(entity) is version-checked and moves the version (TomSqlDatasourceRepository.deletedAtParameter, nextVersionParameter); a delete cut to a window is resolved to the window's keys first. @TomHistory(table:) copies the row as it was — read raw, encrypted columns as ciphertext — into a history table with history_id, history_action, history_at and history_by before every update, delete and restore; historyOf(id) answers TomHistoryEntry versions, oldest first, read through the reflected TomHistoryMeta. TomEntityLifecycle.register<T>(listener) registers a TomEntityListener whose beforeSave may change or refuse the entity and whose afterSave / beforeDelete / afterDelete see the row as stored; TomEntityLifecycle.onChange(listener, {entity}) is told of every TomEntityChange (inserted, updated, deleted, restored) with the rows before and after, their values with encrypted fields redacted, the principal and the tenant, and toJson(). The write of an entity with a listener, a change listener or a history runs inside a transaction — the caller's or its own — so the history, the listeners' writes and the change commit or roll back together, and a change listener inserting an outbox row publishes exactly the committed changes. TomUpdate is the generated UPDATE a soft delete and a restore render as. Refusals are TomEntityLifecycleException keyed crud_repository.soft_delete.invalid_field, crud_repository.soft_delete.not_soft_delete and crud_repository.history.not_declared. example/object_persistence/entity_lifecycle_example.dart walks the connection-free half; test/entity_lifecycle_test.dart and test/entity_lifecycle_db_test.dart cover it without and with a server.

  • Migration lock and repair (db_migration and datasources modules). TomDbMigrations.migrateDataSource runs every schema of a data source under that database's migration lock, TomDbMigrator.withMigrationLock(dsi, body): one migration run per database at a time across every process, so replicas starting together apply each script once — a second process waits, then finds the migrations applied. The SQL adaptors take it through the new TomSqlDatasource.withSessionLock(name, body, {wait, poll}), a database-wide session lock held on a connection of its own beside the pool: released when body returns or throws and by the database when the holding process dies, re-entrant within the zone per database and name, and refused after wait with error.datasource.session_lock_timeout. Drivers supply the protected tryLockOn / unlockOn — GET_LOCK / RELEASE_LOCK on MariaDB, pg_try_advisory_lock(hashtextextended(name, 0)) / pg_advisory_unlock on PostgreSQL. TomSqlMigrationAdaptor.migrationLockName(databaseName) (tom_db_migration:<db>, hashed past MariaDB's 64 characters) and TomSqlMigrationAdaptor.lockWait (10 minutes) name and bound it; a data source that is not a TomSqlDatasource is refused with <initialize>.lock.not_a_sql_datasource. TomDbMigrations.withMigrationLock(datasourceName, body) holds it around a wider run, such as a whole per-tenant forEachTenant walk. A migration recorded as failed is resolved with TomDbMigrator.repair / TomDbMigrations.repair(datasourceName, schemaName, version, action) and TomMigrationRepair.remove (the next run applies it again) or TomMigrationRepair.markResolved (the next run skips it); repair runs under the lock, never runs a script and touches only records with success <> 1, refusing a version with none (<initialize>.repair.no_failed_record) and reporting a failed read or write as <initialize>.repair.failed. TomDbMigrations.repairFromArgs(args) reads --repair-migration=<datasource>/<schema>/<version> and --repair-action=remove|resolve (repairMigrationOption, repairActionOption), answers whether it ran, and refuses a malformed request with db_migration.repair.invalid_arguments. The failed-history refusal names the repair. The four tom_core_samples servers run it before migrating and exit. example/db_migration/db_migration_repair_example.dart shows the connection-free half; test/db_migration_lock_db_test.dart covers the lock and the repair against a live server.

  • Read replicas (datasources module, read_replicas.dart). A SQL data source takes replicas — one settings map per replica, each overriding the primary's settings ([{host: db-replica-1}]) — with replicaStickinessMs (default 5000) and replicaRetryMs (default 30000). A statement runs on a replica only when it is read-only work (a read-only transaction, a GET or HEAD request, or a TomReplicaRouting.readOnly block), a repository read of an entity not marked @TomPrimaryReads, a read (TomReplicaRouting.isReadStatement: a SELECT or WITH that locks, writes and calls no session function), and the principal (per tenant) has not written within replicaStickinessMs on this node. Replicas are used round-robin, a read-only transaction stays on one, and a replica that fails before serving is left out for replicaRetryMs with a warning while the primary serves; one unreachable at connect is marked down, not fatal. TomReplicaRouting.primary forces the primary; routeSafeRequests (default true) decides whether safe requests are read-only work. Every read method of TomSqlDatasourceRepository runs inside TomReplicaRouting.repositoryRead; the framework's own stores read the primary. TomSqlDatasource gains replicas, replicaStickinessMs, replicaRetryMs, replicaSources and the driver hook newReplica() (a plain one-phase data source even behind an XA primary); replicas register as <name>.replica<n>. A malformed replicas setting is refused with error.initialize.invalid_replicas. TomResourceGrant, TomPrincipalResourceGrant, TomPrincipalDelegation, TomUserTwoFactorEnrolment, TomExternalIdentityLink and TomExternalIdentityToken are @TomPrimaryReads. example/datasources/read_replicas_example.dart shows the rules without a server; test/read_replicas_test.dart and test/read_replicas_db_test.dart cover them.

  • Privacy: GDPR erasure, subject-access export, retention and crypto-shredding (new privacy module, lib/src/tomserver/privacy/). Entities declare their personal data: @TomDataSubject(onErasure:) on the int/String column naming a person, with TomErasure.delete, anonymize or unlink; @TomPersonal(subject:, replacement:) on a column holding their data; @TomRetention(field:, days:, action:) on the class. TomPersonalDataModel.of resolves and checks them (TomSubjectField, TomPersonalField), refusing an unusable declaration with privacy.model.subject_type, …subject_unknown, …subject_ambiguous, …structural_personal, …retention_field or …retention_anonymize. TomPrivacy.register(repository) enrols an entity (registration order is visiting order); TomPrivacy.erase(subject, keepAuditKeys:, entities:) deletes, anonymizes or unlinks every row naming the subject through the repositories — audited, files cascaded, unscoped by row scopes — and answers a TomErasureReport of TomErasureCounts; subjectAccess / exportSubject collect those rows, once each, into a TomSubjectAccess rendered as XLSX, CSV or PDF; applyRetention / retentionJob (privacy.retention, tomRetentionJobName) apply TomRetentionPolicys — by default the declared ones, TomRetentionPolicy.declared() — a retentionBatchSize at a time and answer TomRetentionCounts. Each operation is audited (erasureTarget, subjectAccessTarget, retentionTarget). Crypto-shredding: TomSubjectKeys seals a value under a 32-byte key per subject (tomsubj:v1:…, AES-256-GCM, subject and field authenticated), wrapped under the field-encryption keyring's key-encryption keys and kept by a TomSubjectKeyStore (TomSqlSubjectKeyStore over tom_subject_key — subjectKeyTable, subjectKeyTableDdl, defaultSubjectKeyTable, read back as TomSubjectKeyRow — or TomMemorySubjectKeyStore) as TomSubjectKeyRecords; shred deletes the key, reveal opens a record's sealed values or shows [erased]. TomPrivacy.installSubjectKeys / configure install them; TomServer.start applies the privacy configuration through the new TomServer.configurePrivacy, after configureFieldEncryption. Failures are TomPrivacyExceptions (privacy.not_registered, privacy.no_subject_keys, privacy.seal.invalid, privacy.no_datasource, privacy.config.invalid). Reference migration example/privacy/migrations/main/tomcore/[1]-create_tom_subject_key.sql with its PostgreSQL form; frameworkOwnedTables lists the table under the new subjectKey parameter. TomUserPreference, TomUserTwoFactorEnrolment, TomPrincipalResourceGrant and TomPrincipalDelegation declare their principal columns data subjects (and the preference value and enrolment hint personal). doc/privacy.md, doc/api/api_summary_privacy.md, the module readme and example/privacy/privacy_example.dart; covered by test/privacy_test.dart and the db-tagged test/privacy_db_test.dart.

  • The audit trail seals personal values (audit module). TomAuditTrail.recordMutation takes beforeSubjects / afterSubjects — a field mapped to the data subject its value belongs to — and seals those values through the new static TomAuditTrail.personalValueSealer before the record is written; null, the default, records them as they are. TomSqlDatasourceRepository passes the subjects of an entity's @TomPersonal fields from the stored rows, so with subject keys installed a repository's records hold personal values sealed per person.

  • TomAesGcm is public (object_persistence module): the AES-256-GCM cipher of field encryption and data-key wrapping, now shared with the privacy module's sealing (seal, open).

  • Field-level encryption with key rotation (object_persistence module). A String field annotated @TomEncrypted is stored as AES-256-GCM ciphertext — tomenc:v1:<data key id>:<base64url>, randomised per write, with <table>.<column> as additional authenticated data — and read as plaintext: TomSqlDatasourceRepository.save encrypts it, the data source's entity row mapping loads the data keys a result names (TomFieldEncryption.prepareRead) and decrypts it, and TomFieldEncryption.raw reads the stored values as they are without loading references. @TomBlindIndex(field, normalization:) (TomBlindIndexNormalization: exact, trim, trimLowercase) makes a companion column an HMAC-SHA-256 of the normalised plaintext, filled on every save; TomFieldEncryption.blindIndexCondition builds the lookup (<index> is in ( … ) over every index key, blindIndexCandidates), blindIndexOf and blindIndex the single value. Data keys are kept in tom_encryption_key (encryptionKeyTable, encryptionKeyTableDdl, defaultEncryptionKeyTable, read back as TomDataKeyRow; listed by frameworkOwnedTables under the new encryptionKey parameter; reference migration example/object_persistence/migrations/main/tomcore/[1]-create_tom_encryption_key.sql with its PostgreSQL form), wrapped under a TomKeyEncryptionKey — TomAesKeyEncryptionKey, an application-held AES-256 key — by a TomDataKeyStore (TomSqlDataKeyStore, resolved by name per call so a routed tenant keeps its keys in its own schema or database, or .over a data source; TomMemoryDataKeyStore) as TomDataKeyRecords (TomDataKeyPurpose, TomDataKeyState). TomFieldKeyring unwraps and caches them, reloads every refreshInterval or for a key it has not seen, and rotates: rotateDataKey, rotateIndexKey (index-2, index-3, …) and rewrap for a key-encryption key. TomFieldReencryption — run() in every tenant, or job(schedule:) as object_persistence.reencrypt (tomReencryptionJobName) on an inline dispatcher — rewrites values under a previous data key, plaintext (read while acceptPlaintext is on) and stale blind indexes, each row under a guard on the values it read, reporting a TomReencryptionReport. TomServer.start installs the keyring from the fieldEncryption configuration (TomServer.configureFieldEncryption, TomFieldEncryption.configure) after idempotency. Repository audit images redact encrypted fields and blind indexes (TomFieldEncryption.redact, [encrypted]). TomAnnotationAnalyzer.encryptedColumns / blindIndexColumns, and TomColumnInformation.encrypted / blindIndex. TomFieldEncryptionException carries field_encryption.not_configured, …unknown_key, …decrypt_failed, …plaintext_refused, …unknown_kek, …invalid_kek, …config.invalid and …no_datasource; the analyzer refuses a misdeclared field (annotation_analyzer.encryption.not_string, …both, …structural_column, …index_without_field) and the query compiler a condition, group key or aggregate on an encrypted column (query_sentence_compiler.encrypted_column). The cipher is pointycastle's AES-GCM, the blind index crypto's HMAC. doc/object_persistence.md § Field-Level Encryption, doc/configuration.md § Field-Level Encryption, notes in doc/server.md, doc/tenancy.md, doc/db_migration.md, doc/audit.md and doc/datasources.md; example/object_persistence/field_encryption_example.dart, on the package tour; test/field_encryption_test.dart, test/field_encryption_db_test.dart.

  • Sagas (transactions module). TomSagaOrchestrator runs work that spans services as a TomSaga of TomSagaSteps, each an action and an optional compensation (TomSagaWork, given a TomSagaContext with the attempt, the reason it compensates and the saga's JSON data, which a step may change and which is saved with it). Every step and every compensation is a job run of an inline TomScheduler the orchestrator owns — jobs saga.<name> and saga.<name>.compensation (tomSagaJobPrefix, stepJobName, compensationJobName) — run in TomTransactionManager.runInTransaction together with the update that moves the saga on, written only over the version the run was queued for: a step that throws leaves no trace and is retried by TomSaga.retryPolicy, a step that committed never runs again. A step that fails for good, or throws a TomPermanentFailure, turns the saga to compensating, and the completed steps are compensated last first by compensationRetryPolicy (ten attempts by default); a compensation that fails for good ends the saga failed and raises the scheduler's alert (TomSagaStatus: running, compensating, completed, compensated, failed). register, begin(name, data:, id:) — for the tenant in context, inside the caller's transaction when there is one — find, start, tick, close, and resume, which re-queues every unfinished saga whose run is gone, at start and every sweepInterval (five minutes), in every tenant of an installed TomTenancy. States (TomSagaState) are kept in a TomSagaStore: TomMemorySagaStore, or TomSqlSagaStore — a data source resolved by name per call, so under tenancy a tenant's sagas live with its data, or bound with .over(datasource) — in tom_saga, declared once by sagaTable (sagaTableDdl, defaultSagaTable, read back as TomSagaRow), listed by frameworkOwnedTables under the new saga parameter, and shipped as the reference migration example/transactions/migrations/main/tomcore/[1]-create_tom_saga.sql with its PostgreSQL form. TomSagaException carries transactions.saga.invalid_definition, …already_registered, …unknown, …invalid_id, …id_taken, …data_not_json, …closed and …no_datasource. doc/transactions.md § Sagas, notes in doc/tenancy.md and doc/db_migration.md; example/transactions/saga_example.dart, on the package tour; test/saga_test.dart, test/saga_db_test.dart.

  • TomEventInstants (scheduling module): strictly increasing window instants for the runs a component enqueues itself rather than through a schedule — next(now) answers now in UTC, or one microsecond past the last instant it gave out. Used by the outbox and the saga orchestrator.

  • Idempotency keys (endpoints module). @TomIdempotent({retention}) on an endpoint lets a request carry an Idempotency-Key header (1 to 191 characters): the first request runs and its response is stored; a retry with the same key gets the stored response with Idempotent-Replayed: true without running again; the same key for a different request (method, path, query and body, compared as a SHA-256) answers 422 endpoint.idempotency.key_reused, a key whose request still runs 409 endpoint.idempotency.in_progress with Retry-After: 1, an invalid key 400 endpoint.idempotency.key_invalid. A key is scoped to the tenant, the authenticated principal and the operation ("<METHOD> <uri>"); a request without a principal or without the header runs as before. A 5xx, a thrown handler and the statuses saying the request did not run (TomIdempotency.notStoredStatuses: 401, 403, 408, 409, 425, 429) release the key; set-cookie and the hop-by-hop headers are not replayed. A claim left unfinished past inProgressTimeout (five minutes) is taken over; records expire after the retention (24 hours by default) and are removed by TomIdempotencyStore.purgeExpired. TomIdempotency holds the settings and handle, which TomEndpointHandler.handleMethodCall routes every request of an annotated endpoint through (not OPTIONS); TomMethodMetadata.idempotent carries the annotation, and a second one on a method stops the router build with endpoint_pipeline.get_class_method_metadata.multiple_idempotent_tags. Stores: TomMemoryIdempotencyStore (the default, one process) and TomSqlIdempotencyStore — a data source resolved by name per call, so under tenancy a tenant's keys live in its own schema or database, or bound with .over(datasource) — keeping the records in tom_idempotency_key, declared once by idempotencyTable (idempotencyTableDdl, defaultIdempotencyTable, read back as TomIdempotencyRow), listed by frameworkOwnedTables under the new idempotency parameter, and shipped as the reference migration example/endpoints/migrations/main/tomcore/[1]-create_tom_idempotency_key.sql with its PostgreSQL form. TomIdempotencyScope, TomIdempotencyRecord and TomIdempotencyClaim make up the store contract; TomIdempotencyException carries the keys above plus endpoint.idempotency.no_datasource and endpoint.idempotency.config.invalid. TomServer.idempotencyConfigurationKey ('idempotency') and TomServer.configureIdempotency([provider]), called by start() right after configureHttpClients, apply the datasource, table, createSchema, retentionMinutes and inProgressTimeoutSeconds keys. doc/endpoints.md § Idempotency Keys, doc/configuration.md § Idempotency Keys, notes in doc/tenancy.md, doc/db_migration.md and doc/server.md; example/endpoints/idempotency_example.dart, on the package tour; test/idempotency_test.dart, test/idempotency_db_test.dart.

  • Idempotent receiver (inbox) for consumers of a message broker (messaging module). TomInbox.process(consumer, messageId, work, {acknowledge}) records the (consumer, message id) pair and runs work in one database transaction, skips work for a pair already recorded (TomInboxOutcome.processed / duplicate), and calls acknowledge after the commit — for a duplicate too; a failing work rolls back with its record, is not acknowledged and is rethrown. recordAndRun, hasProcessed and purgeProcessedBefore complete the contract. TomSqlInbox (datasource, table, createSchema, clock; ensureSchema, deleteAll) keeps the records in tom_message_inbox of the data source the consumer's work writes to, so both commit together and simultaneous deliveries are serialised by the table's key; TomMemoryInbox keeps them in memory for tests. The table is declared once by inboxTable (inboxTableDdl, defaultInboxTable, read back as TomInboxRow), listed by frameworkOwnedTables under the new inbox parameter, and shipped as the reference migration example/messaging/migrations/main/tomcore/[2]-create_tom_message_inbox.sql with its PostgreSQL form. TomInboxException refuses an empty or over-long consumer name or message id (messaging.inbox.invalid_consumer, messaging.inbox.invalid_message_id). Broker-neutral: there is no message bus or broker adapter in tom_core, and any consumer loop calls process with its own acknowledgement. doc/messaging.md § Idempotent Receiver (Inbox); example/messaging/inbox_example.dart, on the package tour; test/inbox_test.dart, test/inbox_db_test.dart.

  • Resilient outgoing HTTP calls, configured by httpClient. TomServer.httpClientConfigurationKey ('httpClient') names the configuration key and TomServer.configureHttpClients([provider]) applies it to the kernel's TomHttpResilience.configure — timeoutMs, streamedTimeoutMs (uploads with a streamed body; off unless set), retry, circuitBreaker, bulkhead and per-destination destinations. start() calls it after the self-login and before building the router; a value of the wrong shape stops the start with http.client.config.invalid, and without the key the kernel's defaults stand (a 30-second timeout among them). The server's own HTTP clients run under these policies: TomHttpOAuthTokenTransport sends through one shared TomResilientClient (a GET — discovery, SAML metadata, user info — is retried, the token POST is not, as a code is single-use), and TomS3BlobStore and TomOtlpHttpTransport default their client to one; the webhook, SMS and push transports, the FCM token exchange and the server self-login get it through TomServerChannel's default client factory. doc/configuration.md § Outgoing HTTP Calls, with short notes in doc/server.md, doc/authentication.md, doc/file_storage.md, doc/metrics.md and doc/messaging.md; test/http_client_resilience_test.dart.

  • Graceful shutdown on SIGTERM. TomGracefulShutdown (server module) runs, in order: GET /ready answers 503 shutting down; requests are still accepted for the readiness delay; the listener closes and the shutdown gate refuses a new request with 503, Connection: close and Retry-After: 1 while the probe paths (tomProbePaths) pass; every started scheduler — the outbox's included — is stopped and the requests and runs in flight get the grace period to finish; once the responses are written, the started outboxes and schedulers are closed, releasing their leases, and connections still open at the deadline are closed; the data sources close their pools. TomServer.gracefulShutdown is the server's instance and TomServer.shutdown() runs it without a signal (idempotent, never exits). TomServer.start installs the gate as the outermost pipeline layer and registers the shutdown with tomShutdownCleanup for SIGTERM (not on Windows) and SIGINT, raising its phase timeout to cover the delay and the grace period. doc/server.md § Graceful Shutdown, with the Kubernetes settings (terminationGracePeriodSeconds, preStop or readiness delay, probes).

  • Configuration keys shutdownGracePeriodSeconds (default 20), shutdownReadinessDelaySeconds (default 0) and exitOnShutdownSignal (default true; environment SHUTDOWN_GRACE_PERIOD_SECONDS, SHUTDOWN_READINESS_DELAY_SECONDS, EXIT_ON_SHUTDOWN_SIGNAL): the grace period, the time requests are still taken after readiness turned to 503, and whether a signal ends the process with exit code 0 once the shutdown and every cleanup phase have run. doc/configuration.md § Graceful Shutdown.

  • TomHealthServer.markShuttingDown() and TomHealthServer.isShuttingDown: once marked, GET /ready answers 503 shutting down without asking the readiness analyzer, and liveness is unchanged; resetHealthChecks() clears the mark. doc/healthcheck.md § While the server shuts down.

  • example/server/graceful_shutdown_example.dart — a graceful shutdown step by step, around a handler and a scheduler, on the package tour.

  • Request correlation ids and structured logs. tomRequestContextMiddleware mints every request's TomRequestId and continues the caller's W3C traceparent with a span of the server's own (the kernel's TomTraceContext), or starts a trace; it runs the rest of the pipeline in a zone carrying both and answers with the X-Request-Id (tomRequestIdHeader) and traceparent response headers. TomServer.start installs it after the metrics endpoint and before the request metrics, so the access log, both error layers and authentication log under the same ids. Building it registers the request_id field of structured log lines (TomLogContext); a JSON line carries trace_id, span_id, tenant and request_id, and no personal data in a fixed field. doc/server.md § Correlation Ids and Structured Logs.

  • Configuration key logFormat (text — the default — or json; environment LOG_FORMAT) and TomServer.applyLogFormat: json replaces the default console output with the kernel's TomJsonLogOutput, keeping an output the application installed; an unknown value is logged as an error and changes nothing. TomServer.start applies it. doc/configuration.md § Log Format and Readiness.

  • Framework readiness checks — TomFrameworkReadinessAnalyzer and TomReadinessCheck in the healthcheck module: every registered data source operational and answering SELECT 1 within dataSourceTimeout, no database migration running or failed in this process, every started message outbox delivering, and — only when maxOutboxWait is set — no message waiting longer. The body is ready / not ready and one line per check. TomServer.start installs it on GET /ready when the new configuration key frameworkReadiness (default true, environment FRAMEWORK_READINESS) is on and the application installed no readiness analyzer of its own; /health is not affected. doc/healthcheck.md § The Framework Readiness Checks, with the Kubernetes probe configuration.

  • TomDbMigrations.lastRun, a TomMigrationRun with its TomMigrationRunState (notRun, running, succeeded, failed), recorded by every migrateDatabases call, and TomDbMigrations.resetLastRun(). doc/db_migration.md § Where the last run stands.

  • TomMessageOutbox.started — the started, unclosed outboxes of the isolate — and TomMessageOutbox.oldestWaitingSince(). doc/messaging.md § The outbox and readiness.

  • example/healthcheck/readiness_logging_example.dart — the framework readiness checks and the JSON log line of one request, on the package tour.

  • Metrics, exposed for Prometheus and over OTLP — the new metrics module. The server records into the kernel's process registry (TomMetrics.registry, the kernel's new metrics module) beside the scheduler's job series: tom_http_server_requests {method, route, status} and tom_http_server_request_duration_seconds {method, route}, labelled by the endpoint's route pattern (tomUnmatchedRoute, unmatched, when no endpoint matched) through tomRequestMetricsMiddleware; a Prometheus scrape answered by tomMetricsEndpointMiddleware at the outer edge of the pipeline, before authentication, with an optional bearer token compared in constant time; and an OTLP/HTTP JSON export — TomOtlpHttpTransport (signal-neutral, posts to <endpoint>/v1/<signal>, parseHeaders in the OTEL_EXPORTER_OTLP_HEADERS form, urlOf), TomOtlpMetricsExporter (cumulative, every interval, a final push on close), tomOtlpMetricsJson and TomOtlpExportException (otlp.export.failed, otlp.config.invalid_headers). doc/metrics.md, example/metrics/metrics_example.dart.

  • Configuration keys metricsEndpoint, metricsPath, metricsToken, otlpEndpoint, otlpHeaders and otlpMetricsIntervalSeconds (environment METRICS_ENDPOINT, METRICS_PATH, METRICS_TOKEN, OTLP_ENDPOINT, OTLP_HEADERS, OTLP_METRICS_INTERVAL_SECONDS); TomServer.start installs the scrape when metricsEndpoint is on and creates TomServer.metricsExporter — registered with the shutdown cleanup — when otlpEndpoint is set. doc/configuration.md § Publishing Metrics.

  • TomRequestLogInfo.route — the pattern of the endpoint that served the request, set by the endpoint pipeline.

  • Data-source series, recorded by TomSqlDatasource for both drivers: tom_db_statement_duration_seconds and tom_db_statement_errors {datasource, operation}, tom_db_pool_connections {datasource, state: idle, in_use, waiting, max}, tom_db_pool_acquire_duration_seconds and tom_db_pool_acquire_failures {datasource} — names exported as tomDbStatementDurationMetric, tomDbStatementErrorsMetric, tomDbPoolConnectionsMetric, tomDbPoolAcquireDurationMetric and tomDbPoolAcquireFailuresMetric. The label is the new TomSqlDatasource.registeredName, recorded by registerAs, which TomDataSourceInfo.initialize calls — <base>@<tenant> for a tenant's own pool.

  • tom_cache_lookups {cache, result: hit, miss, expired} (tomCacheLookupsMetric) and tom_rate_limit_refusals {rule, tenant} (tomRateLimitRefusalsMetric, with tomNoTenantLabel = none outside any tenant).

  • TomSqlJobStore.backlog and its row type TomJobBacklogRow — the due, unclaimed runs per job and the earliest among them, in one grouped SELECT, read by the kernel scheduler's tom_job_backlog and tom_job_backlog_age_seconds gauges. The messaging outbox runs as the job messaging.outbox, so its lag is the kernel's job series.

  • PostgreSQL (15 and later) as a second database beside MariaDB. PostgresDatasource (type: postgresql) over package:postgres, with the settings schema (the session's search_path) and sslMode (disable, require — the default — or verifyFull; anything else is error.initialize.invalid_ssl_mode), port 5432 by default and the postgres database when databaseName is empty. Sessions run in UTC; :name placeholders become $1, $2, …; every bind is sent untyped so PostgreSQL infers it from its use; result columns match without regard to case; a single-integer INSERT … RETURNING is reported as the generated id; a statement refused with cached plan must not change result type is prepared again once. PostgresXADatasource (type: postgresql_xa) takes part in two-phase commits with BEGIN / PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK [PREPARED], honours read-only, and needs max_prepared_transactions > 0. Failures are PostgresDatasourceException / PostgresConnectionLostException; classifySqlFailure reads a PostgreSQL ServerException as definite. doc/datasources.md § The SQL Drivers.

  • TomSqlDatasource, the driver-neutral SQL base both drivers extend: the pool, a connection per transaction (TomSqlBranch), the statement paths, the one-statement rule, the retry after a lost connection, the row mapping and the settings binding, with TomSqlPreparedStatement, TomSqlRow / TomSqlResult / TomSqlResultValues (every column read as text in one convention), TomSqlLink, TomSqlDatasourceException, TomSqlConnectionLostException, tomSqlBindValue / tomSqlBindParams and tomSqlBranchId.

  • PostgresSqlDialect, and the canonical DDL in each database's words. TomStandardSqlDialect gains columnType, columnDefinition, declaresIndexesInline, keyDefinition, createIndex, insertInto (with ignoreConflict), replaceInto, incrementCounter, returningKey, translateCanonical, translateCanonicalScript, createSchema, schemaSetting, currentSchema, tableColumnsQuery, rejectingTrigger, truthPredicate and quoteIdentifier; the defaults render MariaDB's spelling unchanged. PostgresSqlDialect renders the framework's canonical types (DATETIME(n) → TIMESTAMP(n), TINYINT(1) → SMALLINT, LONGTEXT → TEXT, CHAR(n) → VARCHAR(n), blobs → BYTEA, AUTO_INCREMENT → identity …), indexes as separate statements, ON CONFLICT, RETURNING, and MariaDB's REPLACE as a DELETE of every superseded row followed by an upsert. doc/datasources.md § One declaration, every database.

  • TomFrameworkTable renders for the connected database: createStatements / create, insertRow (with ignoreConflict), replaceStatements / replaceRow (one transaction when a database needs two statements), and TomFrameworkColumn.ddlIn. createTableDdl stays the canonical rendering the convergence guard reads.

  • PostgresMigrationAdaptor (type: postgresql) runs a script and its version row in one transaction, so a failed script is rolled back and nothing is recorded; its keys are prefixed postgres_migration. and postgres_migration_adapter. (PostgresMigrationException). The shared part is TomSqlMigrationAdaptor (with TomSqlMigrationException), which holds the history, the rebinding, the refusal of a recorded failure and the static checksumOf / isWellFormedChecksum; TomDbVersion lives beside it. doc/db_migration.md § The SQL Adaptors.

  • PostgreSQL reference migrations. Every example/<module>/migrations/main/tomcore/*.sql has a generated PostgreSQL form in example/<module>/migrations_postgresql/main/tomcore/, written by dart run tool/generate_postgres_migrations.dart and held to it by test/postgres_migrations_test.dart.

  • TruthPredicate and TomQueryBuilder.truthPredicate — a constant predicate, rendered through the dialect's truthPredicate; a data scope that admits nothing renders it.

  • auditTrailGuardStatements(table, dialect, event:) — the audit trail's write-once guard in a given dialect's database (a trigger function and its trigger on PostgreSQL).

  • SqlPlaceholderScan.toPositional(slot:, backslashEscapes:) — the slot renderer and PostgreSQL's literal rule; a :: cast is never read as a placeholder.

  • The DB suites run against both databases. tool/run_db_tests_postgres.sh starts PostgreSQL 15 on port 5439 (docker-compose.test.postgres.yaml, its own compose project, max_prepared_transactions=20) and runs the db-tagged suites with TOM_CORE_SERVER_TEST_DB_ENGINE=postgresql; a test of a MariaDB-client-only behaviour skips there with its reason stated. test/postgres_dialect_test.dart covers the dialect and the driver's rewrite and settings without a server.

  • A local copy of item authorizations per signed-in user. TomItemAuthorizationClient(copiedTypes: …) answers checks and accessible-item lists of those types from a copy — every item the user reaches with its level — loaded by loadCopy at sign-in (one accessible-item query per level) or on first use, held in memory on each node in TomItemAuthorizationCache's new local-copy cache (copyCacheName, lookupCopy, storeCopy; install(copyTimeToLive: …, maximumCopies: …), twelve hours and 1,000 copies by default), never persisted. A copy drops with every change that names its user — or no user — on an item of its type, and with * for the user, the report of a group membership or delegation change; one overtaken by a change while loading is not kept, and one past maximumScopeItems is refused (error.item_authority.scope_too_large). The row scope reads it unchanged, still as an IN list. doc/item_authorization.md § The Local Copy, doc/cache.md; tests in test/item_authorization_client_test.dart and test/item_authorization_invalidation_test.dart.

  • The item_authorization module: item authorizations kept by an external authority. TomItemAuthority is the transport-neutral port to it — Tom UAM's checks (at most 100 items a call), accessible-item query (paged) and change feed with the application's credentials, and share, change, revoke and shared-with-me with the user's token. TomItemAuthorizationClient answers levelOn / levelsOn / allows from TomItemAuthorizationCache and asks the rest in chunks, gathers accessibleItemIds page by page and caches them, refuses a list over maximumScopeItems (error.item_authority.scope_too_large) rather than cutting it short, drops the answers each sharing act affects on every node, and builds the feed follower; an answer overtaken by an invalidation, or given while acting for a delegator, is not cached. TomItemAuthorityScopeSource feeds the resource row scope from it. TomItemAuthorizationCache gains accessible-item lists (lookupAccessible, storeAccessible), dropped with the changes of their subject and type, and generation. doc/item_authorization.md, doc/authorization.md § When an external authority keeps the grants; tests in test/item_authorization_client_test.dart.

  • Item-authorization answers, dropped by item and subject from outside. TomItemAuthorizationCache keeps the level a remote authority answered for a subject on an item (TomCachedItemLevel). The new invalidation kind TomInvalidationKind.item, raised by TomCacheInvalidator.invalidateItemAuthorization(item, subject: …), drops the answers on an item, a pattern <type>:<prefix>* or * — for one subject or every subject — on every node, through the installed channel. TomItemAuthorizationWebhook verifies an itemAuthorization.changed delivery (HMAC-SHA256 over event id, timestamp and body; two secrets during a rotation; five minutes' tolerance) and raises one per change; TomItemAuthorizationFeedFollower reads the authority's change feed on a timer as the catch-up for a lost delivery, taking the current position and dropping every answer when it has none or its cursor expired (TomItemAuthorizationFeedExpiredException). TomCache.removeWhere drops the entries a test selects. doc/cache.md § Item-Authorization Answers; tests in test/item_authorization_invalidation_test.dart and test/cache_invalidation_db_test.dart.

  • Item permission levels in the grant layer. On the key of an item type the kernel's TomItemTypeRegistry declares, TomPrincipalResourceRoleResolver.foldRoles keeps the stronger level by the type's order and TomResourceDataScope grades the level through the type (TomResourceRoleRegistry.strongerOn, stateOn): view and comment admit a row to queries, edit and manage to writes. doc/authorization.md § Grants, Grades and Delegations, § The grades are the design; tests in test/resource_data_scope_test.dart.

  • The sign-in organization. TomSignInOrganizationDirectory, a bean the deployment installs, lists the organizations a principal may sign in for (TomSignInOrganizations: the organizations, the default, whether a choice is offered, and with TomOrganizationFallback what a non-member organization at sign-in does) and signs a principal in for one of them (signInFor). TomAuthenticationServer settles every login's organization against the list before the second factor is decided — the named organization for a member, the default for none, refusal or the default for a non-member (login.error.organization.not-member, login.error.organization.none) — and switchOrganization issues new tokens for another organization within a session without a credential check, the second factor carried over, audited as session.organizationSwitched; signInOrganizations answers the list. Without a directory nothing changes. doc/authentication.md § The sign-in organization; tests in test/sign_in_organization_test.dart.

  • Tenant and organization from an external identity provider's token. TomExternalTenancyMapping (tenancyMapping on both provider configs, read from tenantClaim, organizationClaim and organizationParameter) reads the tenant and the sign-in organization from an OIDC token or a SAML assertion — for an application signing in through a Tom identity provider such as Tom UAM. TomOAuthAuthenticationService refuses a claimed tenant other than the request's (login.error.tenant-mismatch) before any account is linked or provisioned, places the principal in the claimed tenant, and makes the claimed organization the login's and the principal's. A configured claim is required (login.error.oauth.tenant-missing, login.error.oauth.organization-missing, login.error.oauth.tenant-invalid). With organizationParameter, an OIDC authorization request carries the organization the login asked for, so a new authorization naming another organization is how a user switches. doc/authentication.md § Claim mapping: tenant and organization; tests in test/oauth_test.dart and test/saml_test.dart.

  • Identity federation per tenant. SCIM, the pending state of an external sign-in and the provider configuration follow the tenant in context. TomSqlScimStore.named resolves its data source through tenancy's routing on every call, and tom_scim_resource gains a tenant column every statement filters on, so two tenants' identity providers see only their own users and groups in every isolation model (a userName is unique per tenant); TomMemoryScimStore keeps a partition per tenant. TomScimProvisioning takes secrets per tenant (tenantBearerTokens, scim.tenants.<id>.bearerTokens), and inside a tenant only its own admit. TomOAuthAuthorizationState.tenant records the tenant a flow began in, and both state stores answer and spend records of the tenant in context only, so a callback in another tenant fails as oauth.flow.unknown_state; tom_oauth_state gains a tenant column. TomOAuthProviders.tenants and TomSamlProviders.tenants (TomTenantProviderSets) hold provider sets per tenant, read by loadTenants() from oauth.tenants / saml.tenants or installed at run time; resolve() answers the set of the tenant in context. A deployment that applied the earlier reference migrations runs ALTER TABLE tom_oauth_state ADD COLUMN tenant VARCHAR(32) NOT NULL DEFAULT '' and ALTER TABLE tom_scim_resource ADD COLUMN tenant VARCHAR(32) NOT NULL DEFAULT '', then re-creates uq_tom_scim_resource_name and ix_tom_scim_resource_external with tenant first. doc/tenancy.md § Identity Federation per Tenant; tests in test/tenant_bound_stores_test.dart, test/tenant_bound_stores_db_test.dart (all three isolation models) and test/oauth_test.dart.

  • Removing a tenant from a running deployment — TomTenancy.removeTenant, the counterpart of addTenant. The tenant leaves tenants, so its requests — by token claim, host, path or header — and its work in context are refused as tenancy.tenant.unknown; its routes are dropped and the data sources the tenancy opened for it are unregistered and closed, with their connection pools. A data source it only named by reference stays open; its schema or database is left for the caller to drop, with the returned TomTenant. doc/tenancy.md § Removing a Tenant; tests in test/tenancy_test.dart and, under all three isolation models, test/tenancy_db_test.dart.

  • Tenant resolution by a path segment — TomTenantPathResolver in tenancy/tenant_resolution.dart, beside the host and header resolvers. It names the tenant by the path segment after prefix (t by default: /t/acme/…), mapped through lookup when the URLs name tenants by a slug other than the tenant id. A segment the lookup does not know is refused by TomTenancy.resolveRequest as tenancy.tenant.unknown (404) instead of passing as a request without a tenant; a token for another tenant is refused as tenancy.resolution.conflict (403), as for host and header. Configured as resolution: [path] with pathPrefix and pathSegments (segment → tenant id). doc/tenancy.md § Resolving a Request's Tenant; tests in test/tenancy_test.dart.

  • Secret references in configuration — configuration/secrets.dart. A value in any configuration source may be written secret:<provider>:<path>[#<field>]. TomSecrets holds the registered providers and the resolved values: register, providers, references, load (every reference in a tree, each provider path fetched once, fatal on the first failure), resolve, current, substitute, watch, refresh (fail-soft: a reference that cannot be fetched keeps its last value and is reported), startRefresh, stopRefresh and reset. TomSecretsProvider (name, fetch, close) is the store interface, TomMapSecretsProvider a map-backed one for tests and local development; TomSecretReference (parse, isReference, prefix), TomSecretPayload (data, version, valueOf — a field of a map or of a JSON object string), TomSecretSubscription and TomSecretsRefresh (changed, failed). TomSecretsException raises secrets.reference.malformed, secrets.reference.unresolved, secrets.provider.unknown, secrets.field.required, secrets.field.missing, secrets.fetch.not_found and secrets.fetch.failed.

  • TomBaseServerConfiguration.collectSources: the four configuration sources merged in precedence order with secret references unresolved — what a boot hands to TomSecrets.load. getDefaultValues is collectSources with every reference substituted in place, so a secret takes the precedence of the source it was written in; a reference that was not loaded is refused (secrets.reference.unresolved).

  • example/configuration/secrets_example.dart (nine steps, in the tour) and test/secrets_test.dart.

  • Service accounts and API keys — authentication/api_keys.dart and authentication/sql_api_key_store.dart. TomServiceAccount (id, name, roles, tenant, organization, disabled; withDisabled, withRoles), TomApiKey (the SHA-256 of the secret, never the secret; isLiveAt, copyWith), TomIssuedApiKey (the one-time tomk.<keyId>.<secret> value), TomApiKeyIdentity (the key a request authenticated with, in the request zone), TomApiKeyStore, TomMemoryApiKeyStore, and TomApiKeys with install, reset, current, saveAccount, issue, rotate (overlap zero by default — the old key ends at once; an overlap never extends a key past its own expiry), revoke, keysOf, authenticate and hashSecret. A key's roles are intersected with its account's; the principal is service:<accountId> with the account's tenant and organization. Key records are cached per node in tom.apiKeys for at most lookupCacheSeconds (60), and revoke / rotate drop the entry through TomCacheInvalidator; lastUsedAt is written at most once per touchInterval (one minute). TomSqlApiKeyStore keeps accounts and keys in tom_service_account and tom_api_key (serviceAccountTable, apiKeyTable, defaultServiceAccountTable, defaultApiKeyTable, TomServiceAccountRow, TomApiKeyRow). TomApiKeyException raises api_key.issue.unknown_account, api_key.issue.role_not_held, api_key.rotate.unknown_key and api_key.revoke.unknown_key.

  • Requests authenticated by an API key. tomDefaultAuthenticationMiddleware reads X-API-Key when a request carries no Authorization header, puts the service account's principal and its TomApiKeyIdentity in the zone. A key never widens a request that presented a bearer token; with no TomApiKeys installed the header is ignored with a warning.

  • Rate limiting — the new ratelimit module (ratelimit/rate_limit.dart, ratelimit/sql_rate_limiter.dart). TomRateLimitScope (principal, apiKey, address; parse), TomRateLimitRule (name, scope, limit, window, pathPrefixes, anonymousOnly), TomRateLimitDecision with the sliding-window counter in slidingWindow and retryAfterSeconds, TomRateLimiter, TomMemoryRateLimiter (per node), TomSqlRateLimiter (the tom_rate_limit table — rateLimitTable, defaultRateLimitTable, TomRateLimitRow — counted with one atomic INSERT … ON DUPLICATE KEY UPDATE per request), TomClientAddress (X-Forwarded-For read right to left, only from trusted proxies), TomRateLimits (configurationKey rateLimits, fromConfiguration, install, reset, current, bucketOf, check, headersOf, tooManyRequests) and tomRateLimitMiddleware. A refused request is answered 429 with Retry-After and RateLimit-Limit / -Remaining / -Reset; an admitted one carries the RateLimit-* headers of its tightest rule. TomRateLimitException raises rate_limit.exceeded and rate_limit.config.not_a_map, invalid_rule, duplicate_rule, unknown_scope, unknown_limiter and missing_datasource. Nothing is limited by default.

  • TomServer stacks tomRateLimitMiddleware inside the authentication middleware, and start installs the limits the rateLimits configuration key describes when none were installed.

  • example/ratelimit/rate_limit_example.dart walks a service account's key through seven steps; test/api_keys_test.dart and test/ratelimit_test.dart cover both features without a database and test/ratelimit_api_keys_db_test.dart (tagged db) runs the shared store and limiter across two nodes.

  • doc/ratelimit.md, doc/api/api_summary_ratelimit.md, the module readme, and the service-account section of doc/authentication.md.

  • Multi-tenancy — the new tenancy module (tenancy/tenancy.dart, tenancy/tenant_resolution.dart). TomTenancy holds the deployment's TomTenants, read from the tenancy configuration key by fromConfiguration, and is installed with TomTenancy.install, which provisions every tenant and routes data-source lookups; reset, current, addTenant (onboarding without a restart), isolationOf, route, runFor, forEachTenant and resolveRequest. TomTenantIsolation — shared, schema (a schema of its own on the shared server, created with createSchemas) or database (a database of its own, configured per tenant) — is chosen per tenant. TomTenantResolver, TomTenantHostResolver (subdomain or own host), TomTenantHeaderResolver (X-Tenant-Id by default) and TomTenantResolution. TomTenancyException raises tenancy.config.not_a_map, tenancy.config.unknown_isolation, tenancy.config.unknown_resolver, tenancy.config.schema_unsupported, tenancy.config.invalid_schema, tenancy.config.missing_datasource, tenancy.config.unknown_datasource and tenancy.tenant.unknown. A tenant is an isolation boundary, distinct from the organization, a business unit inside it.

  • Requests run in their tenant. With a tenancy installed, tomDefaultAuthenticationMiddleware resolves the request's tenant — the verified token's tenant claim, authoritative; otherwise host or header — and runs the request inside TomTenantContext. A request naming another tenant than its token is answered 403 tenancy.resolution.conflict, an unknown tenant 404 tenancy.tenant.unknown, a malformed one 400 tenancy.id.invalid, each with a JSON body.

  • Tokens are bound to their tenant. TomAuthenticationServer sets the principal's tenant to the tenant the login runs in, so the token carries the tenant claim, and refuses a principal placed in another tenant with login.error.tenant-mismatch.

  • @TomTenantOwned (object_persistence/persistence_annotations.dart, field default tenantId) and TomAnnotationAnalyzer.findTenantOwnership. The repository ANDs <field> = <tenant> with the @TomDbScope predicate on every read (parameters prefixed TomSqlDatasourceRepository.tenantParameterPrefix, __tomtenant_), fills the tenant on insert, and refuses a write with no tenant (data_scope.write.no_tenant) or naming another (data_scope.write.foreign_tenant).

  • Data-source routing. TomDataSourceInfo.router, route and getBaseDataSourceInfo; getDataSourceInfo answers the data source as the current tenant reaches it.

  • TomSchemaProvisioning (datasources/framework_table.dart): a store's tables are created at most once, however many first calls arrive together.

  • Tenants in caches, revocations, jobs and messages. TomCache's static API qualifies keys by the tenant in context; TomCacheInvalidator.revokePrincipal and invalidateCacheEntry qualify by it; TomAuthorizationCache.isRevoked reads the token's tenant claim. tom_job_run gains tenant VARCHAR(32) NOT NULL DEFAULT '' and the window key (job_name, tenant, scheduled_for); TomSqlJobStore.findWindow takes tenant:. The outbox queues a message for the tenant in context and delivers it inside it; TomDeliveryReceipt.tenant, TomDeliveryReceipt.forTenant and TomDeliveryCorrelation.tenant, stored in tom_delivery_report.tenant VARCHAR(32) NULL.

  • example/tenancy/tenancy_example.dart walks two tenants through eight steps; test/tenancy_test.dart covers the module without a database and test/tenancy_db_test.dart (tagged db) under all three isolation models.

  • doc/tenancy.md, doc/api/api_summary_tenancy.md and the module readme.

  • User provisioning over SCIM 2.0 (RFC 7643 / RFC 7644), for Microsoft Entra ID, Okta and any other SCIM client. TomScimService (authentication/scim_service.dart) creates, reads, queries, replaces, patches and deletes users and groups over a TomScimStore — TomMemoryScimStore, or TomSqlScimStore (authentication/sql_scim_store.dart) over the new framework table tom_scim_resource (scimResourceTable, scimResourceTableDdl, defaultScimResourceTable in authentication/scim_sql.dart; reference migration example/authentication/migrations/main/tomcore/[5]-create_tom_scim_resource.sql). Filtering (eq ne co sw ew gt ge lt le pr, and / or / not, value filters), paging, attributes / excludedAttributes, and PATCH in the forms Entra ID and Okta send (TomScimFilter, TomScimPath, scimValues / scimLookup / scimKeyOf in authentication/scim_filter.dart). Bulk, sorting, ETags, /Me, .search and password changes are not supported and ServiceProviderConfig says so. TomScimResource, TomScimResourceType, TomScimSchemas and TomScimException (scim.not_found, scim.uniqueness, scim.invalid_filter, scim.invalid_path, scim.invalid_value, scim.invalid_syntax, scim.no_target, scim.mutability, scim.unauthorized, scim.not_enabled; clientThrow: true) in authentication/scim_resource.dart.

  • The SCIM endpoints (TomScimApi / TomScimServer, authentication/scim_endpoints.dart): seventeen routes under /scim/v2 — ServiceProviderConfig, ResourceTypes, Schemas, and Users / Groups with GET, POST, PUT, PATCH and DELETE — in application/scim+json, enabled by installing a TomScimProvisioning at start-up, read from scim.bearerTokens and scim.baseUrl by fromConfiguration. The identity provider's bearer secrets are held as SHA-256 digests and compared in constant time.

  • Deprovisioning revokes access. Deactivating, deleting or renaming a user, and changing, renaming or deleting a group, calls TomScimService.revoke — TomCacheInvalidator.revokePrincipal by default — for every affected user, so the sessions they hold are refused on every node.

  • TomScimIdentityDirectory (authentication/scim_directory.dart): single sign-on resolves to the provisioned account under provisioning: linkExisting, with roles mapped from the account's groups, and refuses a deactivated account with login.error.scim.inactive.

  • TomEndpointCredentials (server/server.dart): paths whose Authorization header belongs to the endpoint. extractAuthentication leaves such a header unread and reports the new TomCredentialOutcome.endpointCredential. TomScimProvisioning.install claims /scim/v2/.

  • example/authentication/scim_provisioning_example.dart runs provisioning, single sign-on and deprovisioning end to end through the server's router.

  • Asymmetric token signing, key rotation and a JSON Web Key Set. Tokens are signed RS256 by default, ES256 or HS256 by jwtAlgorithm, with the private key read from the PEM file at jwtSigningKeyFile (PKCS#8; EC keys on the named curve P-256). Every token carries a kid header: the RFC 7638 thumbprint of the key's public JWK, so every server derives the same id from the same key without configuring it. jwtVerificationKeyFiles lists public keys (SPKI) that still verify — the keys an issuer rotated away from, or the issuer's keys on a server that only verifies. jwtIssuer (default tom) and jwtAudience (default none) are written into every token and required of every token verified. New in authentication/jwt_keys.dart: TomJwtSigning (current / install / reset, keys, signingKeyId, canSign, issuer, audience, keyFor, jwks, requireSigning), TomJwtAlgorithm, TomJwtKey, TomJwtSigningKey, readPemFile and TomJwtKeyException (jwt.keys.unknown_algorithm, jwt.keys.unreadable_key_file, jwt.keys.verify_only; clientThrow: false).

  • GET /.well-known/jwks.json (TomJwksService / TomJwksServer, authentication/jwks_endpoint.dart), mounted like every component endpoint: the public half of every verification key, signing key first, as application/jwk-set+json with cache-control: public, max-age=300; @TomPublicAccess; 404 under HS256. Any JWT library or gateway verifies this deployment's tokens through it.

  • A verify-only server. Verification key files and no signing key: tokens are verified, and issuing one is refused with jwt.keys.verify_only.

  • doc/authentication.md documents the rotation procedure.

  • Enterprise single sign-on over SAML 2.0. SAML identity providers are configured under saml.providers (TomSamlProviders, TomSamlProviderConfig, authentication/saml_provider.dart), from the identity provider's metadata (metadataUrl, fetched by TomSamlProviders.load) or directly (issuer, ssoUrl, idpCertificates). TomSamlFlow (authentication/saml_flow.dart) runs service-provider-initiated Web Browser SSO in three legs: the AuthnRequest over the HTTP-Redirect binding (signed when spSigningKeyFile is set), the response to POST /saml/acs, answered with a 303 to the application's redirectUri carrying a one-time handoff code, and the completion through the existing POST /oauth/complete. GET /saml/metadata/<providerId> publishes this server's metadata (TomSamlService / TomSamlServer, authentication/saml_endpoints.dart). TomSamlResponseValidator checks destination, InResponseTo, issuer, status, a single unencrypted assertion, signatures, bearer confirmation, validity window and audience, each with its own saml.* key. TomXmlSignatureVerifier and TomExclusiveCanonicalizer (authentication/saml_xml_signature.dart) verify RSA-SHA256/512 enveloped signatures under exclusive canonicalization, refuse SHA-1, ignore KeyInfo, and defeat signature wrapping. New TomSamlException, TomSamlNames, TomXmlDsig, TomSamlCertificates, TomSamlIdpMetadata. New dependency xml.

  • OpenID Connect discovery. A provider with discovery: true (and an optional discoveryUrl) takes authorizationEndpoint, tokenEndpoint, userInfoEndpoint and revocationEndpoint from the issuer's discovery document, read at start-up by TomOAuthProviders.load / discover; explicit values win, and the document's issuer must match exactly (oauth.config.discovery_issuer_mismatch, oauth.config.discovery_failed, oauth.config.discovery_not_loaded). TomOAuthProviderConfig.isDiscovered.

  • Group-to-role mapping. TomExternalRoleMapping (authentication/external_provider.dart) reads groupsClaim (dotted paths supported), roleMapping and defaultRoles per provider, for both protocols (oauth.config.invalid_role_mapping). The mapped roles are applied to the session's principal on every login by applyExternalRoles, beside the account's own. TomExternalIdentity gained groups, roles, toJson and fromJson; TomOAuthProviderConfig gained roleMapping.

  • TomExternalProvider, the interface TomOAuthProviderConfig and TomSamlProviderConfig implement.

  • TomOAuthProvisioning.linkExisting: link a pre-provisioned account by the asserted username, provision none.

  • The OpenID Connect nonce. Every flow whose scopes include openid sends a nonce, stored on TomOAuthAuthorizationState.nonce, and refuses an id_token that does not return it (oauth.flow.nonce_mismatch).

  • TomOAuthFlow.saml and TomOAuthFlow.descriptors: the flow dispatches SAML provider ids to TomSamlFlow, and GET /oauth/providers lists OAuth and SAML providers together. Provider ids are unique across both protocols (oauth.config.duplicate_provider_id).

  • TomOAuthAuthorizationState.assertedIdentity holds a verified SAML identity between the assertion consumer service and the completion.

  • Optimistic locking: @TomVersion (object_persistence/persistence_annotations.dart) on an int field, read as TomColumnInformation.version and found by TomAnnotationAnalyzer.findVersionColumn. An insert writes version 1; an update writes version + 1 only WHERE … AND version = :__tomversion_expected (TomSqlDatasourceRepository.versionParameter; buildUpdateStatement takes the column as version:), and delete(entity) checks the version the same way. A write that changes no row raises TomOptimisticLockException (extends TomRepositoryException, clientThrow: true, heldVersion, storedVersion — null when the row is gone) with crud_repository.save.version_conflict / crud_repository.delete.version_conflict, writing nothing, discarding no blob and auditing nothing. A versioned save or delete with a null version is refused with crud_repository.save.version_missing / crud_repository.delete.version_missing. The analyzer refuses a second version field (annotation_analyzer.version.more_than_one) and one that is not an int, is the entity id, an association or read-only (annotation_analyzer.version.invalid_field). deleteById and the query deletes are not version-checked. Example: example/object_persistence/optimistic_locking_example.dart.

  • TomPreparedStatement.run and TomStatementOutcome<T> (datasources/datasource_initialization.dart): run returns the rows, the affected-row count (affectedRows) and the generated key (generatedId) of that one execution. MariadbPreparedStatement.run reads all three from the execution's own result set, retry included; execute returns its rows.

  • TomSchemaDivergenceKind.nullableVersionColumn (error): the schema-convergence check reports a @TomVersion column the migration chain leaves nullable, since a NULL version matches no versioned save. TomEntitySchemaReader reads a version column as nullable: false. A table gains the column with ALTER TABLE t ADD COLUMN version BIGINT NOT NULL DEFAULT 0;, and its existing rows load at version 0.

  • The OpenAPI document endpoint (endpoints/openapi_document_endpoint.dart). TomOpenApiService declares GET /openapi.json (@TomPublicAccess(), logRequests: false) and TomOpenApiServer serves the file TomOpenApiServer.globalSettingDocumentPath names, read on every request. The document is generated at build time by tom_core_openapi; the server only serves it. With no path configured the route answers 404 with the error envelope keyed openapi.document.not_served, and with a path naming no file 404 keyed openapi.document.missing.

  • TomBaseServerConfiguration.openApiDocument (environment name OPEN_API_DOCUMENT, default empty): the document GET /openapi.json serves. TomServer.start sets TomOpenApiServer.globalSettingDocumentPath from it.

  • doc/endpoints.md § The OpenAPI Document and doc/configuration.md § Publishing the OpenAPI Document.

  • Per-instance health analyzers (healthcheck module). TomHealthServer({liveness, readiness, timeout}) carries its own analyzers and deadline, read through the getters liveness, readiness and timeout; each one left out is the process-wide setting (livenessAnalyzer, readinessAnalyzer, healthTimeout), read when the probe arrives. A non-positive timeout is refused with an ArgumentError. The mounted bean is built with no arguments and answers with the process-wide settings. TomHealthServer.verdictOf takes the deadline per call (timeout, default healthTimeout). doc/healthcheck.md § Per-Instance Analyzers.

  • Cooperative cancellation of a timed-out health analysis (healthcheck module). TomHealthCheck (probe, deadline, isCancelled, cancelled, throwIfCancelled, cancel) is handed to every analysis; when the deadline passes verdictOf answers TIMEOUT and cancels it, and throwIfCancelled throws TomHealthCheckCancelled. Probes share one analysis per analyzer, and the first deadline to pass cancels it. TomFrameworkReadinessAnalyzer.checks([check]) stops between its stages once the check is cancelled. doc/healthcheck.md § Cancelling a Timed-Out Analysis; example/healthcheck/healthcheck_example.dart steps 7 and 8.

Changed #

  • Breaking: TomHealthAnalyzer.analyzeHealth takes the check. The contract is Future<TomHealthStatus> analyzeHealth(TomHealthCheck check); an analyzer overriding analyzeHealth() adds the parameter, and one composing TomFrameworkReadinessAnalyzer passes its check through. A synchronous throw from analyzeHealth is answered as FAILED, like an asynchronous one.

  • The request id is minted before authentication. tomDefaultAuthenticationMiddleware carries the TomRequestId it finds in the zone — the one tomRequestContextMiddleware minted — into the request's zone values, and mints one only when it runs without that middleware outside it.

  • TomMessageOutbox.start after close throws messaging.outbox.closed, as send does.

  • The server records metrics. doc/server.md no longer states that the module has no metrics facility and that a metrics route is an ordinary endpoint: the pipeline now opens with the scrape endpoint (when configured) and the request series, outside logging, both error layers and authentication. doc/server.md § The Pipeline and § Metrics.

  • Breaking: the MariaDB types extend the SQL base. MariaDbDatasource / MariaDbXADatasource extend TomSqlDatasource; MariadbDatasourceException extends TomSqlDatasourceException, MariadbPreparedStatementException extends TomSqlConnectionLostException, MariadbPreparedStatement extends TomSqlPreparedStatement, and MariadbMigrationAdaptor / MariadbMigrationException extend TomSqlMigrationAdaptor / TomSqlMigrationException. MariaDbDatasource.integerSettings is TomSqlDatasource.integerSettings; mapGroupedRow is an instance method of the base taking a TomSqlRow; mapResultSet maps through mapSqlResult; checksumOf / isWellFormedChecksum are TomSqlMigrationAdaptor's.

  • Breaking: TomFrameworkTable.insert(verb, values) is gone — use insertRow(dialect, values, ignoreConflict: …) or replaceRow / replaceStatements.

  • The framework's stores write in their data source's dialect: every store creates its table with TomFrameworkTable.create, inserts with insertRow and replaces with replaceRow; the rate limiter counts with incrementCounter; repository inserts of a single surrogate key go through returningKey; schema isolation creates and points at a schema through createSchema / schemaSetting (a schema inside the one database on PostgreSQL); the framework-schema verifyLive classifies with the connected dialect and reads tableColumnsQuery; the audit trigger probe reads the dialect's currentSchema.

  • Per-principal rows are keyed by the tenant-qualified principal id (TomPrincipal.qualifiedId, tenant:<tenant>/<id>; the bare id without tenancy). A principal id is unique within its tenant only: another tenant's u1 is a different person. Tom2FAEnrolmentStore.principalKeyOf and Tom2FAGraceStore.principalKeyOf, the owner data scope, TomUserPreferences and TomResourceDataScope's default key all use it, so under shared isolation two tenants' rows stay apart in one table, and under schema and database the stored key is the same. TomUserPreference.principalId is a String and tom_user_preference.principal_id a VARCHAR(255); a deployment that applied the earlier reference migration runs ALTER TABLE tom_user_preference MODIFY principal_id VARCHAR(255) NOT NULL — its rows, written without tenancy, keep their keys. TomAuditActor.userId is a String?. Scope values bind in the column's type and compare by their text (TomDataScope.inList, TomDataScope.permits): an int owner column of a deployment without tenancy keeps matching the bare id, and a tenant-qualified id matches no row there. doc/tenancy.md § Principals per Tenant; tests in test/principal_per_tenant_db_test.dart (all three isolation models) and test/tenancy_test.dart.

  • tom_job_run.job_name is VARCHAR(150), so the window key (job_name, tenant, scheduled_for) stays inside the 767-byte index limit of older InnoDB row formats; the reference migration example/scheduling/migrations/main/tomcore/[1]-create_tom_job_run.sql declares the new shape.

  • TomSqlDatasourceRepository.datasourceInfo is a getter resolved through TomDataSourceInfo.route on every access, and prepared statements are cached per data source, so one repository serves every tenant.

  • Every framework store's ensureSchema — TomSqlJobStore, TomSqlLeaseLock, TomSqlSchedulerNodes, TomSqlDeliveryReportStore, TomSqlInvalidationChannel, TomSqlOAuthStateStore, TomSqlScimStore, TomDatabaseBlobStore — runs through TomSchemaProvisioning. Concurrent first calls on a pooled data source no longer race a CREATE TABLE still in progress on another connection.

  • frameworkOwnedTables lists scimResourceTable and takes a scimResource parameter, so the framework schema check covers tom_scim_resource.

  • verifyBearerToken checks whose token it is: the algorithm is the configured one and never the token's alg header (no algorithm confusion), the kid must name a held key, and iss and aud must match the configuration.

  • installJwtSigning defaults to RS256. Unconfigured, it signs with the bundled tom_crypto RSA pair, in a development or test environment only, with a warning; ES256 has no sample key and is refused unconfigured everywhere. jwtSharedSecretKey is the HS256 secret and is read under no other algorithm. jwtRsaPrivKey / jwtRsaPubKey remain the pair that encrypts the token's access-control payload, under every algorithm.

  • Breaking: TomAuthenticationServer.globalSettingJwtOrganization is gone. Issued tokens carry TomJwtSigning.current's issuer and audience — the configured jwtIssuer and jwtAudience.

  • Requires tom_crypto 1.3.0 (TomJwtConfiguration.keyId, written as the kid header, and TomServerJwtToken(audience:)).

  • Breaking: external logins provision just in time by default. TomOAuthProvisioning.autoProvision is the default of TomOAuthProviderConfig and TomSamlProviderConfig. A deployment offering a public provider sets provisioning: refuse for it.

  • Breaking: TomOAuthExchange.provider is a TomExternalProvider, so it carries a SAML provider as well; code reading OAuth-only fields from it checks for TomOAuthProviderConfig.

  • Breaking: tom_oauth_state has two more columns, nonce VARCHAR(191) NOT NULL and asserted_identity TEXT NULL (example/authentication/migrations/main/tomcore/[2]-create_tom_oauth_state.sql). CREATE TABLE IF NOT EXISTS leaves an existing table as it is, so a deployment holding one adds the two columns itself, or drops the table — it holds only ten-minute pending records. TomOAuthStateRow maps both.

  • TomOAuthFlow's constructor is no longer const: it checks that no provider id is configured under both protocols.

  • An existing account is not linked on an unverified address. When the provider states email_verified: false, linking by the asserted username is refused with login.error.oauth.unverified-email.

  • randomUrlToken, shared by the OAuth and SAML flows, is hidden from the barrel.

  • Breaking: a prepared statement keeps no per-execution state. A repository runs one statement handle from every request at once, so a count or key left on the handle belonged to whichever execution finished last; two overlapping saves could read each other's. The repository's insert key, deleteById count, query-delete counts and versioned writes read the TomStatementOutcome of their own run. A driver implements run.

Removed #

  • Breaking: TomPreparedStatement.lastGeneratedId and TomPreparedStatement.lastAffectedRows (and their MariadbPreparedStatement overrides). Read TomStatementOutcome.generatedId and TomStatementOutcome.affectedRows from run instead.

Tests #

  • test/graceful_shutdown_test.dart sends a real SIGTERM to a server process (test/fixtures/graceful_shutdown_server.dart) during a slow request: the request completes, no new request is accepted, the scheduler's file lease is released and the process exits with 0. In process it covers the drain and the refusals with the probes passed, a scheduler drained before its lease is released, a run past the grace period abandoned with its lease released, and a second call handed the first shutdown.
  • test/structured_logs_readiness_test.dart covers the request context (trace continued or started, the request id kept by authentication and named on the response, the fields of a JSON line), logFormat, and the framework readiness checks without a database — including a started server answering /ready 503 after a failed migration while /health stays 200; test/structured_logs_readiness_db_test.dart (tag db) the data source check against MariaDB or PostgreSQL.
  • test/metrics_test.dart covers the request series by route pattern, an unmatched path, an escaping error counted as 500, the scrape (not counted, token refusals, HEAD), one scrape carrying request, rate-limit, cache and job series, an expired cache read, and the OTLP exporter against an in-process receiver; test/metrics_db_test.dart (tag db) the statement and pool series on MariaDB or PostgreSQL; test/metrics_otel_test.dart (new tag otel, run by tool/run_otel_tests.sh) has promtool lint the scrape and an OpenTelemetry Collector (tool/otel/collector.yaml) receive an export.
  • test/optimistic_locking_test.dart covers which fields may be the version, the versioned UPDATE, the values a versioned save and delete bind, the refusals against a scripted data source, and the migration shapes the convergence check accepts and reports; test/optimistic_locking_db_test.dart (tag db) refuses the second of two saves of one row against MariaDB, lets exactly one of six simultaneous saves win, and turns versioning on for a table that already holds rows. test/datasources_test.dart's group MariadbPreparedStatement.run asserts the count of an execution, of its retry, and of two overlapping executions of one handle.
  • test/scim_test.dart covers the filter and path language, a conformance subset over HTTP in the request shapes Entra ID and Okta send — discovery, the bearer secret, users, groups, every PATCH form — and that each deprovisioning change refuses the session it should; test/scim_store_db_test.dart (tag db) runs the relational store and the service over it. framework_schema_test.dart converges and audits tom_scim_resource; reference_migrations_test.dart records its migration.
  • test/jwt_keys_test.dart covers key ids, RS256 and ES256 key files, rotation, what a verifier refuses, a verify-only server and the published set — verified by package:jose (a dev dependency), which shares no code with the framework. endpoints_pipeline_test.dart asserts the mounted /.well-known/jwks.json route.
  • test/saml_test.dart covers exclusive canonicalization, every refusal of the SAML response validator (signature wrapping, SHA-1, another key, another request, audience, issuer, recipient, validity window, status, DOCTYPE, encryption), the three legs of the flow, identity-provider metadata and the two endpoints against a scripted identity provider (test/fixtures/saml_test_idp.dart, keys in test/fixtures/saml/).
  • test/sso_keycloak_test.dart (tag sso) logs in over OpenID Connect and over SAML 2.0 against Keycloak 26, started by tool/run_sso_tests.sh from docker-compose.sso.yaml with the realm in test/fixtures/keycloak/tom-realm.json; it skips when no Keycloak is listening.
  • test/oauth_test.dart's group single sign-on over OpenID Connect covers the nonce, discovery, role mapping and the provisioning modes; test/oauth_state_store_db_test.dart round-trips the nonce and a verified SAML identity through tom_oauth_state.

Fixed #

  • A two-phase data source can be migrated. A data source of type mariadb_xa or postgresql_xa had no migrator, and migrateDatabases refused it with db_migration.migration.no_migrator_found_for_datasource_type; the MariaDB and PostgreSQL adaptors serve those types too.
  • A write binding no parameter no longer hangs on MariaDB. For a statement with neither parameters nor result columns — delete by label is null — the server answers PREPARE with the PREPARE_OK packet alone, and mysql_client 0.0.27 waits for an EOF that never comes. MariaDbDatasource runs a statement that binds nothing and answers no rows (one not starting with SELECT, WITH, SHOW or a bracket) over the text protocol and skips its prepare, so its errors surface when it runs; a parameterless read keeps the prepared, binary path. The protected driver hook is prepareOn(link, command, parameterCount): a driver implementing TomSqlDatasource adds the parameter count to its override.
  • Two outbox messages queued in the same microsecond are both delivered. TomMessageOutbox.send gave a message's run the clock's reading as its window instant, and TomSqlJobStore keeps one run per job, tenant and window — so a second message queued in the same microsecond replaced the first in tom_job_run. Each run now takes its instant from TomEventInstants.next.
  • CORS reaches the response, not only the preflight. Every response an endpoint returns — an error included — now carries the endpoint's Access-Control-Allow-Origin (corsAllowedOrigins, else globalSettingCorsAllowedOriginsDefault), the same value its preflight announces; before, a browser passed the preflight and was then refused every response body. New TomEndpointHandler.withAllowedOrigin.
  • The request body no longer reaches the trace log. The binder's trace line named the raw JSON body, so a login's password was written down in clear; it now names only the bound object, whose toString masks its secrets.
  • Every request gets its own TomRequestLogInfo. TomServer.start seeded the record through contextSetupMiddleware, which hands every request the same object; the first request marked it logged, and the access log fell silent for every request after it. The pipeline now seeds a new record per request.
  • Concurrent transactions on one data source no longer wait for each other. MariaDbDatasource held a single connection, so a transaction held the whole data source from its first statement to its conclusion and every other transaction — and every statement outside one — waited behind it. Each transaction now runs on a pooled connection of its own, and a statement outside a transaction borrows one for itself.
  • A lost connection inside a transaction is no longer retried elsewhere. A prepared statement whose connection is found gone is retried once on another connection only outside a transaction; inside one the transaction lived on the lost connection, so the failure is reported as error.failed-to-execute.transaction_connection_lost instead of running the statement outside the transaction it belongs to.
  • MariaDbDatasource.close no longer throws when a connection fails to close. The failure is logged and the connection dropped; idle connections are closed at once and lent ones when they are handed back.

Added #

  • A connection pool for data sources: TomConnectionPool<C> (datasources/connection_pool.dart), with TomConnectionPoolSettings (maxSize 10, minIdle 0, acquireTimeout 30 s, idleTimeout 10 min, maxLifetime 30 min, validateAfterIdle 30 s; validate()), the driver interface TomConnectionFactory<C> (open, close, isUsable, validate) and TomConnectionPoolException (clientThrow: false) with the keys error.datasource.pool.closed, error.datasource.pool.acquire_timeout and error.datasource.pool.invalid_settings. Waiters are served first come, first served, a released connection is handed straight to the longest waiter, and idle and old connections are retired when the pool is next used rather than by a timer.

  • Pool settings on MariaDbDatasource: poolMaxSize, poolMinIdle, poolAcquireTimeoutMs, poolIdleTimeoutMs, poolMaxLifetimeMs and poolValidateAfterIdleMs, bound from the configuration keys of the same names, with the poolSettings getter; openConnection() (overridable; opens one MySQLConnection), openConnectionCount and lentConnectionCount. toString reads open (N lent, M idle) while the data source is open.

  • MariaDbDatasource.withDriverConnection(body) runs body with the MySQLConnection a statement would use — the running transaction's, or one lent for the call — for work that needs the driver's own API.

  • error.initialize.invalid_integer: an integer setting other than port given as a string that is no integer, naming the setting and the value.

  • tomRequestLogInfoMiddleware(), the middleware that seeds a new TomRequestLogInfo under TomRequestLogInfo.contextName into every request's context. TomServer.start installs it outermost; a deployment that composes its own pipeline with tomLoggingMiddleware installs it the same way.

  • A send pace for delivered second-factor codes: Tom2FASendLimiter, Tom2FASend and the per-process default Tom2FAMemorySendLimiter (authentication/two_factor_send_limit.dart). TomDeliveredCode2FAService claims every challenge and enrolment code from it: the first code to an address goes out at once, each later one in a run waits twice as long (sendInterval, 30 s, doubling up to maxSendInterval, 30 days), and a correct code ends the run. A send too soon fails with login.error.2fa.send-limit (TomLoginFailure.rateLimited, retryAfterSeconds).

  • Passkeys: TomWebAuthn2FAService. The fourth shipped second factor, registered as @Tom2FAAdaptor('webauthn') and offered once globalSettingRelyingPartyId is set. It issues WebAuthn creation and request options (the kernel's W3C JSON wire types), verifies registrations and assertions with TomWebAuthnVerifier — client data type, challenge and origin, RP id hash, user-present and user-verified flags, an ES256 or RS256 signature, a rising signature counter, the user handle — and keeps one enrolment per credential, writing the counter back after every login. Attestation is requested as none and not verified. New key login.error.2fa.webauthn-registration-invalid.

  • TomWebAuthnSoftwareAuthenticator, a passkey authenticator in software (real P-256 keys, CBOR and signatures in memory) for tests and bring-up.

  • Cross-node cache invalidation (cache/cache_invalidation.dart): TomCacheInvalidator — install(channel), revokePrincipal(username), invalidateCacheEntry(cacheName, key), invalidateCache(cacheName), raise, apply, reset, channel, nodeId — applies an invalidation on this node and publishes it through the installed TomInvalidationChannel (stalenessBound, start, publish, stop); a publish failure propagates after the local apply. TomCacheInvalidation (principal, cacheEntry, cache, fromParts factories), TomInvalidationKind, and TomCacheInvalidateMessage, which carries a worker's call to the main isolate. TomLocalInvalidationChannel is the default: no other node, a bound of zero.

  • TomSqlInvalidationChannel (cache/sql_invalidation_channel.dart), a channel over a table every node polls: publish inserts a row, each node reads the rows above the last id it saw every pollInterval (2 s; also the staleness bound) and skips its own, start first applies the principal revocations raised within retention, and rows older than retention are pruned. TomSqlInvalidationChannel.named(datasourceName) resolves a registered data source and throws cache.invalidation.unknown_datasource otherwise. TomCacheInvalidationRow is the reflected row.

  • The invalidation log table, tom_cache_invalidation: invalidationTable, invalidationTableDdl and defaultInvalidationTable (cache/invalidation_sql.dart), listed by frameworkOwnedTables (new parameter invalidation), with the reference migration example/cache/migrations/main/tomcore/[1]-create_tom_cache_invalidation.sql.

  • Principal revocation in TomAuthorizationCache: revokeLocally(username, at), revokedAt(username), isRevoked(token) and revocationRetention (24 h). getPrincipal refuses a token issued at or before its user's last revocation — compared on iat, else nbf, in whole seconds — with authorization.cache.revoked_principal, whether or not a principal is cached for it; the server's authentication middleware serves such a request unauthenticated, as it does an expired entry.

  • TomCache.clear(), which empties the map and the most-recently-used chain together; the clearance timer and a cache invalidation both use it.

Changed #

  • TomAuthorizationCache.dispose forgets every revocation as well as dropping the cached principals, and the eviction sweep forgets revocations older than revocationRetention.
  • Breaking: every participant hook is handed the transaction. TomTransactionParticipant.prepare, commit, rollback and endTransaction take (TomTransaction transaction), like startTransaction, and the coordinator passes the transaction it is concluding. A participant serving several transactions at once — a pooled data source — needs it to know which one a call concludes.
  • Breaking: MariadbPreparedStatement holds no PreparedStmt. The constructor is MariadbPreparedStatement(parameterList, datasource, resultType, command, {groupedProjection}) and the preparedStmt field is gone. Each pooled connection prepares the statement on first use and keeps it; deallocate() forgets it on every connection, each deallocating it when it is next handed back.
  • Breaking: MariaDbDatasource.normalizePort is replaced by normalizeIntegerSettings, which folds every setting in integerSettings — port, timeOut and the six pool settings — from a string to an int.
  • Breaking: MariaDbDatasource.connection is gone. The data source holds a pool: connect() opens it and its first connection, connected answers whether it is open with at least one established connection, and isOperational() whether it can reach its database. A transaction gets its own connection, pinned from its first statement to endTransaction; a second start of the same transaction is refused with error.transaction.already_active, another transaction is not. XA transactions get per-transaction branch ids, so concurrent ones are separate branches.

Tests #

  • test/connection_pool_test.dart covers TomConnectionPool over a fake factory and an injectable clock — lending, waiting in order, the typed timeout, retirement and closing. datasources_test.dart runs concurrent transactions over stand-in connections, and datasources_db_test.dart commits one transaction while another sleeps on a live server.
  • Every error key the package raises is named by a test. New *_error_keys_test.dart files for object persistence, data sources, database migration, audit, endpoints, export and messaging pin over 150 keys, each asserting the exact key; none needs a database.
  • test/ai_citation_test.dart fails the suite when an artefact of the package cites a document in the AI-state mount. It runs tom_ai/core/tool/check_doc_ai_citations.py over the package through tom_ai/core/tool/package_gate.dart; the rule and its one exemption (the runtime _ai/trail/ directory) are stated in the checker alone.
  • test/formatting_test.dart delegates to the shared guard. The walk, the generated-file exclusion, the coverage floor and the choice of formatter moved to tom_ai/core/tool/package_format_guard.dart, which every package under tom_ai/core shares; the file keeps only this package's floor, landmarks and generated-output flag.

Added #

  • Push notifications: TomPushTransport with FCM, APNs and a described protocol. TomMessageChannel.push now has a transport. A TomPushProtocol maps a notification to a service's request and its answer back: TomFcmPushProtocol (Firebase Cloud Messaging, HTTP v1), TomApnsPushProtocol (Apple's push service, over HTTP/2) and TomFieldMappedPushProtocol for any other JSON push API, described by field paths as TomFieldMappedSmsProtocol describes an SMS gateway. Credentials are minted and refreshed by TomFcmServiceAccountCredential (an RS256 service-account assertion exchanged for an access token) and TomApnsProviderToken (an ES256 provider token); a credential the service rejects is refreshed and the token tried once more. The wire is a TomPushExchange, HTTP/2 over the new http2 dependency for APNs. Both vendor protocols are tested against scripted answers in the vendors' documented formats, not against the live services.

  • TomMessage.push, TomPushRequest and TomPushService. The factory puts the title in subject and the data map in metadata; the service is the third TomTemplatedMessageService, with its own required title and body sub-keys.

  • A server warns about its configuration at startup. One warning lists every key in the configuration resources that names no field of the configuration class — where a misspelled setting would otherwise be dropped in silence — and one lists every @TomRecommendedField setting no source supplied. Only the resources are checked for surplus keys; the environment, .env and command line keep their tolerance. settingWarnings is the check on its own.

  • TomPageRequest.stableUnder(key) returns the request with [key] appended as its last ascending sort term when it cuts a window and does not already sort on the key. It is the rule the repository applies to every paged read, moved onto the page request so it can be tested without a database.

  • A server that boots with resource-key grants unenforced says so. tom.protection.resourceKeys stays off by default — enforcing it refuses every endpoint that declares no access in an application without grants — so start() now warns through TomServer.warnOnUnenforcedResourceKeys when it is off outside a development or test environment, naming the setting. Nothing refuses to start.

  • isDevelopmentOrTestRuntime(), the check JWT signing already made privately, is now a public function in configuration/runtime_environment.dart shared by both.

Changed #

  • A health analysis is never started twice. A timed-out analysis cannot be cancelled, so TomHealthServer.verdictOf keeps one analysis in flight per analyzer: a probe arriving while it is pending waits on it under its own deadline. An analyzer that hangs on every probe now leaves one pending future behind instead of one per probe.

  • A typed TomConfig reads the text of its type. A YAML ${VAR:default} expansion is written back into the tree as text, so port: ${DB_PORT:3306} threw for TomIntConfig while port: 3306 read. After a failed cast, a String value is now read by the TomArgs.convertToObjectMap table the environment, .env and command-line sources already use: '3306' resolves for an int, '2.5' for a double, 'true' for a bool. The map keeps the text, and a TomStringConfig reads it unchanged; text of the wrong type is still configuration.tom_config.property_type_mismatch.

  • TomArgs trims a key and a value as TomEnvFile does, so k = v is k → v from either source, quoted whitespace is kept and a whitespace-only key is skipped.

  • TomDbColumn takes no type parameters, and one resolver answers what a column is stored as. An annotation reaches the persistence layer through reflection, which reconstructs it without type arguments, so TomDbColumn<DART_TYPE, COL_TYPE>'s parameters were never seen at run time — declaredDartType() and declaredColumnType() always fell back to the fields. Both methods and both parameters are gone; columnType: and dartType: are the declarations, and the converters are typed Object? Function(TomDbType) / TomDbType Function(dynamic). Annotations whose box said more than the field type (an enum or Duration stored as text) now say columnType: TomDbString. TomAnnotationAnalyzer.familyOf is the single resolver — one table for boxes, Dart types and observable Tom… wrappers, explicit type first, an explicit type with no family unknown rather than the field's type — and the query compiler, the schema-convergence check, the export projection and the MariaDB datasource read it (new TomAnnotationAnalyzer.dartTypeOf). columnType: bool on an int field was boolean to the compiler and integer to the convergence check; it is boolean to both.

  • The API summaries moved from doc/api_reference/ to doc/api/, where every other package and the workspace guideline already kept them. The folder held only the markdown summaries and their index; the name api_reference now means only the built PDF, doc/{package}_api_reference.pdf.

  • TomLoginFailure moved to tom_core_kernel, beside TomAuthenticationResult. TomAuthenticationServer now sets the result's loginFailureReason wherever it sets error: from the caught TomLoginException's reason, secondFactorInvalid for a refused code it offers again, and providerError for a masked unexpected error.

  • TomErrorHandler.handleUnknownResultTypeError is called, and spelt right. Renamed from handleUnknowResultTypeError. The pipeline now calls it when rendering a result is refused with reflector.type.not_annotated (an unannotated result type, or a type nested in one); every other rendering failure still goes to handleConversionError. The log line names the hook, and a failing hook still raises conversion_error_handler_failed.

  • TomDataSource.isOperational is abstract. It defaulted to true, so a driver that never implemented it reported usable forever through TomDataSourceInfo.isOperational. Every instance member of the data-source contract is now abstract, as TomPreparedStatement's already were, and a source-reading test holds both contracts to it.

  • Unreachable raises removed, with their documented entries: annotation_analyzer.analyse_persistent_type.columns_not_found, query_sentence_compiler.analyse_order_by.failed, query_sentence_compiler.build_expression.negation_of_non_bool, query_sentence_compiler.create_is_in.failed (now a StateError naming the broken invariant), endpoint_pipeline.get_class_method_metadata.multiple_api_endpoint_implementation_tags, error.failed-to-connect.connect_failed, error.failed-to-connect.remained_unconnected and tom_datasource_info.initialize.no_datasource_obtained_for_type. TomDataSource.createDatasourceOf returns a non-nullable TomDataSource.

  • TomEndpointRouting.scanClasses takes an optional class set, defaulting to every annotated class, so the partition can be checked against classes no reflection root could carry.

  • Registrations use TomBean.withType<X>(bean), following the kernel's move of the key into a type argument. The endpoint pipeline's default component, whose type is read off a mirror, registers through TomBean.withRuntimeType. Reflection artifacts regenerated.

  • TomBaseServerConfiguration.listenToAnyIP is renamed listenToAnyIp. It was the one field whose name did not round-trip through the SCREAMING_SNAKE_CASE environment convention, so LISTEN_TO_ANY_IP bound nothing. A configuration file that sets the key listenToAnyIP verbatim must write listenToAnyIp; the old key is now unknown and is traced, not applied.

  • The mutation catalogue covers all twenty modules. tool/mutations.yaml now records configuration, authentication, object_persistence and the rest of datasources and endpoints. Every entry is killed by the tests it names.

  • tool/mutation_sweep.py reports a mutant that does not compile as INVALID, not as killed. Such a mutant fails every test that imports it and so proves nothing; it counts toward the exit code like a survivor, since its entry has to be rewritten before its behaviour counts as pinned. A run that hangs is stopped after ten minutes, with the test VMs it spawned, and counted as killed.

Fixed #

  • Reading TomDataSourceInfo.datasource before initialize raises its key. The field was late as well as nullable, so the read threw a LateInitializationError before the null check and isOperational threw too; it now raises tom_datasource_info.get_datasource.datasource_is_null and isOperational answers false.

  • A repository read an instant up to 0.9 s late whenever its fraction was under a tenth of a second. Rows from a prepared statement — every repository read, and save's re-read — arrive over MySQL's binary protocol, where mysql_client renders a DATETIME's microseconds as a bare integer: 91 371 µs came back as .91371, read as 0.91371 s. One stored instant in ten was affected, silently; it surfaced as an intermittent failure of the preferences "a replace moves the timestamp forward" test. The data source now marks binary-protocol rows and restores the six-digit fraction before the temporal readers parse them; text-protocol rows, whose fraction the server formats at the column's own precision, are left alone.

  • A @TomTransactional endpoint's transaction now holds its writes. Nothing in the framework ever called registerParticipant, so the transaction the pipeline opened, committed and rolled back was empty: every statement autocommitted, and an endpoint that threw half way had its rollback called with nothing to undo. MariaDbDatasource now enlists itself at the point of use — the first statement it runs inside a running transaction joins it — and a data source that cannot start its transaction fails that statement. Because a transaction belongs to the one connection, the transaction holds the data source until the coordinator ends it: a statement from elsewhere waits for the release instead of running inside it, so two concurrent transactions on one data source take turns rather than the second being refused. The transaction's own verbs bypass that admission. test/transactional_endpoint_db_test.dart drives it through the router, a repository and MariaDB.

  • A commit that does not commit fails the request. The pipeline ignored commitTransaction()'s result, so a vetoed or partial commit answered 200 with the method's result. It now raises TomTransactionException (endpoint_pipeline.handle_method_call.commit_failed), and the error path rolls back only a transaction that is still running.

  • A stored date or time the driver cannot read is refused by column. A DATE column holding a timestamp, a TIME holding an interval or an unreadable DATETIME escaped row mapping as a bare FormatException — no key, no clientThrow, no column. Every row shape now reads them through _readTemporal, which raises error.result_processing.not_a_date, .not_a_time or .not_an_instant naming the column and the value.

  • TomDeliveryReportEndpoint.install raises a TomMessagingException for an empty secret, not the kernel's TomException, so it is in the server family like every other server raise.

  • A raise site throws the family, or says why not. doc/little_things.md states the rule and its three exempt categories (programmer error, parse contract, kernel protocol); the thirteen exempt sites carry a // throws-outside-family: marker, and server_exception_family_test.dart enforces it package-wide. little_things.md also counted the direct TomServerException raises as five; there are six.

  • MariaDbXADatasource desynchronised its own connection on every prepare. prepare sent XA END and XA PREPARE as one command, and mysql_client completes a call on the first reply it reads without checking whether more follow — so the PREPARE's reply stayed on the wire, and every later call on that connection was answered by its predecessor's reply. XA COMMIT returned before the server had committed (a reader on another connection saw the commit only when it lost the race, which is why the DB-backed run failed about one time in four), a failed XA PREPARE was never reported, and a query on the XA connection came back empty. prepare and rollback now issue one statement per call; rollback tracks whether the branch has been ended, so a prepare refused after its XA END rolls back without a second, refused XA END.

  • executeQuery and executeCommand refuse a multi-statement command with error.datasource.multiple_statements, before the connection is touched, so the shape above cannot recur elsewhere. The check is quote- and comment-aware (TomSqlLexer.splitStatements).

  • The XA branch id is bounded to the 64-byte gtrid limit. It was built from the database name, host, port and a timestamp, and fitted only for short coordinates — the test database on 127.0.0.1 came to 62 bytes — so a real hostname or a longer database name made every XA transaction fail to start. It is now <database>@<host>:<port>_<process>_<time>_<counter>, with the unique tail kept whole and the readable head truncated, and a random per-process tag so two processes on one database cannot mint the same id. MariaDbXADatasource.maxBranchIdBytes names the limit.

  • The DB-backed suites no longer share one schema. The test fixture gives every suite file a database of its own, created when it registers its data source and dropped when the group ends, so no suite — and no second run against the same server — can truncate or drop another's tables.

  • dart_test.yaml states the real reason the suite runs serially. It said the DB suites shared one schema; the reason that remains is that configuration_test and runtime_environment_test change the process-wide working directory, which every concurrently running suite file would inherit.

  • A broken markdown reference link in doc/authentication.md. The page links type names to in-document anchors throughout; TomAuthenticationService carried the brackets with no target and has no anchor to point at, so the brackets are gone.

Earlier entries #

The full history is in the repository's CHANGELOG.md; this file holds the newest entries that fit pub.dev's size limit.