Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
050502c
style: clang-format LoopLibCore/sources/pdfschemaversion.h
mberrys Sep 14, 2026
8eea83c
feat(core): read current schema versions only from the compatibility …
mberrys Sep 14, 2026
73f50d5
feat(core): fail closed when a document identifies no schema kind (#266)
mberrys Sep 14, 2026
e1740e7
test(core): golden round-trip coverage for every JSON schema kind (#266)
mberrys Sep 14, 2026
8af5254
fix(core): report the document's own version when a newer minor passe…
mberrys Sep 14, 2026
f37314f
feat(core): expose one schema compatibility diagnostic for every surf…
mberrys Sep 14, 2026
a2c3dea
feat(cli): add pdftool schema to report the Core compatibility diagno…
mberrys Sep 14, 2026
9e600de
style: clang-format PdfTool/pdftoolcapabilities.cpp
mberrys Sep 14, 2026
0f3364e
refactor(cli): derive discovered schema versions from the compatibili…
mberrys Sep 14, 2026
2d26657
feat(core): record a SchemaMigrated event when a history database is …
mberrys Sep 14, 2026
d1b8aeb
feat(core): fail closed on unsupported decision-file schema with the …
mberrys Sep 14, 2026
25cbca2
docs: record the schema compatibility contract and add the changelog …
mberrys Sep 14, 2026
449e0d4
fix(cli): reject a non-JSON console format for pdftool schema (#266)
mberrys Sep 14, 2026
7503f98
docs: scope the migration-provenance rule to the paths that implement…
mberrys Sep 14, 2026
51f220d
fix(cli): report document_ready only when the artifact was prepared (…
mberrys Sep 14, 2026
38c6f7b
fix(core): fail closed when a matrix entry declares no current versio…
mberrys Sep 14, 2026
5ce84e9
Merge remote-tracking branch 'origin/dev' into cdx/pr-583-repair
mberrys Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 88 additions & 7 deletions LoopLibCore/sources/pdfoperationhistorystore.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
#include "pdfartifactstore.h"
#include "pdfschemaversion.h"

#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
Expand Down Expand Up @@ -130,6 +132,10 @@ class PDFOperationHistoryStore::Impl
public:
QString connection;
QSqlDatabase database;
/// open() holds a transaction while it upgrades the schema. Appends made
/// inside that window must join it: SQLite rejects a nested BEGIN, and a
/// failure has to roll back together with the schema DDL.
bool joinOpenTransaction = false;
};

PDFOperationHistoryStore::PDFOperationHistoryStore(QString databasePath,
Expand All @@ -145,6 +151,54 @@ PDFOperationHistoryStore::~PDFOperationHistoryStore()
close();
}

bool PDFOperationHistoryStore::appendSchemaUpgradeProvenance(bool upgraded,
int previousSchemaVersion,
const QString& databaseDigest,
qint64 databaseSize,
QString* error)
{
if (!upgraded)
{
return true;
}

// The provenance chain is append-only and must never claim an upgrade that
// was not recorded, so a missing identity fails the open instead of the write.
if (databaseDigest.isEmpty() || databaseSize <= 0)
{
*error = QStringLiteral("Operation history database upgrade has no recordable artifact identity.");
return false;
}

PDFArtifactIdentity database;
database.sha256 = databaseDigest;
database.size = databaseSize;
database.mediaType = QStringLiteral("application/vnd.sqlite3");
database.logicalName = QFileInfo(m_databasePath).fileName();
if (const PDFOperationResult registered = registerArtifact(database); !registered)
{
*error = registered.getErrorMessage();
return false;
}

// open() already holds the upgrade transaction, so this append has to join
// it: SQLite rejects a nested BEGIN, and open()'s ROLLBACK then undoes the
// provenance row together with the schema DDL.
m_impl->joinOpenTransaction = true;
const PDFOperationResult migrated = appendSchemaMigratedEvent(database,
PDFSchemaKind::HistoryDb,
PDFSchemaVersion{ static_cast<quint16>(previousSchemaVersion), 0 },
PDFSchemaVersion{ CurrentSchemaVersion, 0 },
databaseDigest);
m_impl->joinOpenTransaction = false;
if (!migrated)
{
*error = migrated.getErrorMessage();
return false;
}
return true;
}

PDFOperationResult PDFOperationHistoryStore::open(QString* errorMessage)
{
if (isOpen())
Expand All @@ -171,6 +225,22 @@ PDFOperationResult PDFOperationHistoryStore::open(QString* errorMessage)
}
}

// The identity of the object being migrated is the database file as it is
// before this open: SQLite rewrites the file (WAL header, schema DDL), so
// hashing later would record bytes that never existed as an input.
QString preMigrationDigest;
qint64 preMigrationSize = 0;
if (m_databasePath != QStringLiteral(":memory:"))
{
QFile databaseFile(m_databasePath);
if (databaseFile.exists() && databaseFile.open(QIODevice::ReadOnly))
{
const QByteArray bytes = databaseFile.readAll();
preMigrationDigest = QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex());
preMigrationSize = bytes.size();
}
}

m_impl->connection = connectionName();
m_impl->database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_impl->connection);
m_impl->database.setDatabaseName(m_databasePath);
Expand Down Expand Up @@ -219,6 +289,8 @@ PDFOperationResult PDFOperationHistoryStore::open(QString* errorMessage)
return PDFOperationResult(error);
}

const bool upgradesExistingDatabase = schemaVersion > 0 && schemaVersion < CurrentSchemaVersion;

if (!exec(m_impl->database, QStringLiteral("BEGIN IMMEDIATE"), &error) ||
!exec(m_impl->database, QStringLiteral("CREATE TABLE IF NOT EXISTS artifacts (sha256 TEXT PRIMARY KEY, size_bytes INTEGER NOT NULL, media_type TEXT NOT NULL, logical_name TEXT, storage_token TEXT, created_utc TEXT NOT NULL, is_original_input INTEGER NOT NULL DEFAULT 0, artifact_evicted INTEGER NOT NULL DEFAULT 0)"), &error) ||
!exec(m_impl->database, QStringLiteral("CREATE TABLE IF NOT EXISTS executions (execution_id TEXT PRIMARY KEY, parent_execution_id TEXT, operation_id TEXT NOT NULL, operation_version INTEGER NOT NULL, source_sha256 TEXT NOT NULL, source_revision INTEGER NOT NULL, parameters_json TEXT NOT NULL, started_utc TEXT NOT NULL, FOREIGN KEY(source_sha256) REFERENCES artifacts(sha256), FOREIGN KEY(parent_execution_id) REFERENCES executions(execution_id))"), &error) ||
Expand All @@ -241,6 +313,7 @@ PDFOperationResult PDFOperationHistoryStore::open(QString* errorMessage)
!exec(m_impl->database, QStringLiteral("CREATE INDEX IF NOT EXISTS idx_execution_operation ON executions(operation_id, started_utc)"), &error) ||
!exec(m_impl->database, QStringLiteral("CREATE INDEX IF NOT EXISTS idx_rollback_digest ON rollback_points(document_revision_digest)"), &error) ||
!exec(m_impl->database, QStringLiteral("INSERT OR REPLACE INTO schema_meta(key, value) VALUES('schema_version', '%1')").arg(CurrentSchemaVersion), &error) ||
!appendSchemaUpgradeProvenance(upgradesExistingDatabase, schemaVersion, preMigrationDigest, preMigrationSize, &error) ||
!exec(m_impl->database, QStringLiteral("COMMIT"), &error))
{
exec(m_impl->database, QStringLiteral("ROLLBACK"), nullptr);
Expand Down Expand Up @@ -397,12 +470,20 @@ PDFOperationResult PDFOperationHistoryStore::appendEvent(PDFOperationHistoryEven
}

QString error;
if (!exec(m_impl->database, QStringLiteral("BEGIN IMMEDIATE"), &error))
const bool ownsTransaction = !m_impl->joinOpenTransaction;
if (ownsTransaction && !exec(m_impl->database, QStringLiteral("BEGIN IMMEDIATE"), &error))
return PDFOperationResult(error);
// Only the owner of the transaction may end it: a joined append leaves the
// rollback to open(), which rolls the whole upgrade back.
const auto rollback = [this, ownsTransaction]()
{
if (ownsTransaction)
exec(m_impl->database, QStringLiteral("ROLLBACK"), nullptr);
};
QSqlQuery previousQuery(m_impl->database);
if (!previousQuery.exec(QStringLiteral("SELECT event_hash FROM history_events ORDER BY sequence DESC LIMIT 1")))
{
exec(m_impl->database, QStringLiteral("ROLLBACK"), nullptr);
rollback();
return PDFOperationResult(queryError(previousQuery));
}
const QByteArray previousHash = previousQuery.next() ? decodeHash(previousQuery.value(0).toString()) : QByteArray();
Expand Down Expand Up @@ -451,7 +532,7 @@ PDFOperationResult PDFOperationHistoryStore::appendEvent(PDFOperationHistoryEven
query.addBindValue(dateTimeString(event.createdUtc));
if (!query.exec())
{
exec(m_impl->database, QStringLiteral("ROLLBACK"), nullptr);
rollback();
return PDFOperationResult(queryError(query));
}
if (event.status == PDFOperationHistoryStatus::Accepted || event.status == PDFOperationHistoryStatus::RolledBack)
Expand All @@ -461,7 +542,7 @@ PDFOperationResult PDFOperationHistoryStore::appendEvent(PDFOperationHistoryEven
executionQuery.addBindValue(event.executionId.toString(QUuid::WithoutBraces));
if (!executionQuery.exec() || !executionQuery.next())
{
exec(m_impl->database, QStringLiteral("ROLLBACK"), nullptr);
rollback();
return PDFOperationResult(queryError(executionQuery));
}

Expand All @@ -476,15 +557,15 @@ PDFOperationResult PDFOperationHistoryStore::appendEvent(PDFOperationHistoryEven
point.addBindValue(event.output->sha256.toLower());
if (!point.exec() || point.numRowsAffected() != 1)
{
exec(m_impl->database, QStringLiteral("ROLLBACK"), nullptr);
rollback();
return PDFOperationResult(point.numRowsAffected() == 0
? QStringLiteral("History output artifact is not registered.")
: queryError(point));
}
}
if (!exec(m_impl->database, QStringLiteral("COMMIT"), &error))
if (ownsTransaction && !exec(m_impl->database, QStringLiteral("COMMIT"), &error))
{
exec(m_impl->database, QStringLiteral("ROLLBACK"), nullptr);
rollback();
return PDFOperationResult(error);
}
event.sequence = query.lastInsertId().toLongLong();
Expand Down
9 changes: 9 additions & 0 deletions LoopLibCore/sources/pdfoperationhistorystore.h
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ class LOOPLIBCORESHARED_EXPORT PDFOperationHistoryStore
std::unique_ptr<Impl> m_impl;
QString m_databasePath;
PDFOperationHistoryStoreOptions m_options;

/// Records the schema upgrade that this open performed: the database file as
/// it was before SQLite rewrote it, from the previous version to the current
/// one. Joins the caller's transaction; open() rolls it back on failure.
bool appendSchemaUpgradeProvenance(bool upgraded,
int previousSchemaVersion,
const QString& databaseDigest,
qint64 databaseSize,
QString* error);
};

} // namespace pdf
Expand Down
120 changes: 97 additions & 23 deletions LoopLibCore/sources/pdfschemaversion.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,31 @@ QJsonObject loadCompatibilityMatrix()
PDFSchemaVersion parseCurrentVersion(const QJsonObject& entry)
{
bool ok = false;
PDFSchemaVersion version = PDFSchemaVersion::fromJsonValue(entry.value(QStringLiteral("current")), &ok);
const PDFSchemaVersion version = PDFSchemaVersion::fromJsonValue(entry.value(QStringLiteral("current")), &ok);
if (!ok)
{
const int major = entry.value(QStringLiteral("supported_majors")).toArray().last().toInt(1);
version.major = static_cast<quint16>(major);
version.minor = 0;
// No readable `current` means no known current version. Deriving a major
// from `supported_majors` would invent the version the matrix declined
// to declare and relabel documents as current on its strength, so fail
// closed with the same invalid version an absent entry yields.
return {};
}
return version;
}

QString supportedMajorsText(const QJsonObject& matrix, PDFSchemaKind kind)
{
const QJsonObject kinds = matrix.value(QStringLiteral("kinds")).toObject();
const QJsonArray supported =
kinds.value(pdfSchemaKindToString(kind)).toObject().value(QStringLiteral("supported_majors")).toArray();
QStringList majors;
for (const QJsonValue& major : supported)
{
majors.append(QString::number(major.toInt()));
}
return majors.join(QStringLiteral(", "));
}

QJsonObject migratePreflightReportV2ToV3(QJsonObject document)
{
if (!document.contains(QStringLiteral("schema_kind")))
Expand Down Expand Up @@ -302,10 +317,14 @@ PDFSchemaCompatibility checkSchemaCompatibilityWithMatrix(PDFSchemaKind kind,
PDFSchemaVersion version,
const QJsonObject& matrix)
{
if (kind == PDFSchemaKind::Unknown || !version.isValid())
if (kind == PDFSchemaKind::Unknown)
{
return PDFSchemaCompatibility::UnknownKind;
}
if (!version.isValid())
{
return PDFSchemaCompatibility::Invalid;
}

const QJsonObject kinds = matrix.value(QStringLiteral("kinds")).toObject();
QJsonObject entry = kinds.value(pdfSchemaKindToString(kind)).toObject();
Expand Down Expand Up @@ -336,29 +355,78 @@ PDFSchemaCompatibility checkSchemaCompatibility(PDFSchemaKind kind, PDFSchemaVer
return checkSchemaCompatibilityWithMatrix(kind, version, loadCompatibilityMatrix());
}

PDFSchemaVersion currentSchemaVersion(PDFSchemaKind kind)
QString pdfSchemaCompatibilityToString(PDFSchemaCompatibility compatibility)
{
const QJsonObject matrix = loadCompatibilityMatrix();
const QJsonObject kinds = matrix.value(QStringLiteral("kinds")).toObject();
const QJsonObject entry = kinds.value(pdfSchemaKindToString(kind)).toObject();
if (!entry.isEmpty())
switch (compatibility)
{
return parseCurrentVersion(entry);
case PDFSchemaCompatibility::Compatible:
return QStringLiteral("compatible");
case PDFSchemaCompatibility::UnsupportedMajor:
return QStringLiteral("unsupported-major");
case PDFSchemaCompatibility::UnknownKind:
return QStringLiteral("unknown-kind");
case PDFSchemaCompatibility::Invalid:
break;
}
return QStringLiteral("invalid");
}

switch (kind)
PDFSchemaCompatibilityDiagnostic schemaCompatibilityDiagnostic(PDFSchemaKind kind, PDFSchemaVersion version)
{
PDFSchemaCompatibilityDiagnostic diagnostic;
diagnostic.kind = kind;
diagnostic.version = version;
diagnostic.compatibility = checkSchemaCompatibility(kind, version);

switch (diagnostic.compatibility)
{
case PDFSchemaKind::PreflightReport:
return { 3, 0 };
case PDFSchemaKind::HistoryDb:
case PDFSchemaKind::PageMasterManifest:
return { 3, 0 };
default:
return { 1, 0 };
case PDFSchemaKind::Unknown:
case PDFSchemaCompatibility::Compatible:
diagnostic.code = QStringLiteral("schema.compatible");
diagnostic.message = QStringLiteral("Schema kind '%1' version %2 is supported.")
.arg(pdfSchemaKindToString(kind), version.toString());
break;
case PDFSchemaCompatibility::UnsupportedMajor:
diagnostic.code = QStringLiteral("schema.unsupported-major");
// The message names the unsupported major; the full MAJOR.MINOR is
// carried by `diagnostic.version` and reported as `schema_version`.
diagnostic.message =
QStringLiteral("Unsupported schema major: kind '%1' version %2; this build supports major(s) %3.")
.arg(pdfSchemaKindToString(kind), QString::number(version.major),
supportedMajorsText(loadCompatibilityMatrix(), kind));
break;
case PDFSchemaCompatibility::UnknownKind:
diagnostic.code = QStringLiteral("schema.unknown-kind");
diagnostic.message =
QStringLiteral("Unknown schema kind: the document declares no recognised 'schema_kind'.");
break;
case PDFSchemaCompatibility::Invalid:
diagnostic.code = QStringLiteral("schema.invalid-version");
diagnostic.message = QStringLiteral(
"Invalid schema version: 'schema_version' must be an integer major or a \"MAJOR.MINOR\" string.");
break;
}
return diagnostic;
}

QJsonObject schemaCompatibilityMatrix()
{
return loadCompatibilityMatrix();
}

PDFSchemaVersion currentSchemaVersion(PDFSchemaKind kind)
{
return currentSchemaVersionWithMatrix(kind, loadCompatibilityMatrix());
}

PDFSchemaVersion currentSchemaVersionWithMatrix(PDFSchemaKind kind, const QJsonObject& matrix)
{
const QJsonObject kinds = matrix.value(QStringLiteral("kinds")).toObject();
const QJsonObject entry = kinds.value(pdfSchemaKindToString(kind)).toObject();
if (entry.isEmpty())
{
return {};
}
return {};
return parseCurrentVersion(entry);
}

QJsonObject migrateSchemaDocument(PDFSchemaKind kind, PDFSchemaVersion from, QJsonObject document)
Expand Down Expand Up @@ -393,7 +461,10 @@ PDFSchemaMigrationResult prepareSchemaDocument(PDFSchemaKind kind, QJsonObject d
}
if (envelope.kind == PDFSchemaKind::Unknown)
{
envelope.kind = PDFSchemaKind::PreflightReport;
// Neither the document nor the caller identifies the contract. Guessing
// a kind would interpret unknown bytes as a preflight report.
result.document = {};
return result;
}

if (!envelope.version.isValid())
Expand All @@ -414,7 +485,10 @@ PDFSchemaMigrationResult prepareSchemaDocument(PDFSchemaKind kind, QJsonObject d

const PDFSchemaVersion target = currentSchemaVersion(envelope.kind);
result.fromVersion = envelope.version;
result.toVersion = target;
// toVersion is the version the document is at when this returns; only a
// migration moves it. Reporting the matrix target here would tell a caller
// it holds current bytes while it holds a newer minor payload.
result.toVersion = envelope.version;

while (envelope.version.major < target.major)
{
Expand Down
Loading
Loading