From 7cf8314fba7d5921a5244c315b51df9c206f40b4 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:24:22 +0200 Subject: [PATCH 01/85] Phase 64: add managed timer fulfillment contracts --- Makefile | 4 + .../NativeTimerCreateReadbackEvidence.h | 52 ++++++ .../NativeTimerCreateReadbackExpectation.h | 40 ++++ .../timers/include/NativeTimerSpecification.h | 34 ++++ .../src/NativeTimerCreateReadbackEvidence.cpp | 63 +++++++ .../NativeTimerCreateReadbackExpectation.cpp | 67 +++++++ core/timers/src/NativeTimerSpecification.cpp | 134 ++++++++++++++ ..._native_timer_create_readback_evidence.cpp | 92 ++++++++++ ...tive_timer_create_readback_expectation.cpp | 79 ++++++++ .../tests/test_native_timer_specification.cpp | 76 ++++++++ core/vdr/include/VdrTimerManagedCorrelation.h | 50 +++++ core/vdr/src/VdrTimerManagedCorrelation.cpp | 172 ++++++++++++++++++ .../test_vdr_timer_managed_correlation.cpp | 57 ++++++ ...ve-timer-create-readback-evidence-tests.mk | 13 ++ ...timer-create-readback-expectation-tests.mk | 13 ++ ...hase64-native-timer-specification-tests.mk | 12 ++ ...e64-vdr-timer-managed-correlation-tests.mk | 11 ++ 17 files changed, 969 insertions(+) create mode 100644 core/timers/include/NativeTimerCreateReadbackEvidence.h create mode 100644 core/timers/include/NativeTimerCreateReadbackExpectation.h create mode 100644 core/timers/include/NativeTimerSpecification.h create mode 100644 core/timers/src/NativeTimerCreateReadbackEvidence.cpp create mode 100644 core/timers/src/NativeTimerCreateReadbackExpectation.cpp create mode 100644 core/timers/src/NativeTimerSpecification.cpp create mode 100644 core/timers/tests/test_native_timer_create_readback_evidence.cpp create mode 100644 core/timers/tests/test_native_timer_create_readback_expectation.cpp create mode 100644 core/timers/tests/test_native_timer_specification.cpp create mode 100644 core/vdr/include/VdrTimerManagedCorrelation.h create mode 100644 core/vdr/src/VdrTimerManagedCorrelation.cpp create mode 100644 core/vdr/tests/test_vdr_timer_managed_correlation.cpp create mode 100644 mk/phase64-native-timer-create-readback-evidence-tests.mk create mode 100644 mk/phase64-native-timer-create-readback-expectation-tests.mk create mode 100644 mk/phase64-native-timer-specification-tests.mk create mode 100644 mk/phase64-vdr-timer-managed-correlation-tests.mk diff --git a/Makefile b/Makefile index 709821897..566f106e4 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,11 @@ include mk/phase63-runtime-acceptance.mk include mk/agent-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk +include mk/phase64-native-timer-specification-tests.mk +include mk/phase64-vdr-timer-managed-correlation-tests.mk include mk/phase64-native-timer-readback-expectation-tests.mk +include mk/phase64-native-timer-create-readback-expectation-tests.mk +include mk/phase64-native-timer-create-readback-evidence-tests.mk include mk/phase64-native-timer-present-readback-verification-tests.mk include mk/phase64-native-timer-inventory-evidence-tests.mk include mk/phase64-restfulapi-native-timer-inventory-reader-tests.mk diff --git a/core/timers/include/NativeTimerCreateReadbackEvidence.h b/core/timers/include/NativeTimerCreateReadbackEvidence.h new file mode 100644 index 000000000..611b6e2c6 --- /dev/null +++ b/core/timers/include/NativeTimerCreateReadbackEvidence.h @@ -0,0 +1,52 @@ +#pragma once + +#include "NativeTimerObservation.h" + +#include +#include +#include + +namespace vdrsuite::timers +{ + +enum class NativeTimerCreateReadbackCompleteness +{ + unknown, + complete +}; + +// One native Timer carrying an explicitly decoded Suite ownership +// correlation. Backend-specific marker syntax is deliberately not exposed +// here; only stable Suite identities cross into the Timer domain. +struct NativeTimerCreateReadbackCandidate +{ + std::string timerAssignmentId; + std::string nativeTimerBindingId; + NativeTimerObservation observation; +}; + +// Evidence from one complete authoritative native Timer inventory. +// +// A reader must fail rather than mint complete evidence when the inventory, +// a reserved Suite marker or a correlated native observation cannot be parsed +// safely. Multiple candidates are retained so duplicate managed correlation +// is observable and cannot be silently collapsed. +struct NativeTimerCreateReadbackEvidence +{ + std::string backendId; + std::uint64_t backendGeneration = 0; + std::int64_t observedAt = 0; + + NativeTimerCreateReadbackCompleteness completeness = + NativeTimerCreateReadbackCompleteness::unknown; + + std::vector candidates; +}; + +bool nativeTimerCreateReadbackCandidateValid( + const NativeTimerCreateReadbackCandidate& candidate); + +bool nativeTimerCreateReadbackEvidenceValid( + const NativeTimerCreateReadbackEvidence& evidence); + +} diff --git a/core/timers/include/NativeTimerCreateReadbackExpectation.h b/core/timers/include/NativeTimerCreateReadbackExpectation.h new file mode 100644 index 000000000..8b69701d0 --- /dev/null +++ b/core/timers/include/NativeTimerCreateReadbackExpectation.h @@ -0,0 +1,40 @@ +#pragma once + +#include "NativeTimerReadbackExpectation.h" +#include "NativeTimerSpecification.h" + +#include +#include + +namespace vdrsuite::timers +{ + +// CREATE-specific authoritative PRESENT-readback expectation. +// +// Unlike update/toggle readback, no backendNativeTimerId or binding revision +// exists yet. The stable Suite binding identity is reserved before dispatch; +// the backend-native identity is learned only from authoritative correlated +// readback after the native create may have executed. +struct NativeTimerCreateReadbackExpectation +{ + std::string operationId; + + NativeTimerReadbackOperationState operationState = + NativeTimerReadbackOperationState::executedUnverified; + + std::string timerAssignmentId; + std::string nativeTimerBindingId; + + std::string backendId; + std::uint64_t backendGeneration = 0; + + std::int64_t readbackNotBefore = 0; + + NativeTimerSpecification expectedSpecification; + std::string expectedSpecificationFingerprint; +}; + +bool nativeTimerCreateReadbackExpectationValid( + const NativeTimerCreateReadbackExpectation& expectation); + +} diff --git a/core/timers/include/NativeTimerSpecification.h b/core/timers/include/NativeTimerSpecification.h new file mode 100644 index 000000000..c3700b93d --- /dev/null +++ b/core/timers/include/NativeTimerSpecification.h @@ -0,0 +1,34 @@ +#pragma once + +#include "NativeTimerBinding.h" + +#include +#include + +namespace vdrsuite::timers +{ + +struct NativeTimerSpecification +{ + std::string channelId; + std::string title; + std::string directory; + std::string day; + std::string weekdays = "-------"; + std::string startTime; + std::string endTime; + std::int32_t priority = 50; + std::int32_t lifetime = 99; + bool enabled = true; + bool vps = false; +}; + +bool nativeTimerSpecificationValid( + const NativeTimerSpecification& specification); +std::string nativeTimerSpecificationFingerprint( + const NativeTimerSpecification& specification); +bool nativeTimerObservationMatchesSpecification( + const NativeTimerSpecification& specification, + const NativeTimerObservedState& observedState); + +} diff --git a/core/timers/src/NativeTimerCreateReadbackEvidence.cpp b/core/timers/src/NativeTimerCreateReadbackEvidence.cpp new file mode 100644 index 000000000..f79f062b3 --- /dev/null +++ b/core/timers/src/NativeTimerCreateReadbackEvidence.cpp @@ -0,0 +1,63 @@ +#include "NativeTimerCreateReadbackEvidence.h" + +#include +#include +#include + +namespace vdrsuite::timers +{ +namespace +{ + +constexpr std::size_t kMaxIdentityLength = 160; +constexpr std::size_t kMaxCandidates = 4096; + +bool validIdentity(const std::string& value) +{ + return !value.empty() + && value.size() <= kMaxIdentityLength; +} + +} + +bool nativeTimerCreateReadbackCandidateValid( + const NativeTimerCreateReadbackCandidate& candidate) +{ + return validIdentity(candidate.timerAssignmentId) + && validIdentity(candidate.nativeTimerBindingId) + && nativeTimerObservationValid(candidate.observation); +} + +bool nativeTimerCreateReadbackEvidenceValid( + const NativeTimerCreateReadbackEvidence& evidence) +{ + if (!validIdentity(evidence.backendId) + || evidence.backendGeneration == 0 + || evidence.observedAt <= 0 + || evidence.completeness + != NativeTimerCreateReadbackCompleteness::complete + || evidence.candidates.size() > kMaxCandidates) + { + return false; + } + + std::set nativeTimerIds; + + for (const auto& candidate : evidence.candidates) + { + if (!nativeTimerCreateReadbackCandidateValid(candidate) + || candidate.observation.backendId != evidence.backendId + || candidate.observation.backendGeneration + != evidence.backendGeneration + || candidate.observation.observedAt != evidence.observedAt + || !nativeTimerIds.insert( + candidate.observation.backendNativeTimerId).second) + { + return false; + } + } + + return true; +} + +} diff --git a/core/timers/src/NativeTimerCreateReadbackExpectation.cpp b/core/timers/src/NativeTimerCreateReadbackExpectation.cpp new file mode 100644 index 000000000..4ebab7ec1 --- /dev/null +++ b/core/timers/src/NativeTimerCreateReadbackExpectation.cpp @@ -0,0 +1,67 @@ +#include "NativeTimerCreateReadbackExpectation.h" + +#include + +namespace vdrsuite::timers +{ +namespace +{ + +constexpr std::size_t kMaxIdentityLength = 160; +constexpr std::size_t kMaxFingerprintLength = 4096; + +bool nonEmptyBounded( + const std::string& value, + std::size_t maximum) +{ + return !value.empty() && value.size() <= maximum; +} + +bool operationStateValid( + NativeTimerReadbackOperationState state) +{ + switch (state) + { + case NativeTimerReadbackOperationState::executedUnverified: + case NativeTimerReadbackOperationState::outcomeUnknown: + return true; + } + + return false; +} + +} + +bool nativeTimerCreateReadbackExpectationValid( + const NativeTimerCreateReadbackExpectation& expectation) +{ + if (!nonEmptyBounded( + expectation.operationId, + kMaxIdentityLength) + || !operationStateValid(expectation.operationState) + || !nonEmptyBounded( + expectation.timerAssignmentId, + kMaxIdentityLength) + || !nonEmptyBounded( + expectation.nativeTimerBindingId, + kMaxIdentityLength) + || !nonEmptyBounded( + expectation.backendId, + kMaxIdentityLength) + || expectation.backendGeneration == 0 + || expectation.readbackNotBefore <= 0 + || !nativeTimerSpecificationValid( + expectation.expectedSpecification) + || !nonEmptyBounded( + expectation.expectedSpecificationFingerprint, + kMaxFingerprintLength)) + { + return false; + } + + return expectation.expectedSpecificationFingerprint + == nativeTimerSpecificationFingerprint( + expectation.expectedSpecification); +} + +} diff --git a/core/timers/src/NativeTimerSpecification.cpp b/core/timers/src/NativeTimerSpecification.cpp new file mode 100644 index 000000000..11edf8078 --- /dev/null +++ b/core/timers/src/NativeTimerSpecification.cpp @@ -0,0 +1,134 @@ +#include "NativeTimerSpecification.h" + +#include +#include +#include + +namespace vdrsuite::timers +{ +namespace +{ + +constexpr std::size_t kMaxIdentityLength = 160; +constexpr std::size_t kMaxTextLength = 1024; + +bool bounded(const std::string& value, std::size_t maximum) +{ + return value.size() <= maximum; +} + +bool nonEmptyBounded(const std::string& value, std::size_t maximum) +{ + return !value.empty() && bounded(value, maximum); +} + +bool validWeekdays(const std::string& value) +{ + if (value.size() != 7) return false; + for (const unsigned char ch : value) + { + if (ch != '-' && !std::isalpha(ch)) return false; + } + return true; +} + +bool validHhmm(const std::string& value) +{ + if (value.empty() || value.size() > 4) return false; + for (const unsigned char ch : value) + if (!std::isdigit(ch)) return false; + + std::string normalized(4 - value.size(), '0'); + normalized += value; + const int hour = (normalized[0] - '0') * 10 + normalized[1] - '0'; + const int minute = (normalized[2] - '0') * 10 + normalized[3] - '0'; + return hour <= 23 && minute <= 59; +} + +std::string normalizedHhmm(const std::string& value) +{ + return std::string(4 - value.size(), '0') + value; +} + +void append(std::string& output, const std::string& value) +{ + output += std::to_string(value.size()); + output += ':'; + output += value; + output += '|'; +} + +void append(std::string& output, std::int32_t value) +{ + append(output, std::to_string(value)); +} + +void append(std::string& output, bool value) +{ + append(output, value ? "1" : "0"); +} + +} + +bool nativeTimerSpecificationValid( + const NativeTimerSpecification& specification) +{ + return nonEmptyBounded(specification.channelId, kMaxIdentityLength) + && bounded(specification.title, kMaxTextLength) + && bounded(specification.directory, kMaxTextLength) + && bounded(specification.day, kMaxIdentityLength) + && validWeekdays(specification.weekdays) + && validHhmm(specification.startTime) + && validHhmm(specification.endTime) + && specification.priority >= 0 + && specification.priority <= 99 + && specification.lifetime >= 0 + && specification.lifetime <= 99; +} + +std::string nativeTimerSpecificationFingerprint( + const NativeTimerSpecification& specification) +{ + if (!nativeTimerSpecificationValid(specification)) return {}; + + std::string fingerprint = "native-timer-specification/1|"; + append(fingerprint, specification.channelId); + append(fingerprint, specification.title); + append(fingerprint, specification.directory); + append(fingerprint, specification.day); + append(fingerprint, specification.weekdays); + append(fingerprint, normalizedHhmm(specification.startTime)); + append(fingerprint, normalizedHhmm(specification.endTime)); + append(fingerprint, specification.priority); + append(fingerprint, specification.lifetime); + append(fingerprint, specification.enabled); + append(fingerprint, specification.vps); + return fingerprint; +} + +bool nativeTimerObservationMatchesSpecification( + const NativeTimerSpecification& specification, + const NativeTimerObservedState& observedState) +{ + if (!nativeTimerSpecificationValid(specification) + || !nativeTimerObservedStateValid(observedState)) + { + return false; + } + + return specification.channelId == observedState.channelId + && specification.title == observedState.title + && specification.directory == observedState.directory + && specification.day == observedState.day + && specification.weekdays == observedState.weekdays + && normalizedHhmm(specification.startTime) + == normalizedHhmm(observedState.startTime) + && normalizedHhmm(specification.endTime) + == normalizedHhmm(observedState.endTime) + && specification.priority == observedState.priority + && specification.lifetime == observedState.lifetime + && specification.enabled == observedState.enabled + && specification.vps == observedState.vps; +} + +} diff --git a/core/timers/tests/test_native_timer_create_readback_evidence.cpp b/core/timers/tests/test_native_timer_create_readback_evidence.cpp new file mode 100644 index 000000000..b541d2e63 --- /dev/null +++ b/core/timers/tests/test_native_timer_create_readback_evidence.cpp @@ -0,0 +1,92 @@ +#include "NativeTimerCreateReadbackEvidence.h" + +#include +#include + +using namespace vdrsuite::timers; + +static NativeTimerObservation observation(const std::string& nativeId) +{ + NativeTimerObservation value; + value.backendId = "backend-1"; + value.backendGeneration = 7; + value.backendNativeTimerId = nativeId; + value.observedAt = 1000; + + auto& state = value.observedState; + state.channelId = "S19.2E-1-1019-10301"; + state.title = "Managed Timer"; + state.directory = "VDR-Suite"; + state.day = "2026-08-17"; + state.weekdays = "-------"; + state.startTime = "0930"; + state.endTime = "1015"; + state.priority = 50; + state.lifetime = 99; + state.enabled = true; + state.vps = false; + + value.observedFingerprint = + nativeTimerObservedStateFingerprint(state); + + return value; +} + +static NativeTimerCreateReadbackCandidate candidate( + const std::string& nativeId) +{ + NativeTimerCreateReadbackCandidate value; + value.timerAssignmentId = "assignment-1"; + value.nativeTimerBindingId = "binding-1"; + value.observation = observation(nativeId); + return value; +} + +int main() +{ + NativeTimerCreateReadbackEvidence evidence; + evidence.backendId = "backend-1"; + evidence.backendGeneration = 7; + evidence.observedAt = 1000; + evidence.completeness = + NativeTimerCreateReadbackCompleteness::complete; + + // A complete inventory may contain no managed correlation. + assert(nativeTimerCreateReadbackEvidenceValid(evidence)); + + // Same Suite correlation on two distinct native timers must remain + // observable evidence; ambiguity is decided by the verifier. + evidence.candidates = { + candidate("101"), + candidate("102") + }; + assert(nativeTimerCreateReadbackEvidenceValid(evidence)); + + auto duplicateNativeId = evidence; + duplicateNativeId.candidates[1] = candidate("101"); + assert(!nativeTimerCreateReadbackEvidenceValid(duplicateNativeId)); + + auto wrongBackend = evidence; + wrongBackend.candidates[0].observation.backendId = "backend-2"; + assert(!nativeTimerCreateReadbackEvidenceValid(wrongBackend)); + + auto wrongGeneration = evidence; + wrongGeneration.candidates[0].observation.backendGeneration = 8; + assert(!nativeTimerCreateReadbackEvidenceValid(wrongGeneration)); + + auto wrongTime = evidence; + wrongTime.candidates[0].observation.observedAt = 999; + assert(!nativeTimerCreateReadbackEvidenceValid(wrongTime)); + + auto incomplete = evidence; + incomplete.completeness = + NativeTimerCreateReadbackCompleteness::unknown; + assert(!nativeTimerCreateReadbackEvidenceValid(incomplete)); + + auto invalidCorrelation = evidence; + invalidCorrelation.candidates[0].timerAssignmentId.clear(); + assert(!nativeTimerCreateReadbackEvidenceValid(invalidCorrelation)); + + std::cout << "test_native_timer_create_readback_evidence passed\n"; + return 0; +} diff --git a/core/timers/tests/test_native_timer_create_readback_expectation.cpp b/core/timers/tests/test_native_timer_create_readback_expectation.cpp new file mode 100644 index 000000000..6b4d53cc7 --- /dev/null +++ b/core/timers/tests/test_native_timer_create_readback_expectation.cpp @@ -0,0 +1,79 @@ +#include "NativeTimerCreateReadbackExpectation.h" + +#include +#include + +using namespace vdrsuite::timers; + +static NativeTimerCreateReadbackExpectation makeExpectation() +{ + NativeTimerCreateReadbackExpectation expectation; + + expectation.operationId = "operation-1"; + expectation.operationState = + NativeTimerReadbackOperationState::executedUnverified; + expectation.timerAssignmentId = "assignment-1"; + expectation.nativeTimerBindingId = "binding-1"; + expectation.backendId = "backend-1"; + expectation.backendGeneration = 7; + expectation.readbackNotBefore = 1000; + + auto& spec = expectation.expectedSpecification; + spec.channelId = "S19.2E-1-1019-10301"; + spec.title = "Managed Timer"; + spec.directory = "VDR-Suite"; + spec.day = "2026-08-17"; + spec.weekdays = "-------"; + spec.startTime = "0930"; + spec.endTime = "1015"; + spec.priority = 50; + spec.lifetime = 99; + spec.enabled = true; + spec.vps = false; + + expectation.expectedSpecificationFingerprint = + nativeTimerSpecificationFingerprint(spec); + + return expectation; +} + +int main() +{ + const auto valid = makeExpectation(); + assert(nativeTimerCreateReadbackExpectationValid(valid)); + + auto unknown = valid; + unknown.operationState = + NativeTimerReadbackOperationState::outcomeUnknown; + assert(nativeTimerCreateReadbackExpectationValid(unknown)); + + auto badFingerprint = valid; + badFingerprint.expectedSpecificationFingerprint = "wrong"; + assert(!nativeTimerCreateReadbackExpectationValid(badFingerprint)); + + auto noAssignment = valid; + noAssignment.timerAssignmentId.clear(); + assert(!nativeTimerCreateReadbackExpectationValid(noAssignment)); + + auto noBinding = valid; + noBinding.nativeTimerBindingId.clear(); + assert(!nativeTimerCreateReadbackExpectationValid(noBinding)); + + auto noGeneration = valid; + noGeneration.backendGeneration = 0; + assert(!nativeTimerCreateReadbackExpectationValid(noGeneration)); + + auto invalidState = valid; + invalidState.operationState = + static_cast(999); + assert(!nativeTimerCreateReadbackExpectationValid(invalidState)); + + auto changedSpec = valid; + changedSpec.expectedSpecification.startTime = "0945"; + assert(!nativeTimerCreateReadbackExpectationValid(changedSpec)); + + std::cout + << "test_native_timer_create_readback_expectation passed\n"; + + return 0; +} diff --git a/core/timers/tests/test_native_timer_specification.cpp b/core/timers/tests/test_native_timer_specification.cpp new file mode 100644 index 000000000..5de853bf4 --- /dev/null +++ b/core/timers/tests/test_native_timer_specification.cpp @@ -0,0 +1,76 @@ +#include "NativeTimerSpecification.h" + +#include +#include + +using namespace vdrsuite::timers; + +int main() +{ + NativeTimerSpecification spec; + spec.channelId = "S19.2E-1-1019-10301"; + spec.title = "Managed Timer"; + spec.directory = "VDR-Suite"; + spec.day = "2026-08-17"; + spec.weekdays = "-------"; + spec.startTime = "930"; + spec.endTime = "1015"; + spec.priority = 50; + spec.lifetime = 99; + spec.enabled = true; + spec.vps = false; + + assert(nativeTimerSpecificationValid(spec)); + const auto fingerprint = nativeTimerSpecificationFingerprint(spec); + assert(!fingerprint.empty()); + + auto padded = spec; + padded.startTime = "0930"; + assert(nativeTimerSpecificationFingerprint(padded) == fingerprint); + + NativeTimerObservedState observed; + observed.channelId = spec.channelId; + observed.title = spec.title; + observed.directory = spec.directory; + observed.day = spec.day; + observed.weekdays = spec.weekdays; + observed.startTime = "0930"; + observed.endTime = spec.endTime; + observed.priority = spec.priority; + observed.lifetime = spec.lifetime; + observed.enabled = spec.enabled; + observed.vps = spec.vps; + assert(nativeTimerObservationMatchesSpecification(spec, observed)); + + observed.title = "Changed"; + assert(!nativeTimerObservationMatchesSpecification(spec, observed)); + observed.title = spec.title; + + observed.eventId = "event-99"; + observed.flags = 123; + observed.recording = true; + observed.pending = true; + assert(nativeTimerObservationMatchesSpecification(spec, observed)); + + observed.enabled = false; + assert(!nativeTimerObservationMatchesSpecification(spec, observed)); + observed.enabled = spec.enabled; + + observed.vps = true; + assert(!nativeTimerObservationMatchesSpecification(spec, observed)); + observed.vps = spec.vps; + + observed.priority = 40; + assert(!nativeTimerObservationMatchesSpecification(spec, observed)); + observed.priority = spec.priority; + + observed.startTime = "0945"; + assert(!nativeTimerObservationMatchesSpecification(spec, observed)); + + auto invalid = spec; + invalid.weekdays = "------"; + assert(!nativeTimerSpecificationValid(invalid)); + + std::cout << "test_native_timer_specification passed\n"; + return 0; +} diff --git a/core/vdr/include/VdrTimerManagedCorrelation.h b/core/vdr/include/VdrTimerManagedCorrelation.h new file mode 100644 index 000000000..d8fe6a874 --- /dev/null +++ b/core/vdr/include/VdrTimerManagedCorrelation.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +struct VdrTimerManagedCorrelation +{ + std::string timerAssignmentId; + std::string nativeTimerBindingId; +}; + +enum class VdrTimerManagedCorrelationStatus +{ + ok, + absent, + invalidCorrelation, + malformedMarker, + conflictingMarker, +}; + +struct VdrTimerManagedCorrelationParseResult +{ + VdrTimerManagedCorrelationStatus status = + VdrTimerManagedCorrelationStatus::absent; + VdrTimerManagedCorrelation correlation; + + bool ok() const + { + return status == VdrTimerManagedCorrelationStatus::ok; + } +}; + +struct VdrTimerManagedCorrelationAttachResult +{ + VdrTimerManagedCorrelationStatus status = + VdrTimerManagedCorrelationStatus::invalidCorrelation; + std::string aux; + + bool ok() const + { + return status == VdrTimerManagedCorrelationStatus::ok; + } +}; + +bool vdrTimerManagedCorrelationValid( + const VdrTimerManagedCorrelation& correlation); +VdrTimerManagedCorrelationParseResult parseVdrTimerManagedCorrelation( + const std::string& aux); +VdrTimerManagedCorrelationAttachResult attachVdrTimerManagedCorrelation( + const std::string& aux, + const VdrTimerManagedCorrelation& correlation); diff --git a/core/vdr/src/VdrTimerManagedCorrelation.cpp b/core/vdr/src/VdrTimerManagedCorrelation.cpp new file mode 100644 index 000000000..c5f4bd106 --- /dev/null +++ b/core/vdr/src/VdrTimerManagedCorrelation.cpp @@ -0,0 +1,172 @@ +#include "VdrTimerManagedCorrelation.h" + +#include +#include + +namespace +{ + +constexpr std::size_t kMaxIdentityLength = 160; +const std::string kReservedPrefix = ""; + +bool validIdentity(const std::string& value) +{ + return !value.empty() && value.size() <= kMaxIdentityLength; +} + +char hexDigit(unsigned int value) +{ + return static_cast(value < 10 ? '0' + value : 'A' + value - 10); +} + +std::string encodeHex(const std::string& value) +{ + std::string encoded; + encoded.reserve(value.size() * 2); + for (const unsigned char ch : value) + { + encoded.push_back(hexDigit((ch >> 4) & 0x0F)); + encoded.push_back(hexDigit(ch & 0x0F)); + } + return encoded; +} + +int hexValue(char ch) +{ + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return 10 + ch - 'a'; + if (ch >= 'A' && ch <= 'F') return 10 + ch - 'A'; + return -1; +} + +bool decodeHex(const std::string& encoded, std::string& value) +{ + if (encoded.empty() + || encoded.size() % 2 != 0 + || encoded.size() > kMaxIdentityLength * 2) + { + return false; + } + + value.clear(); + value.reserve(encoded.size() / 2); + for (std::size_t i = 0; i < encoded.size(); i += 2) + { + const int high = hexValue(encoded[i]); + const int low = hexValue(encoded[i + 1]); + if (high < 0 || low < 0) return false; + value.push_back(static_cast((high << 4) | low)); + } + return validIdentity(value); +} + +bool sameCorrelation( + const VdrTimerManagedCorrelation& left, + const VdrTimerManagedCorrelation& right) +{ + return left.timerAssignmentId == right.timerAssignmentId + && left.nativeTimerBindingId == right.nativeTimerBindingId; +} + +std::string markerFor(const VdrTimerManagedCorrelation& correlation) +{ + return kMarkerPrefix + + encodeHex(correlation.timerAssignmentId) + + kBindingSeparator + + encodeHex(correlation.nativeTimerBindingId) + + kMarkerSuffix; +} + +VdrTimerManagedCorrelationParseResult parseResult( + VdrTimerManagedCorrelationStatus status) +{ + VdrTimerManagedCorrelationParseResult result; + result.status = status; + return result; +} + +} + +bool vdrTimerManagedCorrelationValid( + const VdrTimerManagedCorrelation& correlation) +{ + return validIdentity(correlation.timerAssignmentId) + && validIdentity(correlation.nativeTimerBindingId); +} + +VdrTimerManagedCorrelationParseResult parseVdrTimerManagedCorrelation( + const std::string& aux) +{ + const std::size_t reserved = aux.find(kReservedPrefix); + if (reserved == std::string::npos) + return parseResult(VdrTimerManagedCorrelationStatus::absent); + + if (aux.find(kReservedPrefix, reserved + 1) != std::string::npos) + return parseResult(VdrTimerManagedCorrelationStatus::conflictingMarker); + + if (aux.compare(reserved, kMarkerPrefix.size(), kMarkerPrefix) != 0) + return parseResult(VdrTimerManagedCorrelationStatus::malformedMarker); + + const std::size_t assignmentStart = reserved + kMarkerPrefix.size(); + const std::size_t separator = aux.find(kBindingSeparator, assignmentStart); + if (separator == std::string::npos) + return parseResult(VdrTimerManagedCorrelationStatus::malformedMarker); + + const std::size_t bindingStart = separator + kBindingSeparator.size(); + const std::size_t suffix = aux.find(kMarkerSuffix, bindingStart); + if (suffix == std::string::npos) + return parseResult(VdrTimerManagedCorrelationStatus::malformedMarker); + + VdrTimerManagedCorrelation correlation; + if (!decodeHex( + aux.substr(assignmentStart, separator - assignmentStart), + correlation.timerAssignmentId) + || !decodeHex( + aux.substr(bindingStart, suffix - bindingStart), + correlation.nativeTimerBindingId)) + { + return parseResult(VdrTimerManagedCorrelationStatus::malformedMarker); + } + + VdrTimerManagedCorrelationParseResult result; + result.status = VdrTimerManagedCorrelationStatus::ok; + result.correlation = std::move(correlation); + return result; +} + +VdrTimerManagedCorrelationAttachResult attachVdrTimerManagedCorrelation( + const std::string& aux, + const VdrTimerManagedCorrelation& correlation) +{ + VdrTimerManagedCorrelationAttachResult result; + result.aux = aux; + + if (!vdrTimerManagedCorrelationValid(correlation)) + { + result.status = VdrTimerManagedCorrelationStatus::invalidCorrelation; + return result; + } + + const auto parsed = parseVdrTimerManagedCorrelation(aux); + if (parsed.status == VdrTimerManagedCorrelationStatus::absent) + { + result.aux += markerFor(correlation); + result.status = VdrTimerManagedCorrelationStatus::ok; + return result; + } + + if (parsed.status == VdrTimerManagedCorrelationStatus::ok) + { + result.status = sameCorrelation(parsed.correlation, correlation) + ? VdrTimerManagedCorrelationStatus::ok + : VdrTimerManagedCorrelationStatus::conflictingMarker; + return result; + } + + result.status = parsed.status; + return result; +} diff --git a/core/vdr/tests/test_vdr_timer_managed_correlation.cpp b/core/vdr/tests/test_vdr_timer_managed_correlation.cpp new file mode 100644 index 000000000..2d8d22924 --- /dev/null +++ b/core/vdr/tests/test_vdr_timer_managed_correlation.cpp @@ -0,0 +1,57 @@ +#include "VdrTimerManagedCorrelation.h" + +#include +#include +#include + +int main() +{ + const VdrTimerManagedCorrelation correlation{ + "assignment-1", + "binding-1" + }; + assert(vdrTimerManagedCorrelationValid(correlation)); + + const std::string foreignAux = + "42"; + assert(parseVdrTimerManagedCorrelation(foreignAux).status + == VdrTimerManagedCorrelationStatus::absent); + + const auto attached = + attachVdrTimerManagedCorrelation(foreignAux, correlation); + assert(attached.ok()); + assert(attached.aux.compare(0, foreignAux.size(), foreignAux) == 0); + + const auto parsed = parseVdrTimerManagedCorrelation(attached.aux); + assert(parsed.ok()); + assert(parsed.correlation.timerAssignmentId == correlation.timerAssignmentId); + assert(parsed.correlation.nativeTimerBindingId + == correlation.nativeTimerBindingId); + + const auto repeated = + attachVdrTimerManagedCorrelation(attached.aux, correlation); + assert(repeated.ok()); + assert(repeated.aux == attached.aux); + + VdrTimerManagedCorrelation other = correlation; + other.nativeTimerBindingId = "binding-2"; + const auto conflict = attachVdrTimerManagedCorrelation(attached.aux, other); + assert(conflict.status == VdrTimerManagedCorrelationStatus::conflictingMarker); + assert(conflict.aux == attached.aux); + + const std::string malformed = ""; + assert(parseVdrTimerManagedCorrelation(malformed).status + == VdrTimerManagedCorrelationStatus::malformedMarker); + const auto malformedAttach = + attachVdrTimerManagedCorrelation(malformed, correlation); + assert(malformedAttach.status + == VdrTimerManagedCorrelationStatus::malformedMarker); + assert(malformedAttach.aux == malformed); + + const std::string duplicate = attached.aux + attached.aux; + assert(parseVdrTimerManagedCorrelation(duplicate).status + == VdrTimerManagedCorrelationStatus::conflictingMarker); + + std::cout << "test_vdr_timer_managed_correlation passed\n"; + return 0; +} diff --git a/mk/phase64-native-timer-create-readback-evidence-tests.mk b/mk/phase64-native-timer-create-readback-evidence-tests.mk new file mode 100644 index 000000000..031ebf50b --- /dev/null +++ b/mk/phase64-native-timer-create-readback-evidence-tests.mk @@ -0,0 +1,13 @@ +.PHONY: test-phase64-native-timer-create-readback-evidence + +test-phase64-native-timer-create-readback-evidence: + $(BUILD_CXX) $(CXXFLAGS) -Icore/timers/include \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerObservation.cpp \ + core/timers/src/NativeTimerCreateReadbackEvidence.cpp \ + core/timers/tests/test_native_timer_create_readback_evidence.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_create_readback_evidence + $(BUILD_DIR)/test_native_timer_create_readback_evidence + +test-fast: test-phase64-native-timer-create-readback-evidence diff --git a/mk/phase64-native-timer-create-readback-expectation-tests.mk b/mk/phase64-native-timer-create-readback-expectation-tests.mk new file mode 100644 index 000000000..aacff15bd --- /dev/null +++ b/mk/phase64-native-timer-create-readback-expectation-tests.mk @@ -0,0 +1,13 @@ +.PHONY: test-phase64-native-timer-create-readback-expectation + +test-phase64-native-timer-create-readback-expectation: + $(BUILD_CXX) $(CXXFLAGS) -Icore/timers/include \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/src/NativeTimerCreateReadbackExpectation.cpp \ + core/timers/tests/test_native_timer_create_readback_expectation.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_create_readback_expectation + $(BUILD_DIR)/test_native_timer_create_readback_expectation + +test-fast: test-phase64-native-timer-create-readback-expectation diff --git a/mk/phase64-native-timer-specification-tests.mk b/mk/phase64-native-timer-specification-tests.mk new file mode 100644 index 000000000..3cb0c0b04 --- /dev/null +++ b/mk/phase64-native-timer-specification-tests.mk @@ -0,0 +1,12 @@ +.PHONY: test-phase64-native-timer-specification + +test-phase64-native-timer-specification: + $(BUILD_CXX) $(CXXFLAGS) -Icore/timers/include \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/tests/test_native_timer_specification.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_specification + $(BUILD_DIR)/test_native_timer_specification + +test-fast: test-phase64-native-timer-specification diff --git a/mk/phase64-vdr-timer-managed-correlation-tests.mk b/mk/phase64-vdr-timer-managed-correlation-tests.mk new file mode 100644 index 000000000..36bd77dfc --- /dev/null +++ b/mk/phase64-vdr-timer-managed-correlation-tests.mk @@ -0,0 +1,11 @@ +.PHONY: test-phase64-vdr-timer-managed-correlation + +test-phase64-vdr-timer-managed-correlation: + $(BUILD_CXX) $(CXXFLAGS) -Icore/vdr/include \ + core/vdr/src/VdrTimerManagedCorrelation.cpp \ + core/vdr/tests/test_vdr_timer_managed_correlation.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_vdr_timer_managed_correlation + $(BUILD_DIR)/test_vdr_timer_managed_correlation + +test-fast: test-phase64-vdr-timer-managed-correlation From 15b5411e82062fd966c86c420f78b8d81ca63436 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:31:39 +0200 Subject: [PATCH 02/85] noop --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 566f106e4..962b66442 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ include mk/phase64-vdr-timer-managed-correlation-tests.mk include mk/phase64-native-timer-readback-expectation-tests.mk include mk/phase64-native-timer-create-readback-expectation-tests.mk include mk/phase64-native-timer-create-readback-evidence-tests.mk +include mk/phase64-native-timer-create-readback-verification-tests.mk include mk/phase64-native-timer-present-readback-verification-tests.mk include mk/phase64-native-timer-inventory-evidence-tests.mk include mk/phase64-restfulapi-native-timer-inventory-reader-tests.mk From 54bd379df55b4b13efab6e5a68b237cf49f821a5 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:32:08 +0200 Subject: [PATCH 03/85] Phase 64: stage managed timer create readback verifier --- ...veTimerCreateReadbackVerificationService.h | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 core/timers/include/NativeTimerCreateReadbackVerificationService.h diff --git a/core/timers/include/NativeTimerCreateReadbackVerificationService.h b/core/timers/include/NativeTimerCreateReadbackVerificationService.h new file mode 100644 index 000000000..1d10dd1df --- /dev/null +++ b/core/timers/include/NativeTimerCreateReadbackVerificationService.h @@ -0,0 +1,56 @@ +#pragma once + +#include "NativeTimerBinding.h" +#include "NativeTimerCreateReadbackEvidence.h" +#include "NativeTimerCreateReadbackExpectation.h" + +namespace vdrsuite::timers +{ + +class NativeTimerBindingRepository; + +enum class NativeTimerCreateReadbackVerificationStatus +{ + verified, + alreadyVerified, + correlationNotFound, + correlationAmbiguous, + backendConflict, + generationConflict, + staleEvidence, + reconciliationRequired, + bindingConflict, + nativeIdentityConflict, + assignmentBindingConflict, + repositoryError, + invalid, +}; + +struct NativeTimerCreateReadbackVerificationResult +{ + NativeTimerCreateReadbackVerificationStatus status = + NativeTimerCreateReadbackVerificationStatus::repositoryError; + NativeTimerBinding binding; + + bool ok() const + { + return status == NativeTimerCreateReadbackVerificationStatus::verified + || status == NativeTimerCreateReadbackVerificationStatus::alreadyVerified; + } +}; + +class NativeTimerCreateReadbackVerificationService +{ +public: + explicit NativeTimerCreateReadbackVerificationService( + NativeTimerBindingRepository& repository); + + NativeTimerCreateReadbackVerificationResult verify( + const NativeTimerCreateReadbackExpectation& expectation, + const NativeTimerCreateReadbackEvidence& evidence); + +private: + NativeTimerBindingRepository& repository_; +}; + +} From d9028d6654f6275a0a8b93b74b84c61a08db3f83 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:32:31 +0200 Subject: [PATCH 04/85] Phase 64: complete managed timer create readback verifier --- ...TimerCreateReadbackVerificationService.cpp | 157 ++++++++++++++ ...r_create_readback_verification_service.cpp | 198 ++++++++++++++++++ ...tive-timer-create-readback-verification.md | 15 ++ ...imer-create-readback-verification-tests.mk | 25 +++ ...tive_timer_create_readback_verification.py | 117 +++++++++++ 5 files changed, 512 insertions(+) create mode 100644 core/timers/src/NativeTimerCreateReadbackVerificationService.cpp create mode 100644 core/timers/tests/test_native_timer_create_readback_verification_service.cpp create mode 100644 docs/development/phase-64-native-timer-create-readback-verification.md create mode 100644 mk/phase64-native-timer-create-readback-verification-tests.mk create mode 100755 tools/check_phase64_native_timer_create_readback_verification.py diff --git a/core/timers/src/NativeTimerCreateReadbackVerificationService.cpp b/core/timers/src/NativeTimerCreateReadbackVerificationService.cpp new file mode 100644 index 000000000..a6e091ab3 --- /dev/null +++ b/core/timers/src/NativeTimerCreateReadbackVerificationService.cpp @@ -0,0 +1,157 @@ +#include "NativeTimerCreateReadbackVerificationService.h" + +#include "NativeTimerBindingRepository.h" +#include "NativeTimerSpecification.h" + +namespace vdrsuite::timers +{ +namespace +{ +NativeTimerCreateReadbackVerificationResult result( + NativeTimerCreateReadbackVerificationStatus status, + const NativeTimerBinding& binding = {}) +{ + NativeTimerCreateReadbackVerificationResult value; + value.status = status; + value.binding = binding; + return value; +} + +bool correlationMatches( + const NativeTimerCreateReadbackCandidate& candidate, + const NativeTimerCreateReadbackExpectation& expectation) +{ + return candidate.timerAssignmentId == expectation.timerAssignmentId + && candidate.nativeTimerBindingId == expectation.nativeTimerBindingId; +} + +bool alreadyVerified( + const NativeTimerBinding& binding, + const NativeTimerCreateReadbackExpectation& expectation, + const NativeTimerCreateReadbackCandidate& candidate) +{ + const NativeTimerObservation& observation = candidate.observation; + return binding.nativeTimerBindingId == expectation.nativeTimerBindingId + && binding.timerAssignmentId == expectation.timerAssignmentId + && binding.backendId == expectation.backendId + && binding.backendGeneration == expectation.backendGeneration + && binding.backendNativeTimerId == observation.backendNativeTimerId + && binding.ownership == NativeTimerBindingOwnership::managed + && binding.observedFingerprint == observation.observedFingerprint + && binding.lastObservedAt >= expectation.readbackNotBefore + && binding.lastVerifiedOperationId == expectation.operationId + && binding.missingSince == 0 + && binding.driftState == NativeTimerBindingDriftState::none; +} +} + +NativeTimerCreateReadbackVerificationService:: +NativeTimerCreateReadbackVerificationService( + NativeTimerBindingRepository& repository) + : repository_(repository) +{ +} + +NativeTimerCreateReadbackVerificationResult +NativeTimerCreateReadbackVerificationService::verify( + const NativeTimerCreateReadbackExpectation& expectation, + const NativeTimerCreateReadbackEvidence& evidence) +{ + if (!nativeTimerCreateReadbackExpectationValid(expectation) + || !nativeTimerCreateReadbackEvidenceValid(evidence)) + { + return result(NativeTimerCreateReadbackVerificationStatus::invalid); + } + + if (evidence.backendId != expectation.backendId) + return result(NativeTimerCreateReadbackVerificationStatus::backendConflict); + if (evidence.backendGeneration != expectation.backendGeneration) + return result(NativeTimerCreateReadbackVerificationStatus::generationConflict); + if (evidence.observedAt < expectation.readbackNotBefore) + return result(NativeTimerCreateReadbackVerificationStatus::staleEvidence); + + const NativeTimerCreateReadbackCandidate* matched = nullptr; + for (const auto& candidate : evidence.candidates) + { + if (!correlationMatches(candidate, expectation)) continue; + if (matched != nullptr) + return result( + NativeTimerCreateReadbackVerificationStatus::correlationAmbiguous); + matched = &candidate; + } + + if (matched == nullptr) + return result(NativeTimerCreateReadbackVerificationStatus::correlationNotFound); + + if (!nativeTimerObservationMatchesSpecification( + expectation.expectedSpecification, + matched->observation.observedState)) + { + return result( + NativeTimerCreateReadbackVerificationStatus::reconciliationRequired); + } + + const auto existing = repository_.findById(expectation.nativeTimerBindingId); + if (existing.ok()) + { + if (alreadyVerified(existing.binding, expectation, *matched)) + { + return result( + NativeTimerCreateReadbackVerificationStatus::alreadyVerified, + existing.binding); + } + return result( + NativeTimerCreateReadbackVerificationStatus::bindingConflict, + existing.binding); + } + if (existing.status != NativeTimerBindingRepositoryStatus::notFound) + return result(NativeTimerCreateReadbackVerificationStatus::repositoryError); + + NativeTimerBinding binding; + binding.nativeTimerBindingId = expectation.nativeTimerBindingId; + binding.backendId = expectation.backendId; + binding.backendGeneration = expectation.backendGeneration; + binding.backendNativeTimerId = matched->observation.backendNativeTimerId; + binding.timerAssignmentId = expectation.timerAssignmentId; + binding.ownership = NativeTimerBindingOwnership::managed; + binding.observedState = matched->observation.observedState; + binding.observedFingerprint = matched->observation.observedFingerprint; + binding.lastObservedAt = matched->observation.observedAt; + binding.lastVerifiedOperationId = expectation.operationId; + binding.missingSince = 0; + binding.driftState = NativeTimerBindingDriftState::none; + + const auto created = repository_.create(binding); + if (created.ok()) + { + return result( + NativeTimerCreateReadbackVerificationStatus::verified, + created.binding); + } + + switch (created.status) + { + case NativeTimerBindingRepositoryStatus::alreadyExists: + if (alreadyVerified(created.binding, expectation, *matched)) + { + return result( + NativeTimerCreateReadbackVerificationStatus::alreadyVerified, + created.binding); + } + return result( + NativeTimerCreateReadbackVerificationStatus::bindingConflict, + created.binding); + case NativeTimerBindingRepositoryStatus::nativeIdentityConflict: + return result( + NativeTimerCreateReadbackVerificationStatus::nativeIdentityConflict, + created.binding); + case NativeTimerBindingRepositoryStatus::assignmentBindingConflict: + return result( + NativeTimerCreateReadbackVerificationStatus::assignmentBindingConflict, + created.binding); + default: + return result(NativeTimerCreateReadbackVerificationStatus::repositoryError); + } +} + +} diff --git a/core/timers/tests/test_native_timer_create_readback_verification_service.cpp b/core/timers/tests/test_native_timer_create_readback_verification_service.cpp new file mode 100644 index 000000000..075332097 --- /dev/null +++ b/core/timers/tests/test_native_timer_create_readback_verification_service.cpp @@ -0,0 +1,198 @@ +#include "Database.h" +#include "NativeTimerBindingRepository.h" +#include "NativeTimerCreateReadbackEvidence.h" +#include "NativeTimerCreateReadbackExpectation.h" +#include "NativeTimerCreateReadbackVerificationService.h" +#include "NativeTimerSpecification.h" + +#include +#include + +using namespace vdrsuite::timers; + +namespace +{ +NativeTimerSpecification specification() +{ + NativeTimerSpecification value; + value.channelId = "channel:1"; + value.title = "Managed Timer"; + value.directory = "VDR-Suite"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1015"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +NativeTimerObservation observation( + const std::string& nativeId, + std::int64_t observedAt = 2100) +{ + NativeTimerObservation value; + value.backendId = "backend:1"; + value.backendGeneration = 7; + value.backendNativeTimerId = nativeId; + value.observedAt = observedAt; + value.observedState.channelId = "channel:1"; + value.observedState.title = "Managed Timer"; + value.observedState.directory = "VDR-Suite"; + value.observedState.day = "2026-08-17"; + value.observedState.weekdays = "-------"; + value.observedState.startTime = "930"; + value.observedState.endTime = "1015"; + value.observedState.priority = 50; + value.observedState.lifetime = 99; + value.observedState.enabled = true; + value.observedState.vps = false; + value.observedState.pending = true; + value.observedFingerprint = + nativeTimerObservedStateFingerprint(value.observedState); + return value; +} + +NativeTimerCreateReadbackCandidate candidate( + const std::string& assignmentId, + const std::string& bindingId, + const std::string& nativeId) +{ + NativeTimerCreateReadbackCandidate value; + value.timerAssignmentId = assignmentId; + value.nativeTimerBindingId = bindingId; + value.observation = observation(nativeId); + return value; +} + +NativeTimerCreateReadbackExpectation expectation( + const std::string& operationId = "operation:1", + const std::string& assignmentId = "assignment:1", + const std::string& bindingId = "binding:1") +{ + NativeTimerCreateReadbackExpectation value; + value.operationId = operationId; + value.operationState = + NativeTimerReadbackOperationState::executedUnverified; + value.timerAssignmentId = assignmentId; + value.nativeTimerBindingId = bindingId; + value.backendId = "backend:1"; + value.backendGeneration = 7; + value.readbackNotBefore = 2000; + value.expectedSpecification = specification(); + value.expectedSpecificationFingerprint = + nativeTimerSpecificationFingerprint(value.expectedSpecification); + return value; +} + +NativeTimerCreateReadbackEvidence evidence( + std::initializer_list candidates) +{ + NativeTimerCreateReadbackEvidence value; + value.backendId = "backend:1"; + value.backendGeneration = 7; + value.observedAt = 2100; + value.completeness = NativeTimerCreateReadbackCompleteness::complete; + value.candidates.assign(candidates.begin(), candidates.end()); + return value; +} +} + +int main() +{ + Database database; + assert(database.open(":memory:")); + NativeTimerBindingRepository repository(database); + assert(repository.ensureSchema()); + NativeTimerCreateReadbackVerificationService service(repository); + + const auto expected = expectation(); + const auto observed = evidence({candidate("assignment:1", "binding:1", "timer:17")}); + + const auto verified = service.verify(expected, observed); + assert(verified.status == NativeTimerCreateReadbackVerificationStatus::verified); + assert(verified.binding.bindingRevision == "1"); + assert(verified.binding.nativeTimerBindingId == "binding:1"); + assert(verified.binding.backendNativeTimerId == "timer:17"); + assert(verified.binding.timerAssignmentId == "assignment:1"); + assert(verified.binding.ownership == NativeTimerBindingOwnership::managed); + assert(verified.binding.lastVerifiedOperationId == "operation:1"); + assert(verified.binding.lastObservedAt == 2100); + assert(verified.binding.missingSince == 0); + assert(verified.binding.driftState == NativeTimerBindingDriftState::none); + + const auto replay = service.verify(expected, observed); + assert(replay.status == NativeTimerCreateReadbackVerificationStatus::alreadyVerified); + assert(replay.binding.bindingRevision == "1"); + + assert(service.verify(expected, evidence({})).status == + NativeTimerCreateReadbackVerificationStatus::correlationNotFound); + + const auto ambiguous = evidence({ + candidate("assignment:1", "binding:1", "timer:18"), + candidate("assignment:1", "binding:1", "timer:19")}); + assert(service.verify(expected, ambiguous).status == + NativeTimerCreateReadbackVerificationStatus::correlationAmbiguous); + + const auto withForeign = evidence({ + candidate("assignment:other", "binding:other", "timer:20"), + candidate("assignment:1", "binding:1", "timer:17")}); + assert(service.verify(expected, withForeign).status == + NativeTimerCreateReadbackVerificationStatus::alreadyVerified); + + auto wrongBackend = observed; + wrongBackend.backendId = "backend:other"; + for (auto& item : wrongBackend.candidates) + item.observation.backendId = "backend:other"; + assert(service.verify(expected, wrongBackend).status == + NativeTimerCreateReadbackVerificationStatus::backendConflict); + + auto wrongGeneration = observed; + wrongGeneration.backendGeneration = 8; + for (auto& item : wrongGeneration.candidates) + item.observation.backendGeneration = 8; + assert(service.verify(expected, wrongGeneration).status == + NativeTimerCreateReadbackVerificationStatus::generationConflict); + + auto stale = observed; + stale.observedAt = 1999; + for (auto& item : stale.candidates) + item.observation.observedAt = 1999; + assert(service.verify(expected, stale).status == + NativeTimerCreateReadbackVerificationStatus::staleEvidence); + + auto changed = evidence({candidate("assignment:2", "binding:2", "timer:21")}); + changed.candidates[0].observation.observedState.startTime = "0945"; + changed.candidates[0].observation.observedFingerprint = + nativeTimerObservedStateFingerprint(changed.candidates[0].observation.observedState); + assert(service.verify(expectation("operation:2", "assignment:2", "binding:2"), changed).status == + NativeTimerCreateReadbackVerificationStatus::reconciliationRequired); + + assert(service.verify( + expectation("operation:other", "assignment:1", "binding:1"), observed).status == + NativeTimerCreateReadbackVerificationStatus::bindingConflict); + + const auto nativeConflictEvidence = evidence({ + candidate("assignment:2", "binding:2", "timer:17")}); + assert(service.verify( + expectation("operation:2", "assignment:2", "binding:2"), + nativeConflictEvidence).status == + NativeTimerCreateReadbackVerificationStatus::nativeIdentityConflict); + + const auto assignmentConflictEvidence = evidence({ + candidate("assignment:1", "binding:3", "timer:22")}); + assert(service.verify( + expectation("operation:3", "assignment:1", "binding:3"), + assignmentConflictEvidence).status == + NativeTimerCreateReadbackVerificationStatus::assignmentBindingConflict); + + auto invalidExpectation = expected; + invalidExpectation.readbackNotBefore = 0; + assert(service.verify(invalidExpectation, observed).status == + NativeTimerCreateReadbackVerificationStatus::invalid); + + std::cout << "test_native_timer_create_readback_verification_service passed\n"; + return 0; +} diff --git a/docs/development/phase-64-native-timer-create-readback-verification.md b/docs/development/phase-64-native-timer-create-readback-verification.md new file mode 100644 index 000000000..3beae1022 --- /dev/null +++ b/docs/development/phase-64-native-timer-create-readback-verification.md @@ -0,0 +1,15 @@ +# Phase 64 — Managed Native Timer CREATE Readback Verification + +This slice verifies the result of one managed native Timer CREATE without inventing a backend-native identity and without retrying the native mutation. + +The verifier consumes a durable `NativeTimerCreateReadbackExpectation` and evidence from one authoritative complete inventory. The evidence may contain unrelated managed Timer correlations, but successful verification requires exactly one managed correlation matching both the reserved `timerAssignmentId` and reserved `nativeTimerBindingId`. + +A matching candidate is not sufficient by itself. Backend identity, backend generation, readback freshness and the managed `NativeTimerSpecification` must also match. Observation-only facts such as recording/pending status are retained in the resulting `NativeTimerBinding` but are not part of the desired-write specification comparison. + +On verified CREATE, the service persists the first managed `NativeTimerBinding` with the native identity discovered by readback, the authoritative observed fingerprint/state and `lastVerifiedOperationId`. Repository uniqueness still enforces both backend-native identity and one managed binding per assignment. + +Replaying the same verified operation is idempotent and returns `alreadyVerified`. Zero matching correlations return `correlationNotFound`; multiple matches return `correlationAmbiguous`; a desired-state mismatch returns `reconciliationRequired`. Existing binding/native/assignment conflicts fail closed. + +Both `executed_unverified` and `outcome_unknown` expectations can converge through authoritative readback. An `outcome_unknown` result therefore never causes a blind retry: the old CREATE is resolved from evidence first. + +This component performs no Agent dispatch, SuiteBridge call, VDR mutation, provider selection or reassignment. Runtime wiring is deliberately deferred until the surrounding managed fulfillment pipeline has durable dispatch/start/recovery semantics. diff --git a/mk/phase64-native-timer-create-readback-verification-tests.mk b/mk/phase64-native-timer-create-readback-verification-tests.mk new file mode 100644 index 000000000..8b2b03ce7 --- /dev/null +++ b/mk/phase64-native-timer-create-readback-verification-tests.mk @@ -0,0 +1,25 @@ +.PHONY: test-phase64-native-timer-create-readback-verification-architecture test-phase64-native-timer-create-readback-verification + +test-phase64-native-timer-create-readback-verification-architecture: + python3 tools/check_phase64_native_timer_create_readback_verification.py + +test-phase64-native-timer-create-readback-verification: test-phase64-native-timer-create-readback-verification-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/timers/include \ + $(SQLITE_SRC) \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerObservation.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/src/NativeTimerReadbackExpectation.cpp \ + core/timers/src/NativeTimerCreateReadbackExpectation.cpp \ + core/timers/src/NativeTimerCreateReadbackEvidence.cpp \ + core/timers/src/NativeTimerBindingRepository.cpp \ + core/timers/src/NativeTimerBindingReadRepository.cpp \ + core/timers/src/NativeTimerBindingWriteRepository.cpp \ + core/timers/src/NativeTimerCreateReadbackVerificationService.cpp \ + core/timers/tests/test_native_timer_create_readback_verification_service.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_create_readback_verification_service + $(BUILD_DIR)/test_native_timer_create_readback_verification_service + +test-fast: test-phase64-native-timer-create-readback-verification +test-architecture: test-phase64-native-timer-create-readback-verification-architecture diff --git a/tools/check_phase64_native_timer_create_readback_verification.py b/tools/check_phase64_native_timer_create_readback_verification.py new file mode 100755 index 000000000..d2f298a4a --- /dev/null +++ b/tools/check_phase64_native_timer_create_readback_verification.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +paths = { + "header": "core/timers/include/NativeTimerCreateReadbackVerificationService.h", + "source": "core/timers/src/NativeTimerCreateReadbackVerificationService.cpp", + "test": "core/timers/tests/test_native_timer_create_readback_verification_service.cpp", + "doc": "docs/development/phase-64-native-timer-create-readback-verification.md", + "mk": "mk/phase64-native-timer-create-readback-verification-tests.mk", + "makefile": "Makefile", +} + +for relative in paths.values(): + if not (ROOT / relative).is_file(): + raise SystemExit(f"missing Phase-64 CREATE readback verification file: {relative}") + +header = (ROOT / paths["header"]).read_text(encoding="utf-8") +source = (ROOT / paths["source"]).read_text(encoding="utf-8") +test = (ROOT / paths["test"]).read_text(encoding="utf-8") +doc = (ROOT / paths["doc"]).read_text(encoding="utf-8") +mk = (ROOT / paths["mk"]).read_text(encoding="utf-8") +makefile = (ROOT / paths["makefile"]).read_text(encoding="utf-8") + +for token in [ + "NativeTimerCreateReadbackVerificationStatus", + "correlationNotFound", + "correlationAmbiguous", + "reconciliationRequired", + "nativeIdentityConflict", + "assignmentBindingConflict", + "class NativeTimerCreateReadbackVerificationService", +]: + if token not in header: + raise SystemExit(f"missing CREATE verification header marker: {token}") + +for token in [ + "nativeTimerCreateReadbackExpectationValid(expectation)", + "nativeTimerCreateReadbackEvidenceValid(evidence)", + "evidence.backendGeneration != expectation.backendGeneration", + "evidence.observedAt < expectation.readbackNotBefore", + "correlationMatches(candidate, expectation)", + "nativeTimerObservationMatchesSpecification(", + "repository_.findById(expectation.nativeTimerBindingId)", + "binding.ownership = NativeTimerBindingOwnership::managed", + "binding.lastVerifiedOperationId = expectation.operationId", + "repository_.create(binding)", +]: + if token not in source: + raise SystemExit(f"missing CREATE verification source marker: {token}") + +for forbidden in [ + "VdrTimer", "SuiteBridge", "BackendAgentCommand", "SVDRP", "sqlite3_", + "RestfulApi", "TimerAssignmentRepository", +]: + if forbidden in header + source: + raise SystemExit(f"premature CREATE verification boundary crossing: {forbidden}") + +for token in [ + "NativeTimerCreateReadbackVerificationStatus::verified", + "NativeTimerCreateReadbackVerificationStatus::alreadyVerified", + "NativeTimerCreateReadbackVerificationStatus::correlationNotFound", + "NativeTimerCreateReadbackVerificationStatus::correlationAmbiguous", + "NativeTimerCreateReadbackVerificationStatus::reconciliationRequired", + "NativeTimerCreateReadbackVerificationStatus::nativeIdentityConflict", + "NativeTimerCreateReadbackVerificationStatus::assignmentBindingConflict", + "test_native_timer_create_readback_verification_service passed", +]: + if token not in test: + raise SystemExit(f"missing CREATE verification regression marker: {token}") + +for token in [ + "authoritative complete inventory", + "exactly one managed correlation", + "no blind retry", + "NativeTimerBinding", + "outcome_unknown", +]: + if token not in doc: + raise SystemExit(f"missing CREATE verification documentation marker: {token}") + +for token in [ + "test-phase64-native-timer-create-readback-verification-architecture", + "test-phase64-native-timer-create-readback-verification:", + "test-fast: test-phase64-native-timer-create-readback-verification", + "test-architecture: test-phase64-native-timer-create-readback-verification-architecture", +]: + if token not in mk: + raise SystemExit(f"missing CREATE verification make marker: {token}") + +include_line = "include mk/phase64-native-timer-create-readback-verification-tests.mk" +if include_line not in makefile: + raise SystemExit("CREATE verification make fragment is not included") + +for scan_root in [ + ROOT / "apps", ROOT / "api", ROOT / "core" / "agent", + ROOT / "core" / "daemon", ROOT / "core" / "http", + ROOT / "core" / "runtime", ROOT / "core" / "vdr", + ROOT / "vdr-plugin-suite-bridge", +]: + if not scan_root.exists(): + continue + for path in scan_root.rglob("*"): + if not path.is_file() or path.suffix not in { + ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".inc", ".mk" + }: + continue + if "NativeTimerCreateReadbackVerificationService" in path.read_text( + encoding="utf-8", errors="ignore" + ): + raise SystemExit( + "premature CREATE readback runtime wiring: " + + str(path.relative_to(ROOT)) + ) + +print("Phase-64 native Timer CREATE readback verification check passed") From b5084a52daf83ca5d3f82add00c81f9434a10f32 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:34:53 +0200 Subject: [PATCH 05/85] Phase 64: build VDR managed create readback evidence --- Makefile | 1 + ...anagedTimerCreateReadbackEvidenceBuilder.h | 39 +++++ ...agedTimerCreateReadbackEvidenceBuilder.cpp | 119 +++++++++++++++ ...timer_create_readback_evidence_builder.cpp | 143 ++++++++++++++++++ ...-managed-timer-create-readback-evidence.md | 11 ++ ...ed-timer-create-readback-evidence-tests.mk | 21 +++ ..._managed_timer_create_readback_evidence.py | 89 +++++++++++ 7 files changed, 423 insertions(+) create mode 100644 core/vdr/include/VdrManagedTimerCreateReadbackEvidenceBuilder.h create mode 100644 core/vdr/src/VdrManagedTimerCreateReadbackEvidenceBuilder.cpp create mode 100644 core/vdr/tests/test_vdr_managed_timer_create_readback_evidence_builder.cpp create mode 100644 docs/development/phase-64-vdr-managed-timer-create-readback-evidence.md create mode 100644 mk/phase64-vdr-managed-timer-create-readback-evidence-tests.mk create mode 100755 tools/check_phase64_vdr_managed_timer_create_readback_evidence.py diff --git a/Makefile b/Makefile index 962b66442..3cf747442 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ include mk/phase64-vdr-timer-managed-correlation-tests.mk include mk/phase64-native-timer-readback-expectation-tests.mk include mk/phase64-native-timer-create-readback-expectation-tests.mk include mk/phase64-native-timer-create-readback-evidence-tests.mk +include mk/phase64-vdr-managed-timer-create-readback-evidence-tests.mk include mk/phase64-native-timer-create-readback-verification-tests.mk include mk/phase64-native-timer-present-readback-verification-tests.mk include mk/phase64-native-timer-inventory-evidence-tests.mk diff --git a/core/vdr/include/VdrManagedTimerCreateReadbackEvidenceBuilder.h b/core/vdr/include/VdrManagedTimerCreateReadbackEvidenceBuilder.h new file mode 100644 index 000000000..544184815 --- /dev/null +++ b/core/vdr/include/VdrManagedTimerCreateReadbackEvidenceBuilder.h @@ -0,0 +1,39 @@ +#pragma once + +#include "NativeTimerCreateReadbackEvidence.h" +#include "NativeTimerInventoryEvidence.h" +#include "VdrTimer.h" + +#include + +enum class VdrManagedTimerCreateReadbackEvidenceBuildStatus +{ + ok, + invalidInventoryEvidence, + invalidTimerObservation, + duplicateNativeTimerIdentity, + inventoryMismatch, + malformedManagedCorrelation, + conflictingManagedCorrelation, + invalidEvidence, +}; + +struct VdrManagedTimerCreateReadbackEvidenceBuildResult +{ + VdrManagedTimerCreateReadbackEvidenceBuildStatus status = + VdrManagedTimerCreateReadbackEvidenceBuildStatus::invalidEvidence; + vdrsuite::timers::NativeTimerCreateReadbackEvidence evidence; + + bool ok() const + { + return status == VdrManagedTimerCreateReadbackEvidenceBuildStatus::ok; + } +}; + +class VdrManagedTimerCreateReadbackEvidenceBuilder +{ +public: + static VdrManagedTimerCreateReadbackEvidenceBuildResult build( + const vdrsuite::timers::NativeTimerInventoryEvidence& inventory, + const std::vector& timers); +}; diff --git a/core/vdr/src/VdrManagedTimerCreateReadbackEvidenceBuilder.cpp b/core/vdr/src/VdrManagedTimerCreateReadbackEvidenceBuilder.cpp new file mode 100644 index 000000000..25804d751 --- /dev/null +++ b/core/vdr/src/VdrManagedTimerCreateReadbackEvidenceBuilder.cpp @@ -0,0 +1,119 @@ +#include "VdrManagedTimerCreateReadbackEvidenceBuilder.h" + +#include "VdrNativeTimerObservationMapper.h" +#include "VdrTimerManagedCorrelation.h" + +#include +#include +#include +#include +#include + +namespace +{ +VdrManagedTimerCreateReadbackEvidenceBuildResult result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus status) +{ + VdrManagedTimerCreateReadbackEvidenceBuildResult value; + value.status = status; + return value; +} +} + +VdrManagedTimerCreateReadbackEvidenceBuildResult +VdrManagedTimerCreateReadbackEvidenceBuilder::build( + const vdrsuite::timers::NativeTimerInventoryEvidence& inventory, + const std::vector& timers) +{ + using namespace vdrsuite::timers; + + if (!nativeTimerInventoryEvidenceValid(inventory)) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + invalidInventoryEvidence); + } + + if (timers.size() != inventory.backendNativeTimerIds.size()) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus::inventoryMismatch); + } + + NativeTimerCreateReadbackEvidence evidence; + evidence.backendId = inventory.backendId; + evidence.backendGeneration = inventory.backendGeneration; + evidence.observedAt = inventory.observedAt; + evidence.completeness = NativeTimerCreateReadbackCompleteness::complete; + + std::set nativeIds; + + for (const auto& timer : timers) + { + const auto mapped = VdrNativeTimerObservationMapper::map( + inventory.backendId, + inventory.backendGeneration, + inventory.observedAt, + timer); + if (!mapped.ok()) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + invalidTimerObservation); + } + + if (!nativeIds.insert(mapped.observation.backendNativeTimerId).second) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + duplicateNativeTimerIdentity); + } + + const auto correlation = parseVdrTimerManagedCorrelation(timer.aux); + if (correlation.status == VdrTimerManagedCorrelationStatus::absent) + continue; + if (correlation.status == VdrTimerManagedCorrelationStatus::malformedMarker + || correlation.status == VdrTimerManagedCorrelationStatus::invalidCorrelation) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + malformedManagedCorrelation); + } + if (correlation.status == VdrTimerManagedCorrelationStatus::conflictingMarker) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + conflictingManagedCorrelation); + } + if (!correlation.ok()) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + malformedManagedCorrelation); + } + + NativeTimerCreateReadbackCandidate candidate; + candidate.timerAssignmentId = correlation.correlation.timerAssignmentId; + candidate.nativeTimerBindingId = correlation.correlation.nativeTimerBindingId; + candidate.observation = mapped.observation; + evidence.candidates.push_back(std::move(candidate)); + } + + std::vector observedIds(nativeIds.begin(), nativeIds.end()); + if (observedIds != inventory.backendNativeTimerIds) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus::inventoryMismatch); + } + + if (!nativeTimerCreateReadbackEvidenceValid(evidence)) + { + return result( + VdrManagedTimerCreateReadbackEvidenceBuildStatus::invalidEvidence); + } + + VdrManagedTimerCreateReadbackEvidenceBuildResult value; + value.status = VdrManagedTimerCreateReadbackEvidenceBuildStatus::ok; + value.evidence = std::move(evidence); + return value; +} diff --git a/core/vdr/tests/test_vdr_managed_timer_create_readback_evidence_builder.cpp b/core/vdr/tests/test_vdr_managed_timer_create_readback_evidence_builder.cpp new file mode 100644 index 000000000..70f404f3e --- /dev/null +++ b/core/vdr/tests/test_vdr_managed_timer_create_readback_evidence_builder.cpp @@ -0,0 +1,143 @@ +#include "NativeTimerInventoryEvidence.h" +#include "VdrManagedTimerCreateReadbackEvidenceBuilder.h" +#include "VdrTimerManagedCorrelation.h" + +#include +#include +#include +#include + +using namespace vdrsuite::timers; + +namespace +{ +NativeTimerInventoryEvidence inventory(std::vector ids) +{ + NativeTimerInventoryEvidence value; + value.backendId = "backend:1"; + value.backendGeneration = 7; + value.observedAt = 2100; + value.completeness = NativeTimerInventoryCompleteness::complete; + value.backendNativeTimerIds = std::move(ids); + return value; +} + +VdrTimer timer(const std::string& id, const std::string& aux = {}) +{ + VdrTimer value; + value.id = id; + value.channelId = "channel:1"; + value.eventId = "event:1"; + value.title = "Managed Timer"; + value.directory = "VDR-Suite"; + value.aux = aux; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1015"; + value.flags = 1; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.pending = true; + return value; +} + +std::string managedAux( + const std::string& assignmentId, + const std::string& bindingId, + const std::string& prefix = {}) +{ + VdrTimerManagedCorrelation correlation; + correlation.timerAssignmentId = assignmentId; + correlation.nativeTimerBindingId = bindingId; + const auto attached = attachVdrTimerManagedCorrelation(prefix, correlation); + assert(attached.ok()); + return attached.aux; +} +} + +int main() +{ + const auto complete = inventory({"10", "20", "30"}); + const std::string foreign = + "42"; + const std::string aux = managedAux("assignment:1", "binding:1", foreign); + + std::vector timers = { + timer("10"), + timer("20", aux), + timer("30", foreign), + }; + + const auto built = VdrManagedTimerCreateReadbackEvidenceBuilder::build( + complete, timers); + assert(built.ok()); + assert(built.evidence.backendId == "backend:1"); + assert(built.evidence.backendGeneration == 7); + assert(built.evidence.observedAt == 2100); + assert(built.evidence.completeness == + NativeTimerCreateReadbackCompleteness::complete); + assert(built.evidence.candidates.size() == 1); + assert(built.evidence.candidates[0].timerAssignmentId == "assignment:1"); + assert(built.evidence.candidates[0].nativeTimerBindingId == "binding:1"); + assert(built.evidence.candidates[0].observation.backendNativeTimerId == "20"); + assert(built.evidence.candidates[0].observation.observedState.title == + "Managed Timer"); + + const std::string duplicateCorrelation = + managedAux("assignment:dup", "binding:dup"); + const auto ambiguousEvidence = + VdrManagedTimerCreateReadbackEvidenceBuilder::build( + inventory({"40", "41"}), + {timer("40", duplicateCorrelation), timer("41", duplicateCorrelation)}); + assert(ambiguousEvidence.ok()); + assert(ambiguousEvidence.evidence.candidates.size() == 2); + assert(ambiguousEvidence.evidence.candidates[0].timerAssignmentId == + "assignment:dup"); + assert(ambiguousEvidence.evidence.candidates[1].timerAssignmentId == + "assignment:dup"); + + const auto missingTimer = VdrManagedTimerCreateReadbackEvidenceBuilder::build( + complete, {timer("10"), timer("20", aux)}); + assert(missingTimer.status == + VdrManagedTimerCreateReadbackEvidenceBuildStatus::inventoryMismatch); + + const auto duplicateNative = + VdrManagedTimerCreateReadbackEvidenceBuilder::build( + inventory({"10", "20"}), {timer("10"), timer("10")}); + assert(duplicateNative.status == + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + duplicateNativeTimerIdentity); + + auto invalidTimer = timer("20", aux); + invalidTimer.channelId.clear(); + const auto invalidObservation = + VdrManagedTimerCreateReadbackEvidenceBuilder::build( + inventory({"20"}), {invalidTimer}); + assert(invalidObservation.status == + VdrManagedTimerCreateReadbackEvidenceBuildStatus::invalidTimerObservation); + + const auto malformed = VdrManagedTimerCreateReadbackEvidenceBuilder::build( + inventory({"20"}), {timer("20", "")}); + assert(malformed.status == + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + malformedManagedCorrelation); + + const std::string oneMarker = managedAux("assignment:1", "binding:1"); + const auto conflicting = VdrManagedTimerCreateReadbackEvidenceBuilder::build( + inventory({"20"}), {timer("20", oneMarker + oneMarker)}); + assert(conflicting.status == + VdrManagedTimerCreateReadbackEvidenceBuildStatus:: + conflictingManagedCorrelation); + + auto invalidInventory = inventory({"20", "10"}); + const auto invalidInventoryResult = + VdrManagedTimerCreateReadbackEvidenceBuilder::build( + invalidInventory, {timer("10"), timer("20")}); + assert(invalidInventoryResult.status == + VdrManagedTimerCreateReadbackEvidenceBuildStatus::invalidInventoryEvidence); + + std::cout << "test_vdr_managed_timer_create_readback_evidence_builder passed\n"; + return 0; +} diff --git a/docs/development/phase-64-vdr-managed-timer-create-readback-evidence.md b/docs/development/phase-64-vdr-managed-timer-create-readback-evidence.md new file mode 100644 index 000000000..dbbcff3a7 --- /dev/null +++ b/docs/development/phase-64-vdr-managed-timer-create-readback-evidence.md @@ -0,0 +1,11 @@ +# Phase 64 — VDR Managed Timer CREATE Readback Evidence + +This adapter builds backend-neutral managed CREATE readback evidence from VDR Timer objects only when a separate complete native Timer inventory evidence object proves the authoritative native-id set for the same backend generation and observation time. + +The builder cross-checks the exact native-id set before minting `NativeTimerCreateReadbackEvidence`. A shortened, duplicated or otherwise inconsistent VDR Timer vector therefore cannot be treated as a complete readback. + +VDR `aux` remains opaque provider-local data. Only the reserved VDR-Suite managed Timer marker is decoded. Foreign `aux` content is ignored as ownership evidence, malformed or conflicting reserved markers fail closed, and raw `aux` never becomes part of the backend-neutral `NativeTimerBinding` observation model. + +Each correlated timer is normalized through `VdrNativeTimerObservationMapper`. The builder deliberately does not collapse duplicate managed correlations: two distinct native Timer identities carrying the same Suite assignment/binding correlation remain two candidates so the Timer-domain CREATE verifier can classify the result as ambiguous. + +This component performs no HTTP request, Agent transport, SuiteBridge call, persistence or native mutation. It requires complete native Timer inventory evidence supplied by an authoritative reader and only translates/cross-checks VDR provider-local observations. diff --git a/mk/phase64-vdr-managed-timer-create-readback-evidence-tests.mk b/mk/phase64-vdr-managed-timer-create-readback-evidence-tests.mk new file mode 100644 index 000000000..71dc068f0 --- /dev/null +++ b/mk/phase64-vdr-managed-timer-create-readback-evidence-tests.mk @@ -0,0 +1,21 @@ +.PHONY: test-phase64-vdr-managed-timer-create-readback-evidence-architecture test-phase64-vdr-managed-timer-create-readback-evidence + +test-phase64-vdr-managed-timer-create-readback-evidence-architecture: + python3 tools/check_phase64_vdr_managed_timer_create_readback_evidence.py + +test-phase64-vdr-managed-timer-create-readback-evidence: test-phase64-vdr-managed-timer-create-readback-evidence-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/timers/include -Icore/vdr/include \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerObservation.cpp \ + core/timers/src/NativeTimerInventoryEvidence.cpp \ + core/timers/src/NativeTimerCreateReadbackEvidence.cpp \ + core/vdr/src/VdrNativeTimerObservationMapper.cpp \ + core/vdr/src/VdrTimerManagedCorrelation.cpp \ + core/vdr/src/VdrManagedTimerCreateReadbackEvidenceBuilder.cpp \ + core/vdr/tests/test_vdr_managed_timer_create_readback_evidence_builder.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_vdr_managed_timer_create_readback_evidence_builder + $(BUILD_DIR)/test_vdr_managed_timer_create_readback_evidence_builder + +test-fast: test-phase64-vdr-managed-timer-create-readback-evidence +test-architecture: test-phase64-vdr-managed-timer-create-readback-evidence-architecture diff --git a/tools/check_phase64_vdr_managed_timer_create_readback_evidence.py b/tools/check_phase64_vdr_managed_timer_create_readback_evidence.py new file mode 100755 index 000000000..9c9c2f6e2 --- /dev/null +++ b/tools/check_phase64_vdr_managed_timer_create_readback_evidence.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +files = [ + "core/vdr/include/VdrManagedTimerCreateReadbackEvidenceBuilder.h", + "core/vdr/src/VdrManagedTimerCreateReadbackEvidenceBuilder.cpp", + "core/vdr/tests/test_vdr_managed_timer_create_readback_evidence_builder.cpp", + "docs/development/phase-64-vdr-managed-timer-create-readback-evidence.md", + "mk/phase64-vdr-managed-timer-create-readback-evidence-tests.mk", + "Makefile", +] +for relative in files: + if not (ROOT / relative).is_file(): + raise SystemExit(f"missing VDR managed CREATE readback evidence file: {relative}") + +header = (ROOT / files[0]).read_text(encoding="utf-8") +source = (ROOT / files[1]).read_text(encoding="utf-8") +test = (ROOT / files[2]).read_text(encoding="utf-8") +doc = (ROOT / files[3]).read_text(encoding="utf-8") +mk = (ROOT / files[4]).read_text(encoding="utf-8") +makefile = (ROOT / files[5]).read_text(encoding="utf-8") + +for token in [ + "VdrManagedTimerCreateReadbackEvidenceBuildStatus", + "duplicateNativeTimerIdentity", + "inventoryMismatch", + "malformedManagedCorrelation", + "conflictingManagedCorrelation", + "class VdrManagedTimerCreateReadbackEvidenceBuilder", +]: + if token not in header: + raise SystemExit(f"missing VDR managed CREATE evidence header marker: {token}") + +for token in [ + "nativeTimerInventoryEvidenceValid(inventory)", + "VdrNativeTimerObservationMapper::map(", + "parseVdrTimerManagedCorrelation(timer.aux)", + "candidate.timerAssignmentId", + "candidate.nativeTimerBindingId", + "observedIds != inventory.backendNativeTimerIds", + "nativeTimerCreateReadbackEvidenceValid(evidence)", +]: + if token not in source: + raise SystemExit(f"missing VDR managed CREATE evidence source marker: {token}") + +for forbidden in [ + "IHttpClient", "RestfulApi", "SuiteBridge", "BackendAgentCommand", "SVDRP", + "repository_", "MutationOperation", +]: + if forbidden in header + source: + raise SystemExit(f"premature VDR managed CREATE evidence boundary crossing: {forbidden}") + +for token in [ + "ambiguousEvidence.ok()", + "duplicateNativeTimerIdentity", + "inventoryMismatch", + "invalidTimerObservation", + "malformedManagedCorrelation", + "conflictingManagedCorrelation", + "test_vdr_managed_timer_create_readback_evidence_builder passed", +]: + if token not in test: + raise SystemExit(f"missing VDR managed CREATE evidence regression marker: {token}") + +for token in [ + "complete native Timer inventory evidence", + "opaque aux", + "exact native-id set", + "does not collapse duplicate managed correlations", +]: + if token not in doc: + raise SystemExit(f"missing VDR managed CREATE evidence documentation marker: {token}") + +include_line = "include mk/phase64-vdr-managed-timer-create-readback-evidence-tests.mk" +if include_line not in makefile: + raise SystemExit("VDR managed CREATE evidence make fragment is not included") + +for token in [ + "test-phase64-vdr-managed-timer-create-readback-evidence-architecture", + "test-phase64-vdr-managed-timer-create-readback-evidence:", + "test-fast: test-phase64-vdr-managed-timer-create-readback-evidence", + "test-architecture: test-phase64-vdr-managed-timer-create-readback-evidence-architecture", +]: + if token not in mk: + raise SystemExit(f"missing VDR managed CREATE evidence make marker: {token}") + +print("Phase-64 VDR managed Timer CREATE readback evidence check passed") From 7a1f64255380e6eedf7c90932b1bf31f6dcb7f19 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:36:54 +0200 Subject: [PATCH 06/85] Phase 64: build managed VDR timer create request --- Makefile | 1 + .../VdrManagedTimerCreateRequestBuilder.h | 38 ++++++ .../VdrManagedTimerCreateRequestBuilder.cpp | 77 ++++++++++++ ...r_managed_timer_create_request_builder.cpp | 113 ++++++++++++++++++ ...ase-64-vdr-managed-timer-create-request.md | 11 ++ ...-vdr-managed-timer-create-request-tests.mk | 18 +++ ...hase64_vdr_managed_timer_create_request.py | 90 ++++++++++++++ 7 files changed, 348 insertions(+) create mode 100644 core/vdr/include/VdrManagedTimerCreateRequestBuilder.h create mode 100644 core/vdr/src/VdrManagedTimerCreateRequestBuilder.cpp create mode 100644 core/vdr/tests/test_vdr_managed_timer_create_request_builder.cpp create mode 100644 docs/development/phase-64-vdr-managed-timer-create-request.md create mode 100644 mk/phase64-vdr-managed-timer-create-request-tests.mk create mode 100755 tools/check_phase64_vdr_managed_timer_create_request.py diff --git a/Makefile b/Makefile index 3cf747442..e3c2bb10b 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-native-timer-specification-tests.mk include mk/phase64-vdr-timer-managed-correlation-tests.mk +include mk/phase64-vdr-managed-timer-create-request-tests.mk include mk/phase64-native-timer-readback-expectation-tests.mk include mk/phase64-native-timer-create-readback-expectation-tests.mk include mk/phase64-native-timer-create-readback-evidence-tests.mk diff --git a/core/vdr/include/VdrManagedTimerCreateRequestBuilder.h b/core/vdr/include/VdrManagedTimerCreateRequestBuilder.h new file mode 100644 index 000000000..2f2999daa --- /dev/null +++ b/core/vdr/include/VdrManagedTimerCreateRequestBuilder.h @@ -0,0 +1,38 @@ +#pragma once + +#include "NativeTimerSpecification.h" +#include "VdrTimerManagedCorrelation.h" +#include "VdrTimerOperationRequest.h" + +#include + +enum class VdrManagedTimerCreateRequestBuildStatus +{ + ok, + invalidBackendIdentity, + invalidSpecification, + invalidCorrelation, + auxConflict, +}; + +struct VdrManagedTimerCreateRequestBuildResult +{ + VdrManagedTimerCreateRequestBuildStatus status = + VdrManagedTimerCreateRequestBuildStatus::invalidSpecification; + VdrTimerOperationRequest request; + + bool ok() const + { + return status == VdrManagedTimerCreateRequestBuildStatus::ok; + } +}; + +class VdrManagedTimerCreateRequestBuilder +{ +public: + static VdrManagedTimerCreateRequestBuildResult build( + const std::string& backendId, + const vdrsuite::timers::NativeTimerSpecification& specification, + const VdrTimerManagedCorrelation& correlation, + const std::string& baseAux = {}); +}; diff --git a/core/vdr/src/VdrManagedTimerCreateRequestBuilder.cpp b/core/vdr/src/VdrManagedTimerCreateRequestBuilder.cpp new file mode 100644 index 000000000..52100dd38 --- /dev/null +++ b/core/vdr/src/VdrManagedTimerCreateRequestBuilder.cpp @@ -0,0 +1,77 @@ +#include "VdrManagedTimerCreateRequestBuilder.h" + +#include + +namespace +{ +constexpr std::size_t kMaxIdentityLength = 160; + +bool validIdentity(const std::string& value) +{ + return !value.empty() && value.size() <= kMaxIdentityLength; +} + +int hhmmToInt(const std::string& value) +{ + int numeric = 0; + for (char ch : value) + numeric = numeric * 10 + (ch - '0'); + return numeric; +} + +VdrManagedTimerCreateRequestBuildResult result( + VdrManagedTimerCreateRequestBuildStatus status) +{ + VdrManagedTimerCreateRequestBuildResult value; + value.status = status; + return value; +} +} + +VdrManagedTimerCreateRequestBuildResult +VdrManagedTimerCreateRequestBuilder::build( + const std::string& backendId, + const vdrsuite::timers::NativeTimerSpecification& specification, + const VdrTimerManagedCorrelation& correlation, + const std::string& baseAux) +{ + if (!validIdentity(backendId)) + { + return result( + VdrManagedTimerCreateRequestBuildStatus::invalidBackendIdentity); + } + if (!vdrsuite::timers::nativeTimerSpecificationValid(specification)) + { + return result( + VdrManagedTimerCreateRequestBuildStatus::invalidSpecification); + } + if (!vdrTimerManagedCorrelationValid(correlation)) + { + return result( + VdrManagedTimerCreateRequestBuildStatus::invalidCorrelation); + } + + const auto attached = attachVdrTimerManagedCorrelation(baseAux, correlation); + if (!attached.ok()) + { + return result(VdrManagedTimerCreateRequestBuildStatus::auxConflict); + } + + VdrManagedTimerCreateRequestBuildResult value; + value.status = VdrManagedTimerCreateRequestBuildStatus::ok; + auto& request = value.request; + request.backendId = backendId; + request.channelId = specification.channelId; + request.title = specification.title; + request.directory = specification.directory; + request.day = specification.day; + request.weekdays = specification.weekdays; + request.start = hhmmToInt(specification.startTime); + request.stop = hhmmToInt(specification.endTime); + request.priority = specification.priority; + request.lifetime = specification.lifetime; + request.active = specification.enabled; + request.vps = specification.vps; + request.aux = attached.aux; + return value; +} diff --git a/core/vdr/tests/test_vdr_managed_timer_create_request_builder.cpp b/core/vdr/tests/test_vdr_managed_timer_create_request_builder.cpp new file mode 100644 index 000000000..194432259 --- /dev/null +++ b/core/vdr/tests/test_vdr_managed_timer_create_request_builder.cpp @@ -0,0 +1,113 @@ +#include "NativeTimerSpecification.h" +#include "VdrManagedTimerCreateRequestBuilder.h" +#include "VdrTimerManagedCorrelation.h" + +#include +#include + +using namespace vdrsuite::timers; + +namespace +{ +NativeTimerSpecification specification() +{ + NativeTimerSpecification value; + value.channelId = "S19.2E-1-1019-10301"; + value.title = "Managed Timer"; + value.directory = "VDR-Suite"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1015"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +VdrTimerManagedCorrelation correlation() +{ + VdrTimerManagedCorrelation value; + value.timerAssignmentId = "assignment:1"; + value.nativeTimerBindingId = "binding:1"; + return value; +} +} + +int main() +{ + const std::string foreign = + "42"; + + const auto built = VdrManagedTimerCreateRequestBuilder::build( + "backend:1", specification(), correlation(), foreign); + assert(built.ok()); + assert(built.request.timerId.empty()); + assert(built.request.backendId == "backend:1"); + assert(built.request.channelId == "S19.2E-1-1019-10301"); + assert(built.request.title == "Managed Timer"); + assert(built.request.directory == "VDR-Suite"); + assert(built.request.day == "2026-08-17"); + assert(built.request.weekdays == "-------"); + assert(built.request.start == 930); + assert(built.request.stop == 1015); + assert(built.request.priority == 50); + assert(built.request.lifetime == 99); + assert(built.request.active); + assert(!built.request.vps); + assert(built.request.aux.rfind(foreign, 0) == 0); + + const auto parsed = parseVdrTimerManagedCorrelation(built.request.aux); + assert(parsed.ok()); + assert(parsed.correlation.timerAssignmentId == "assignment:1"); + assert(parsed.correlation.nativeTimerBindingId == "binding:1"); + + const auto replay = VdrManagedTimerCreateRequestBuilder::build( + "backend:1", specification(), correlation(), built.request.aux); + assert(replay.ok()); + assert(replay.request.aux == built.request.aux); + + auto midnight = specification(); + midnight.startTime = "0000"; + const auto midnightBuilt = VdrManagedTimerCreateRequestBuilder::build( + "backend:1", midnight, correlation()); + assert(midnightBuilt.ok()); + assert(midnightBuilt.request.start == 0); + + auto disabled = specification(); + disabled.enabled = false; + disabled.vps = true; + const auto disabledBuilt = VdrManagedTimerCreateRequestBuilder::build( + "backend:1", disabled, correlation()); + assert(disabledBuilt.ok()); + assert(!disabledBuilt.request.active); + assert(disabledBuilt.request.vps); + + assert(VdrManagedTimerCreateRequestBuilder::build( + "", specification(), correlation()).status == + VdrManagedTimerCreateRequestBuildStatus::invalidBackendIdentity); + + auto invalidSpec = specification(); + invalidSpec.startTime = "2460"; + assert(VdrManagedTimerCreateRequestBuilder::build( + "backend:1", invalidSpec, correlation()).status == + VdrManagedTimerCreateRequestBuildStatus::invalidSpecification); + + auto invalidCorrelation = correlation(); + invalidCorrelation.nativeTimerBindingId.clear(); + assert(VdrManagedTimerCreateRequestBuilder::build( + "backend:1", specification(), invalidCorrelation).status == + VdrManagedTimerCreateRequestBuildStatus::invalidCorrelation); + + VdrTimerManagedCorrelation other = correlation(); + other.nativeTimerBindingId = "binding:other"; + const auto existing = attachVdrTimerManagedCorrelation({}, other); + assert(existing.ok()); + assert(VdrManagedTimerCreateRequestBuilder::build( + "backend:1", specification(), correlation(), existing.aux).status == + VdrManagedTimerCreateRequestBuildStatus::auxConflict); + + std::cout << "test_vdr_managed_timer_create_request_builder passed\n"; + return 0; +} diff --git a/docs/development/phase-64-vdr-managed-timer-create-request.md b/docs/development/phase-64-vdr-managed-timer-create-request.md new file mode 100644 index 000000000..7ec2a088a --- /dev/null +++ b/docs/development/phase-64-vdr-managed-timer-create-request.md @@ -0,0 +1,11 @@ +# Phase 64 — VDR Managed Timer CREATE Request + +This adapter converts the backend-neutral `NativeTimerSpecification` plus a pre-reserved VDR-Suite managed Timer correlation into one provider-local `VdrTimerOperationRequest` for CREATE. + +The cross-backend specification remains free of native IDs and raw plugin data. The VDR adapter adds the stable `timerAssignmentId` / `nativeTimerBindingId` marker only in provider-local aux. Existing provider-local aux is preserved byte-for-byte before the marker, and a malformed or conflicting reserved marker fails closed. + +HHMM values are validated by `NativeTimerSpecification` before conversion to the integer form expected by the existing VDR operation primitive. Midnight (`0000`) is valid and maps to integer `0`; the old `VdrTimerOperationRequest::hasTimeWindow()` convenience helper is not used as an authority gate. + +The builder maps only managed write fields: channel, title/directory, day/weekdays, start/stop, priority/lifetime, enabled and VPS. It leaves the native timer ID empty because native identity does not exist before CREATE readback. + +This component does not dispatch, persist, select a provider, call SuiteBridge or mutate VDR. Durable starting, generation/provider fencing and exactly-one native invocation belong to the later managed execution path. diff --git a/mk/phase64-vdr-managed-timer-create-request-tests.mk b/mk/phase64-vdr-managed-timer-create-request-tests.mk new file mode 100644 index 000000000..1918f8d9d --- /dev/null +++ b/mk/phase64-vdr-managed-timer-create-request-tests.mk @@ -0,0 +1,18 @@ +.PHONY: test-phase64-vdr-managed-timer-create-request-architecture test-phase64-vdr-managed-timer-create-request + +test-phase64-vdr-managed-timer-create-request-architecture: + python3 tools/check_phase64_vdr_managed_timer_create_request.py + +test-phase64-vdr-managed-timer-create-request: test-phase64-vdr-managed-timer-create-request-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/timers/include -Icore/vdr/include \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/vdr/src/VdrTimerManagedCorrelation.cpp \ + core/vdr/src/VdrManagedTimerCreateRequestBuilder.cpp \ + core/vdr/tests/test_vdr_managed_timer_create_request_builder.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_vdr_managed_timer_create_request_builder + $(BUILD_DIR)/test_vdr_managed_timer_create_request_builder + +test-fast: test-phase64-vdr-managed-timer-create-request +test-architecture: test-phase64-vdr-managed-timer-create-request-architecture diff --git a/tools/check_phase64_vdr_managed_timer_create_request.py b/tools/check_phase64_vdr_managed_timer_create_request.py new file mode 100755 index 000000000..b924aa7fb --- /dev/null +++ b/tools/check_phase64_vdr_managed_timer_create_request.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +files = [ + "core/vdr/include/VdrManagedTimerCreateRequestBuilder.h", + "core/vdr/src/VdrManagedTimerCreateRequestBuilder.cpp", + "core/vdr/tests/test_vdr_managed_timer_create_request_builder.cpp", + "docs/development/phase-64-vdr-managed-timer-create-request.md", + "mk/phase64-vdr-managed-timer-create-request-tests.mk", + "Makefile", +] +for relative in files: + if not (ROOT / relative).is_file(): + raise SystemExit(f"missing VDR managed CREATE request file: {relative}") + +header = (ROOT / files[0]).read_text(encoding="utf-8") +source = (ROOT / files[1]).read_text(encoding="utf-8") +test = (ROOT / files[2]).read_text(encoding="utf-8") +doc = (ROOT / files[3]).read_text(encoding="utf-8") +mk = (ROOT / files[4]).read_text(encoding="utf-8") +makefile = (ROOT / files[5]).read_text(encoding="utf-8") + +for token in [ + "VdrManagedTimerCreateRequestBuildStatus", + "invalidBackendIdentity", + "invalidSpecification", + "invalidCorrelation", + "auxConflict", + "class VdrManagedTimerCreateRequestBuilder", +]: + if token not in header: + raise SystemExit(f"missing VDR managed CREATE request header marker: {token}") + +for token in [ + "nativeTimerSpecificationValid(specification)", + "vdrTimerManagedCorrelationValid(correlation)", + "attachVdrTimerManagedCorrelation(baseAux, correlation)", + "request.backendId = backendId", + "request.channelId = specification.channelId", + "request.start = hhmmToInt(specification.startTime)", + "request.active = specification.enabled", + "request.aux = attached.aux", +]: + if token not in source: + raise SystemExit(f"missing VDR managed CREATE request source marker: {token}") + +for forbidden in [ + "IHttpClient", "RestfulApi", "SuiteBridge", "BackendAgentCommand", "SVDRP", + "repository_", "MutationOperation", +]: + if forbidden in header + source: + raise SystemExit(f"premature VDR managed CREATE request boundary crossing: {forbidden}") + +for token in [ + "replay.request.aux == built.request.aux", + "midnightBuilt.request.start == 0", + "invalidBackendIdentity", + "invalidSpecification", + "invalidCorrelation", + "auxConflict", + "test_vdr_managed_timer_create_request_builder passed", +]: + if token not in test: + raise SystemExit(f"missing VDR managed CREATE request regression marker: {token}") + +for token in [ + "NativeTimerSpecification", + "provider-local aux", + "midnight", + "does not dispatch", +]: + if token not in doc: + raise SystemExit(f"missing VDR managed CREATE request documentation marker: {token}") + +include_line = "include mk/phase64-vdr-managed-timer-create-request-tests.mk" +if include_line not in makefile: + raise SystemExit("VDR managed CREATE request make fragment is not included") + +for token in [ + "test-phase64-vdr-managed-timer-create-request-architecture", + "test-phase64-vdr-managed-timer-create-request:", + "test-fast: test-phase64-vdr-managed-timer-create-request", + "test-architecture: test-phase64-vdr-managed-timer-create-request-architecture", +]: + if token not in mk: + raise SystemExit(f"missing VDR managed CREATE request make marker: {token}") + +print("Phase-64 VDR managed Timer CREATE request check passed") From d510f824a7e340622e72c06bddd7cb289e2f1207 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:40:22 +0200 Subject: [PATCH 07/85] Fix native timer specification boolean fingerprint encoding --- core/timers/src/NativeTimerSpecification.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/timers/src/NativeTimerSpecification.cpp b/core/timers/src/NativeTimerSpecification.cpp index 11edf8078..fbc6b86cb 100644 --- a/core/timers/src/NativeTimerSpecification.cpp +++ b/core/timers/src/NativeTimerSpecification.cpp @@ -65,7 +65,7 @@ void append(std::string& output, std::int32_t value) void append(std::string& output, bool value) { - append(output, value ? "1" : "0"); + append(output, std::string(value ? "1" : "0")); } } From 35ca8437707d773fb6988e326e1885e5c4133fb5 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:40:30 +0200 Subject: [PATCH 08/85] Fix managed VDR create request architecture marker --- docs/development/phase-64-vdr-managed-timer-create-request.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/phase-64-vdr-managed-timer-create-request.md b/docs/development/phase-64-vdr-managed-timer-create-request.md index 7ec2a088a..e527642e2 100644 --- a/docs/development/phase-64-vdr-managed-timer-create-request.md +++ b/docs/development/phase-64-vdr-managed-timer-create-request.md @@ -4,7 +4,7 @@ This adapter converts the backend-neutral `NativeTimerSpecification` plus a pre- The cross-backend specification remains free of native IDs and raw plugin data. The VDR adapter adds the stable `timerAssignmentId` / `nativeTimerBindingId` marker only in provider-local aux. Existing provider-local aux is preserved byte-for-byte before the marker, and a malformed or conflicting reserved marker fails closed. -HHMM values are validated by `NativeTimerSpecification` before conversion to the integer form expected by the existing VDR operation primitive. Midnight (`0000`) is valid and maps to integer `0`; the old `VdrTimerOperationRequest::hasTimeWindow()` convenience helper is not used as an authority gate. +HHMM values are validated by `NativeTimerSpecification` before conversion to the integer form expected by the existing VDR operation primitive. The midnight value (`0000`) is valid and maps to integer `0`; the old `VdrTimerOperationRequest::hasTimeWindow()` convenience helper is not used as an authority gate. The builder maps only managed write fields: channel, title/directory, day/weekdays, start/stop, priority/lifetime, enabled and VPS. It leaves the native timer ID empty because native identity does not exist before CREATE readback. From 6e648b1f3f1818e203a390d394914d83576c1629 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:43:42 +0200 Subject: [PATCH 09/85] Phase 64: fence timer assignment fulfillment state --- Makefile | 1 + .../TimerAssignmentFulfillmentService.h | 80 ++++++ .../src/TimerAssignmentFulfillmentService.cpp | 271 ++++++++++++++++++ ...t_timer_assignment_fulfillment_service.cpp | 191 ++++++++++++ .../phase-64-timer-assignment-fulfillment.md | 15 + ...se64-timer-assignment-fulfillment-tests.mk | 24 ++ ...ck_phase64_timer_assignment_fulfillment.py | 87 ++++++ 7 files changed, 669 insertions(+) create mode 100644 core/timers/include/TimerAssignmentFulfillmentService.h create mode 100644 core/timers/src/TimerAssignmentFulfillmentService.cpp create mode 100644 core/timers/tests/test_timer_assignment_fulfillment_service.cpp create mode 100644 docs/development/phase-64-timer-assignment-fulfillment.md create mode 100644 mk/phase64-timer-assignment-fulfillment-tests.mk create mode 100755 tools/check_phase64_timer_assignment_fulfillment.py diff --git a/Makefile b/Makefile index e3c2bb10b..46d8e3c09 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ include mk/phase63-runtime-acceptance.mk include mk/agent-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk +include mk/phase64-timer-assignment-fulfillment-tests.mk include mk/phase64-native-timer-specification-tests.mk include mk/phase64-vdr-timer-managed-correlation-tests.mk include mk/phase64-vdr-managed-timer-create-request-tests.mk diff --git a/core/timers/include/TimerAssignmentFulfillmentService.h b/core/timers/include/TimerAssignmentFulfillmentService.h new file mode 100644 index 000000000..bab528a39 --- /dev/null +++ b/core/timers/include/TimerAssignmentFulfillmentService.h @@ -0,0 +1,80 @@ +#pragma once + +#include "NativeTimerBinding.h" +#include "TimerAssignment.h" + +#include +#include + +namespace vdrsuite::timers +{ + +class NativeTimerBindingRepository; +class TimerAssignmentRepository; + +enum class TimerAssignmentFulfillmentStatus +{ + provisioningStarted, + alreadyProvisioning, + bound, + alreadyBound, + assignmentNotFound, + bindingNotFound, + assignmentRevisionConflict, + bindingRevisionConflict, + intentRevisionConflict, + stateConflict, + identityConflict, + generationConflict, + ownershipConflict, + bindingStateConflict, + repositoryConflict, + repositoryError, + invalid, +}; + +struct TimerAssignmentFulfillmentResult +{ + TimerAssignmentFulfillmentStatus status = + TimerAssignmentFulfillmentStatus::repositoryError; + TimerAssignment assignment; + NativeTimerBinding binding; + + bool ok() const + { + return status == TimerAssignmentFulfillmentStatus::provisioningStarted + || status == TimerAssignmentFulfillmentStatus::alreadyProvisioning + || status == TimerAssignmentFulfillmentStatus::bound + || status == TimerAssignmentFulfillmentStatus::alreadyBound; + } +}; + +class TimerAssignmentFulfillmentService +{ +public: + TimerAssignmentFulfillmentService( + TimerAssignmentRepository& assignmentRepository, + NativeTimerBindingRepository& bindingRepository); + + TimerAssignmentFulfillmentResult beginProvisioning( + const std::string& timerAssignmentId, + const std::string& expectedAssignmentRevision, + const std::string& expectedIntentRevision, + std::uint64_t expectedBackendGeneration, + std::int64_t updatedAt); + + TimerAssignmentFulfillmentResult bindVerified( + const std::string& timerAssignmentId, + const std::string& expectedAssignmentRevision, + const std::string& expectedIntentRevision, + std::uint64_t expectedBackendGeneration, + const std::string& nativeTimerBindingId, + const std::string& expectedBindingRevision, + std::int64_t updatedAt); + +private: + TimerAssignmentRepository& assignmentRepository_; + NativeTimerBindingRepository& bindingRepository_; +}; + +} diff --git a/core/timers/src/TimerAssignmentFulfillmentService.cpp b/core/timers/src/TimerAssignmentFulfillmentService.cpp new file mode 100644 index 000000000..9ff36e837 --- /dev/null +++ b/core/timers/src/TimerAssignmentFulfillmentService.cpp @@ -0,0 +1,271 @@ +#include "TimerAssignmentFulfillmentService.h" + +#include "NativeTimerBindingRepository.h" +#include "TimerAssignmentRepository.h" + +namespace vdrsuite::timers +{ +namespace +{ +TimerAssignmentFulfillmentResult result( + TimerAssignmentFulfillmentStatus status, + const TimerAssignment& assignment = {}, + const NativeTimerBinding& binding = {}) +{ + TimerAssignmentFulfillmentResult value; + value.status = status; + value.assignment = assignment; + value.binding = binding; + return value; +} + +bool validCommon( + const std::string& timerAssignmentId, + const std::string& expectedAssignmentRevision, + const std::string& expectedIntentRevision, + std::uint64_t expectedBackendGeneration, + std::int64_t updatedAt) +{ + return !timerAssignmentId.empty() + && !expectedAssignmentRevision.empty() + && !expectedIntentRevision.empty() + && expectedBackendGeneration > 0 + && updatedAt > 0; +} + +TimerAssignmentFulfillmentStatus assignmentRepositoryFailure( + TimerAssignmentRepositoryStatus status) +{ + switch (status) + { + case TimerAssignmentRepositoryStatus::conflict: + return TimerAssignmentFulfillmentStatus::repositoryConflict; + case TimerAssignmentRepositoryStatus::intentRevisionConflict: + return TimerAssignmentFulfillmentStatus::intentRevisionConflict; + case TimerAssignmentRepositoryStatus::ownershipConflict: + return TimerAssignmentFulfillmentStatus::ownershipConflict; + case TimerAssignmentRepositoryStatus::invalid: + return TimerAssignmentFulfillmentStatus::invalid; + default: + return TimerAssignmentFulfillmentStatus::repositoryError; + } +} + +bool bindingVerifiedForAssignment( + const NativeTimerBinding& binding, + const TimerAssignment& assignment) +{ + return binding.timerAssignmentId == assignment.timerAssignmentId + && binding.backendId == assignment.backendId + && binding.backendGeneration == assignment.backendGeneration + && binding.ownership == NativeTimerBindingOwnership::managed + && !binding.lastVerifiedOperationId.empty() + && binding.missingSince == 0 + && binding.driftState == NativeTimerBindingDriftState::none; +} +} + +TimerAssignmentFulfillmentService::TimerAssignmentFulfillmentService( + TimerAssignmentRepository& assignmentRepository, + NativeTimerBindingRepository& bindingRepository) + : assignmentRepository_(assignmentRepository), + bindingRepository_(bindingRepository) +{ +} + +TimerAssignmentFulfillmentResult +TimerAssignmentFulfillmentService::beginProvisioning( + const std::string& timerAssignmentId, + const std::string& expectedAssignmentRevision, + const std::string& expectedIntentRevision, + std::uint64_t expectedBackendGeneration, + std::int64_t updatedAt) +{ + if (!validCommon( + timerAssignmentId, + expectedAssignmentRevision, + expectedIntentRevision, + expectedBackendGeneration, + updatedAt)) + { + return result(TimerAssignmentFulfillmentStatus::invalid); + } + + const auto found = assignmentRepository_.findById(timerAssignmentId); + if (found.status == TimerAssignmentRepositoryStatus::notFound) + return result(TimerAssignmentFulfillmentStatus::assignmentNotFound); + if (!found.ok()) + return result(TimerAssignmentFulfillmentStatus::repositoryError); + + const TimerAssignment& current = found.assignment; + if (current.intentRevision != expectedIntentRevision) + return result( + TimerAssignmentFulfillmentStatus::intentRevisionConflict, + current); + if (current.backendGeneration != expectedBackendGeneration) + return result( + TimerAssignmentFulfillmentStatus::generationConflict, + current); + if (current.assignmentRevision != expectedAssignmentRevision) + return result( + TimerAssignmentFulfillmentStatus::assignmentRevisionConflict, + current); + if (current.state == TimerAssignmentState::provisioning) + return result( + TimerAssignmentFulfillmentStatus::alreadyProvisioning, + current); + if (current.state != TimerAssignmentState::selected) + return result( + TimerAssignmentFulfillmentStatus::stateConflict, + current); + if (updatedAt <= current.updatedAt) + return result(TimerAssignmentFulfillmentStatus::invalid, current); + + TimerAssignment next = current; + next.state = TimerAssignmentState::provisioning; + next.updatedAt = updatedAt; + const auto updated = assignmentRepository_.update( + next, + current.assignmentRevision); + if (updated.ok()) + return result( + TimerAssignmentFulfillmentStatus::provisioningStarted, + updated.assignment); + + return result( + assignmentRepositoryFailure(updated.status), + updated.assignment); +} + +TimerAssignmentFulfillmentResult +TimerAssignmentFulfillmentService::bindVerified( + const std::string& timerAssignmentId, + const std::string& expectedAssignmentRevision, + const std::string& expectedIntentRevision, + std::uint64_t expectedBackendGeneration, + const std::string& nativeTimerBindingId, + const std::string& expectedBindingRevision, + std::int64_t updatedAt) +{ + if (!validCommon( + timerAssignmentId, + expectedAssignmentRevision, + expectedIntentRevision, + expectedBackendGeneration, + updatedAt) + || nativeTimerBindingId.empty() + || expectedBindingRevision.empty()) + { + return result(TimerAssignmentFulfillmentStatus::invalid); + } + + const auto foundAssignment = + assignmentRepository_.findById(timerAssignmentId); + if (foundAssignment.status == TimerAssignmentRepositoryStatus::notFound) + return result(TimerAssignmentFulfillmentStatus::assignmentNotFound); + if (!foundAssignment.ok()) + return result(TimerAssignmentFulfillmentStatus::repositoryError); + + const TimerAssignment& assignment = foundAssignment.assignment; + if (assignment.intentRevision != expectedIntentRevision) + return result( + TimerAssignmentFulfillmentStatus::intentRevisionConflict, + assignment); + if (assignment.backendGeneration != expectedBackendGeneration) + return result( + TimerAssignmentFulfillmentStatus::generationConflict, + assignment); + + const auto foundBinding = + bindingRepository_.findById(nativeTimerBindingId); + if (foundBinding.status == NativeTimerBindingRepositoryStatus::notFound) + return result( + TimerAssignmentFulfillmentStatus::bindingNotFound, + assignment); + if (!foundBinding.ok()) + return result( + TimerAssignmentFulfillmentStatus::repositoryError, + assignment); + + const NativeTimerBinding& binding = foundBinding.binding; + if (binding.bindingRevision != expectedBindingRevision) + return result( + TimerAssignmentFulfillmentStatus::bindingRevisionConflict, + assignment, + binding); + if (binding.timerAssignmentId != assignment.timerAssignmentId + || binding.backendId != assignment.backendId) + { + return result( + TimerAssignmentFulfillmentStatus::identityConflict, + assignment, + binding); + } + if (binding.backendGeneration != expectedBackendGeneration) + return result( + TimerAssignmentFulfillmentStatus::generationConflict, + assignment, + binding); + if (binding.ownership != NativeTimerBindingOwnership::managed) + return result( + TimerAssignmentFulfillmentStatus::ownershipConflict, + assignment, + binding); + if (!bindingVerifiedForAssignment(binding, assignment)) + return result( + TimerAssignmentFulfillmentStatus::bindingStateConflict, + assignment, + binding); + + if (assignment.state == TimerAssignmentState::bound) + { + if (assignment.nativeTimerBindingId == nativeTimerBindingId) + return result( + TimerAssignmentFulfillmentStatus::alreadyBound, + assignment, + binding); + return result( + TimerAssignmentFulfillmentStatus::identityConflict, + assignment, + binding); + } + + if (assignment.assignmentRevision != expectedAssignmentRevision) + return result( + TimerAssignmentFulfillmentStatus::assignmentRevisionConflict, + assignment, + binding); + if (assignment.state != TimerAssignmentState::provisioning + && assignment.state != TimerAssignmentState::reconciling) + { + return result( + TimerAssignmentFulfillmentStatus::stateConflict, + assignment, + binding); + } + if (updatedAt <= assignment.updatedAt) + return result( + TimerAssignmentFulfillmentStatus::invalid, + assignment, + binding); + + TimerAssignment next = assignment; + next.state = TimerAssignmentState::bound; + next.nativeTimerBindingId = nativeTimerBindingId; + next.updatedAt = updatedAt; + const auto updated = assignmentRepository_.update( + next, + assignment.assignmentRevision); + if (updated.ok()) + return result( + TimerAssignmentFulfillmentStatus::bound, + updated.assignment, + binding); + + return result( + assignmentRepositoryFailure(updated.status), + updated.assignment, + binding); +} + +} diff --git a/core/timers/tests/test_timer_assignment_fulfillment_service.cpp b/core/timers/tests/test_timer_assignment_fulfillment_service.cpp new file mode 100644 index 000000000..27be1cfab --- /dev/null +++ b/core/timers/tests/test_timer_assignment_fulfillment_service.cpp @@ -0,0 +1,191 @@ +#include "Database.h" +#include "NativeTimerBindingRepository.h" +#include "TimerAssignmentFulfillmentService.h" +#include "TimerAssignmentRepository.h" +#include "TimerIntentRepository.h" + +#include +#include + +using namespace vdrsuite::timers; + +namespace +{ +TimerIntent activeIntent(TimerIntentRepository& repository) +{ + TimerIntent intent; + intent.timerIntentId = "intent:1"; + intent.state = TimerIntentState::draft; + intent.createdByActorId = "actor:create"; + intent.spec.intentType = TimerIntentType::manualWindow; + intent.spec.ownerActorId = "actor:owner"; + intent.spec.channelRequirement.canonicalChannelId = "channel:ard"; + intent.spec.schedule.startAt = 1000; + intent.spec.schedule.stopAt = 2000; + intent.spec.schedule.timezone = "Europe/Berlin"; + intent.createdAt = 100; + intent.updatedAt = 100; + intent.expiresAt = 3000; + const auto created = repository.create(intent); + assert(created.ok()); + TimerIntent active = created.intent; + active.state = TimerIntentState::active; + active.updatedAt = 101; + const auto updated = repository.update(active, created.intent.intentRevision); + assert(updated.ok()); + return updated.intent; +} + +TimerAssignment selectedAssignment( + TimerAssignmentRepository& repository, + const TimerIntent& intent) +{ + TimerAssignment assignment; + assignment.timerAssignmentId = "assignment:1"; + assignment.timerIntentId = intent.timerIntentId; + assignment.intentRevision = intent.intentRevision; + assignment.backendId = "backend:1"; + assignment.backendGeneration = 7; + assignment.state = TimerAssignmentState::selected; + assignment.role = TimerAssignmentRole::primary; + assignment.channelBinding.canonicalChannelId = "channel:ard"; + assignment.channelBinding.backendChannelId = "S19.2E-1-1019-10301"; + assignment.channelBinding.mappingSource = "canonical-channel-map"; + assignment.channelBinding.mappingRevision = "mapping:7"; + assignment.capabilityRevision = "capability:7"; + assignment.backendHealthRevision = "health:7"; + assignment.decisionPolicyVersion = "policy:1"; + assignment.decisionEvidence.reasons = {"eligible_backend"}; + assignment.createdAt = 200; + assignment.updatedAt = 200; + const auto created = repository.create(assignment); + assert(created.ok()); + return created.assignment; +} + +NativeTimerBinding verifiedBinding( + NativeTimerBindingRepository& repository, + const TimerAssignment& assignment, + const std::string& bindingId = "binding:1") +{ + NativeTimerBinding binding; + binding.nativeTimerBindingId = bindingId; + binding.backendId = assignment.backendId; + binding.backendGeneration = assignment.backendGeneration; + binding.backendNativeTimerId = "timer:17"; + binding.timerAssignmentId = assignment.timerAssignmentId; + binding.ownership = NativeTimerBindingOwnership::managed; + binding.observedState.channelId = assignment.channelBinding.backendChannelId; + binding.observedState.title = "Managed Timer"; + binding.observedState.day = "2026-08-17"; + binding.observedState.weekdays = "-------"; + binding.observedState.startTime = "0930"; + binding.observedState.endTime = "1015"; + binding.observedFingerprint = + nativeTimerObservedStateFingerprint(binding.observedState); + binding.lastObservedAt = 210; + binding.lastVerifiedOperationId = "operation:1"; + const auto created = repository.create(binding); + assert(created.ok()); + return created.binding; +} +} + +int main() +{ + Database database; + assert(database.open(":memory:")); + TimerIntentRepository intentRepository(database); + TimerAssignmentRepository assignmentRepository(database); + NativeTimerBindingRepository bindingRepository(database); + assert(intentRepository.ensureSchema()); + assert(assignmentRepository.ensureSchema()); + assert(bindingRepository.ensureSchema()); + + const TimerIntent intent = activeIntent(intentRepository); + const TimerAssignment selected = selectedAssignment(assignmentRepository, intent); + TimerAssignmentFulfillmentService service( + assignmentRepository, bindingRepository); + + const auto started = service.beginProvisioning( + selected.timerAssignmentId, + selected.assignmentRevision, + intent.intentRevision, + 7, + 201); + assert(started.status == + TimerAssignmentFulfillmentStatus::provisioningStarted); + assert(started.assignment.state == TimerAssignmentState::provisioning); + assert(started.assignment.assignmentRevision == "2"); + assert(started.assignment.nativeTimerBindingId.empty()); + + const auto alreadyStarting = service.beginProvisioning( + selected.timerAssignmentId, + started.assignment.assignmentRevision, + intent.intentRevision, + 7, + 202); + assert(alreadyStarting.status == + TimerAssignmentFulfillmentStatus::alreadyProvisioning); + + const auto staleStart = service.beginProvisioning( + selected.timerAssignmentId, + selected.assignmentRevision, + intent.intentRevision, + 7, + 202); + assert(staleStart.status == + TimerAssignmentFulfillmentStatus::assignmentRevisionConflict); + + const NativeTimerBinding binding = verifiedBinding( + bindingRepository, started.assignment); + + const auto bound = service.bindVerified( + selected.timerAssignmentId, + started.assignment.assignmentRevision, + intent.intentRevision, + 7, + binding.nativeTimerBindingId, + binding.bindingRevision, + 211); + assert(bound.status == TimerAssignmentFulfillmentStatus::bound); + assert(bound.assignment.state == TimerAssignmentState::bound); + assert(bound.assignment.assignmentRevision == "3"); + assert(bound.assignment.nativeTimerBindingId == "binding:1"); + + const auto replay = service.bindVerified( + selected.timerAssignmentId, + started.assignment.assignmentRevision, + intent.intentRevision, + 7, + binding.nativeTimerBindingId, + binding.bindingRevision, + 212); + assert(replay.status == TimerAssignmentFulfillmentStatus::alreadyBound); + assert(replay.assignment.assignmentRevision == "3"); + + const auto wrongGeneration = service.bindVerified( + selected.timerAssignmentId, + bound.assignment.assignmentRevision, + intent.intentRevision, + 8, + binding.nativeTimerBindingId, + binding.bindingRevision, + 213); + assert(wrongGeneration.status == + TimerAssignmentFulfillmentStatus::generationConflict); + + const auto missingBinding = service.bindVerified( + selected.timerAssignmentId, + bound.assignment.assignmentRevision, + intent.intentRevision, + 7, + "binding:missing", + "1", + 213); + assert(missingBinding.status == + TimerAssignmentFulfillmentStatus::bindingNotFound); + + std::cout << "test_timer_assignment_fulfillment_service passed\n"; + return 0; +} diff --git a/docs/development/phase-64-timer-assignment-fulfillment.md b/docs/development/phase-64-timer-assignment-fulfillment.md new file mode 100644 index 000000000..07967767b --- /dev/null +++ b/docs/development/phase-64-timer-assignment-fulfillment.md @@ -0,0 +1,15 @@ +# Phase 64 — TimerAssignment Fulfillment State + +This service owns the narrow Control-Plane transition from a deterministic durable assignment into native fulfillment. + +`selected -> provisioning` is revision-fenced against the exact assignment revision, intent revision and backend generation selected by the scheduler. Entering `provisioning` happens before native CREATE dispatch work is allowed to begin. + +`provisioning -> bound` (and reconciliation convergence from `reconciling -> bound`) requires an already persisted, verified managed NativeTimerBinding. The binding must belong to the exact assignment and backend generation, have a matching binding revision, contain a verified operation reference, and have neither missing evidence nor drift. + +The bound transition copies only the stable `nativeTimerBindingId` onto the assignment. It does not manufacture a native identity, verify backend state itself or treat transport reachability as authority. + +Both transitions are repository revision-fenced. A stale assignment/binding revision, stale intent revision or generation mismatch fails closed. Replaying an already bound assignment with the same verified binding is idempotent. + +An `outcome_unknown` native CREATE is not bound merely because dispatch may have happened. Its operation must first converge through authoritative CREATE readback; only the verified managed binding produced by that readback can complete the assignment. + +This service performs no provider selection, Agent dispatch, SuiteBridge call, VDR write, reassignment or failover. diff --git a/mk/phase64-timer-assignment-fulfillment-tests.mk b/mk/phase64-timer-assignment-fulfillment-tests.mk new file mode 100644 index 000000000..4cacfe819 --- /dev/null +++ b/mk/phase64-timer-assignment-fulfillment-tests.mk @@ -0,0 +1,24 @@ +.PHONY: test-phase64-timer-assignment-fulfillment-architecture test-phase64-timer-assignment-fulfillment + +test-phase64-timer-assignment-fulfillment-architecture: + python3 tools/check_phase64_timer_assignment_fulfillment.py + +test-phase64-timer-assignment-fulfillment: test-phase64-timer-assignment-fulfillment-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/timers/include \ + $(SQLITE_SRC) \ + core/timers/src/TimerIntent.cpp \ + core/timers/src/TimerIntentRepository.cpp \ + core/timers/src/TimerAssignment.cpp \ + core/timers/src/TimerAssignmentRepository.cpp \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerBindingRepository.cpp \ + core/timers/src/NativeTimerBindingReadRepository.cpp \ + core/timers/src/NativeTimerBindingWriteRepository.cpp \ + core/timers/src/TimerAssignmentFulfillmentService.cpp \ + core/timers/tests/test_timer_assignment_fulfillment_service.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_timer_assignment_fulfillment_service + $(BUILD_DIR)/test_timer_assignment_fulfillment_service + +test-fast: test-phase64-timer-assignment-fulfillment +test-architecture: test-phase64-timer-assignment-fulfillment-architecture diff --git a/tools/check_phase64_timer_assignment_fulfillment.py b/tools/check_phase64_timer_assignment_fulfillment.py new file mode 100755 index 000000000..cafd6d9b9 --- /dev/null +++ b/tools/check_phase64_timer_assignment_fulfillment.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +files = [ + "core/timers/include/TimerAssignmentFulfillmentService.h", + "core/timers/src/TimerAssignmentFulfillmentService.cpp", + "core/timers/tests/test_timer_assignment_fulfillment_service.cpp", + "docs/development/phase-64-timer-assignment-fulfillment.md", + "mk/phase64-timer-assignment-fulfillment-tests.mk", + "Makefile", +] +for relative in files: + if not (ROOT / relative).is_file(): + raise SystemExit(f"missing TimerAssignment fulfillment file: {relative}") + +header = (ROOT / files[0]).read_text(encoding="utf-8") +source = (ROOT / files[1]).read_text(encoding="utf-8") +test = (ROOT / files[2]).read_text(encoding="utf-8") +doc = (ROOT / files[3]).read_text(encoding="utf-8") +mk = (ROOT / files[4]).read_text(encoding="utf-8") +makefile = (ROOT / files[5]).read_text(encoding="utf-8") + +for token in [ + "provisioningStarted", "alreadyProvisioning", "alreadyBound", + "assignmentRevisionConflict", "bindingRevisionConflict", + "class TimerAssignmentFulfillmentService", +]: + if token not in header: + raise SystemExit(f"missing fulfillment header marker: {token}") + +for token in [ + "current.state =", # prohibited below if accidental direct mutation +]: + if token in source: + raise SystemExit("fulfillment must not mutate repository snapshots in place") + +for token in [ + "assignmentRepository_.findById(timerAssignmentId)", + "next.state = TimerAssignmentState::provisioning", + "assignmentRepository_.update(", + "bindingRepository_.findById(nativeTimerBindingId)", + "binding.ownership != NativeTimerBindingOwnership::managed", + "!binding.lastVerifiedOperationId.empty()", + "next.state = TimerAssignmentState::bound", + "next.nativeTimerBindingId = nativeTimerBindingId", +]: + if token not in source: + raise SystemExit(f"missing fulfillment source marker: {token}") + +for forbidden in ["SuiteBridge", "BackendAgentCommand", "SVDRP", "RestfulApi", "MutationOperationRepository"]: + if forbidden in header + source: + raise SystemExit(f"premature fulfillment boundary crossing: {forbidden}") + +for token in [ + "provisioningStarted", "alreadyProvisioning", + "assignmentRevisionConflict", "TimerAssignmentFulfillmentStatus::bound", + "alreadyBound", "generationConflict", "bindingNotFound", + "test_timer_assignment_fulfillment_service passed", +]: + if token not in test: + raise SystemExit(f"missing fulfillment regression marker: {token}") + +for token in [ + "selected -> provisioning", + "provisioning -> bound", + "verified managed NativeTimerBinding", + "revision-fenced", + "outcome_unknown", +]: + if token not in doc: + raise SystemExit(f"missing fulfillment documentation marker: {token}") + +if "include mk/phase64-timer-assignment-fulfillment-tests.mk" not in makefile: + raise SystemExit("fulfillment make fragment is not included") + +for token in [ + "test-phase64-timer-assignment-fulfillment-architecture", + "test-phase64-timer-assignment-fulfillment:", + "test-fast: test-phase64-timer-assignment-fulfillment", + "test-architecture: test-phase64-timer-assignment-fulfillment-architecture", +]: + if token not in mk: + raise SystemExit(f"missing fulfillment make marker: {token}") + +print("Phase-64 TimerAssignment fulfillment check passed") From 75c160b388804dcba1b063839dce5e18951a8d17 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:45:22 +0200 Subject: [PATCH 10/85] Fix managed CREATE evidence architecture marker --- .../check_phase64_vdr_managed_timer_create_readback_evidence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/check_phase64_vdr_managed_timer_create_readback_evidence.py b/tools/check_phase64_vdr_managed_timer_create_readback_evidence.py index 9c9c2f6e2..e6b22b5ce 100755 --- a/tools/check_phase64_vdr_managed_timer_create_readback_evidence.py +++ b/tools/check_phase64_vdr_managed_timer_create_readback_evidence.py @@ -66,7 +66,7 @@ for token in [ "complete native Timer inventory evidence", - "opaque aux", + "opaque provider-local data", "exact native-id set", "does not collapse duplicate managed correlations", ]: From 495b9d26a386be6dd73f97188fb5f362d4e13550 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:46:46 +0200 Subject: [PATCH 11/85] Fix TimerAssignment fulfillment mutation guard --- tools/check_phase64_timer_assignment_fulfillment.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tools/check_phase64_timer_assignment_fulfillment.py b/tools/check_phase64_timer_assignment_fulfillment.py index cafd6d9b9..d6dd9ba24 100755 --- a/tools/check_phase64_timer_assignment_fulfillment.py +++ b/tools/check_phase64_timer_assignment_fulfillment.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 from pathlib import Path +import re ROOT = Path(__file__).resolve().parents[1] @@ -30,11 +31,8 @@ if token not in header: raise SystemExit(f"missing fulfillment header marker: {token}") -for token in [ - "current.state =", # prohibited below if accidental direct mutation -]: - if token in source: - raise SystemExit("fulfillment must not mutate repository snapshots in place") +if re.search(r"\bcurrent\.state\s*=(?!=)", source): + raise SystemExit("fulfillment must not mutate repository snapshots in place") for token in [ "assignmentRepository_.findById(timerAssignmentId)", From a24e8cd1b82f3e56dea0d9e5fb9c223e6f2cf69c Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:47:56 +0200 Subject: [PATCH 12/85] Fix managed CREATE verification documentation guard --- .../check_phase64_native_timer_create_readback_verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/check_phase64_native_timer_create_readback_verification.py b/tools/check_phase64_native_timer_create_readback_verification.py index d2f298a4a..597813c41 100755 --- a/tools/check_phase64_native_timer_create_readback_verification.py +++ b/tools/check_phase64_native_timer_create_readback_verification.py @@ -73,7 +73,7 @@ for token in [ "authoritative complete inventory", "exactly one managed correlation", - "no blind retry", + "blind retry", "NativeTimerBinding", "outcome_unknown", ]: From 5a8d76609439e543f82d5db096088da465725172 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:49:00 +0200 Subject: [PATCH 13/85] Allow reviewed CREATE inventory evidence consumer --- tools/check_phase64_native_timer_inventory_evidence.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/check_phase64_native_timer_inventory_evidence.py b/tools/check_phase64_native_timer_inventory_evidence.py index c773348db..26360dc19 100644 --- a/tools/check_phase64_native_timer_inventory_evidence.py +++ b/tools/check_phase64_native_timer_inventory_evidence.py @@ -116,12 +116,15 @@ if include_line not in makefile: raise SystemExit("Slice-15 make fragment is not included") -# Slice 16 adds one explicitly reviewed, test-only provider-side producer for this -# evidence. Keep the older Slice-15 runtime-wiring fence closed everywhere else. +# Later Phase-64 read-only consumers are explicitly allow-listed. The fence +# stays closed for command/runtime mutation wiring and unrelated adapters. allowed_later_consumers = { "core/vdr/include/RestfulApiNativeTimerInventoryReader.h", "core/vdr/src/RestfulApiNativeTimerInventoryReader.cpp", "core/vdr/tests/test_restfulapi_native_timer_inventory_reader.cpp", + "core/vdr/include/VdrManagedTimerCreateReadbackEvidenceBuilder.h", + "core/vdr/src/VdrManagedTimerCreateReadbackEvidenceBuilder.cpp", + "core/vdr/tests/test_vdr_managed_timer_create_readback_evidence_builder.cpp", } for scan_root in [ @@ -149,5 +152,5 @@ print("Phase-64 native Timer inventory evidence check passed") print( "Slice-15 boundary: complete backend-generation-fenced inventory evidence " - "only; producer, missing-state application and native mutation deferred" + "with explicitly reviewed read-only consumers; native mutation remains deferred" ) From 62951bef02c2dedb8bd49401fb632eb743eb4172 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 14:58:32 +0200 Subject: [PATCH 14/85] Phase 64: reserve immutable mutation payload atomically --- .../include/MutationOperationRepository.h | 36 ++ .../src/MutationOperationRepository.cpp | 340 ++++++++++++++---- .../test_mutation_operation_repository.cpp | 108 ++++++ .../phase-64-mutation-operation-repository.md | 111 +++--- ...k_phase64_mutation_operation_repository.py | 26 +- 5 files changed, 499 insertions(+), 122 deletions(-) mode change 100644 => 100755 tools/check_phase64_mutation_operation_repository.py diff --git a/core/operations/include/MutationOperationRepository.h b/core/operations/include/MutationOperationRepository.h index bb659ff8a..dd131e489 100644 --- a/core/operations/include/MutationOperationRepository.h +++ b/core/operations/include/MutationOperationRepository.h @@ -2,6 +2,7 @@ #include "MutationOperation.h" +#include #include class Database; @@ -34,6 +35,30 @@ struct MutationOperationRepositoryResult } }; +// Immutable versioned handler input that may outlive the process which +// reserved the operation. The repository never interprets payload contents; +// domain services own serialization and validation of their payload type. +struct MutationOperationPayload +{ + std::string operationId; + std::string payloadType; + std::uint32_t payloadVersion = 0; + std::string payload; + std::string payloadFingerprint; +}; + +struct MutationOperationPayloadRepositoryResult +{ + MutationOperationRepositoryStatus status = + MutationOperationRepositoryStatus::storageError; + MutationOperationPayload payload; + + bool ok() const + { + return status == MutationOperationRepositoryStatus::ok; + } +}; + class MutationOperationRepository { public: @@ -46,9 +71,20 @@ class MutationOperationRepository MutationOperationRepositoryResult reserve( const MutationOperation& operation); + // Atomically reserve the operation and one immutable versioned payload in + // the same SQLite transaction. Exact replay requires both the logical + // operation and payload to match; a missing or changed payload fails closed + // as operationConflict. Existing reserve() callers remain payload-free. + MutationOperationRepositoryResult reserveWithPayload( + const MutationOperation& operation, + const MutationOperationPayload& payload); + MutationOperationRepositoryResult findById( const std::string& operationId); + MutationOperationPayloadRepositoryResult findPayloadByOperationId( + const std::string& operationId); + MutationOperationRepositoryResult findByIdempotencyScope( const std::string& actorId, const std::string& backendId, diff --git a/core/operations/src/MutationOperationRepository.cpp b/core/operations/src/MutationOperationRepository.cpp index 2a1d40c5b..98bca0b63 100644 --- a/core/operations/src/MutationOperationRepository.cpp +++ b/core/operations/src/MutationOperationRepository.cpp @@ -14,12 +14,25 @@ namespace { constexpr std::size_t kMaxIdentityLength = 160; constexpr std::size_t kMaxResultReferenceLength = 512; +constexpr std::size_t kMaxPayloadLength = 64 * 1024; +constexpr std::size_t kMaxPayloadFingerprintLength = 4096; bool safeIdentity(const std::string& value) { return !value.empty() && value.size() <= kMaxIdentityLength; } +bool validPayload(const MutationOperationPayload& payload) +{ + return safeIdentity(payload.operationId) + && safeIdentity(payload.payloadType) + && payload.payloadVersion > 0 + && !payload.payload.empty() + && payload.payload.size() <= kMaxPayloadLength + && !payload.payloadFingerprint.empty() + && payload.payloadFingerprint.size() <= kMaxPayloadFingerprintLength; +} + bool bindText(sqlite3_stmt* statement, int index, const std::string& value) { return sqlite3_bind_text( @@ -96,6 +109,24 @@ MutationOperationRepositoryResult operationResult( return result; } +MutationOperationPayloadRepositoryResult payloadStatusResult( + MutationOperationRepositoryStatus status) +{ + MutationOperationPayloadRepositoryResult result; + result.status = status; + return result; +} + +MutationOperationPayloadRepositoryResult payloadResult( + MutationOperationRepositoryStatus status, + const MutationOperationPayload& payload) +{ + MutationOperationPayloadRepositoryResult result; + result.status = status; + result.payload = payload; + return result; +} + bool readOperation(sqlite3_stmt* statement, MutationOperation& operation) { operation.operationId = columnText(statement, 0); @@ -123,6 +154,21 @@ bool readOperation(sqlite3_stmt* statement, MutationOperation& operation) return mutationOperationValidDurable(operation); } +bool readPayload(sqlite3_stmt* statement, MutationOperationPayload& payload) +{ + payload.operationId = columnText(statement, 0); + payload.payloadType = columnText(statement, 1); + const sqlite3_int64 payloadVersion = sqlite3_column_int64(statement, 2); + if (payloadVersion <= 0 + || payloadVersion > static_cast( + std::numeric_limits::max())) + return false; + payload.payloadVersion = static_cast(payloadVersion); + payload.payload = columnText(statement, 3); + payload.payloadFingerprint = columnText(statement, 4); + return validPayload(payload); +} + constexpr const char* kColumns = "operation_id,operation_revision,idempotency_key,actor_id,backend_id," "backend_generation,resource_type,resource_id,expected_revision," @@ -156,6 +202,34 @@ bool selectById( return step == SQLITE_DONE; } +bool selectPayloadByOperationId( + Database& database, + const std::string& operationId, + MutationOperationPayload& payload, + bool& found) +{ + found = false; + sqlite3_stmt* statement = nullptr; + const char* sql = + "SELECT operation_id,payload_type,payload_version,payload,payload_fingerprint " + "FROM mutation_operation_payloads WHERE operation_id=? LIMIT 1;"; + if (sqlite3_prepare_v2(database.handle(), sql, -1, &statement, nullptr) != SQLITE_OK + || !bindText(statement, 1, operationId)) + { + if (statement != nullptr) sqlite3_finalize(statement); + return false; + } + const int step = sqlite3_step(statement); + if (step == SQLITE_ROW) + { + found = readPayload(statement, payload); + sqlite3_finalize(statement); + return found; + } + sqlite3_finalize(statement); + return step == SQLITE_DONE; +} + bool selectByScope( Database& database, const std::string& actorId, @@ -213,77 +287,140 @@ bool sameLogicalOperation( left.deadline == right.deadline && left.verificationPolicy == right.verificationPolicy; } -} -MutationOperationRepository::MutationOperationRepository(Database& database) - : database_(database) +bool samePayload( + const MutationOperationPayload& left, + const MutationOperationPayload& right) { + return left.operationId == right.operationId + && left.payloadType == right.payloadType + && left.payloadVersion == right.payloadVersion + && left.payload == right.payload + && left.payloadFingerprint == right.payloadFingerprint; } -bool MutationOperationRepository::ensureSchema() +bool insertOperation(Database& database, const MutationOperation& durable) { - if (!database_.execute( - "CREATE TABLE IF NOT EXISTS mutation_operations (" - "operation_id TEXT PRIMARY KEY," - "operation_revision INTEGER NOT NULL," - "idempotency_key TEXT NOT NULL,actor_id TEXT NOT NULL,backend_id TEXT NOT NULL," - "backend_generation INTEGER NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL," - "expected_revision TEXT NOT NULL,expected_resource_fingerprint TEXT NOT NULL DEFAULT ''," - "action_family TEXT NOT NULL,request_fingerprint TEXT NOT NULL," - "requested_at INTEGER NOT NULL,deadline INTEGER NOT NULL,verification_policy TEXT NOT NULL," - "state TEXT NOT NULL,result_reference TEXT NOT NULL,updated_at INTEGER NOT NULL," - "CHECK(operation_revision>0),CHECK(backend_generation>0)," - "CHECK(verification_policy IN ('none','readback_required','event_confirmation','reconciliation_required'))," - "CHECK(state IN ('accepted','rejected','conflict','queued','dispatching','executed_unverified','succeeded','failed_before_dispatch','failed_verified','outcome_unknown','cancelled'))" - ");")) - return false; - - if (!hasColumn(database_, "mutation_operations", "expected_resource_fingerprint") && - !database_.execute( - "ALTER TABLE mutation_operations " - "ADD COLUMN expected_resource_fingerprint TEXT NOT NULL DEFAULT '';")) - return false; + sqlite3_stmt* statement = nullptr; + const char* sql = + "INSERT INTO mutation_operations(" + "operation_id,operation_revision,idempotency_key,actor_id,backend_id,backend_generation," + "resource_type,resource_id,expected_revision,expected_resource_fingerprint,action_family," + "request_fingerprint,requested_at,deadline,verification_policy,state,result_reference,updated_at)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"; + const bool prepared = + sqlite3_prepare_v2(database.handle(), sql, -1, &statement, nullptr) == SQLITE_OK; + const bool bound = prepared && + bindText(statement, 1, durable.operationId) && + bindInt64(statement, 2, 1) && + bindText(statement, 3, durable.idempotencyKey) && + bindText(statement, 4, durable.actorId) && + bindText(statement, 5, durable.backendId) && + bindInt64(statement, 6, static_cast(durable.backendGeneration)) && + bindText(statement, 7, durable.resourceType) && + bindText(statement, 8, durable.resourceId) && + bindText(statement, 9, durable.expectedRevision) && + bindText(statement, 10, durable.expectedResourceFingerprint) && + bindText(statement, 11, durable.actionFamily) && + bindText(statement, 12, durable.requestFingerprint) && + bindInt64(statement, 13, durable.requestedAt) && + bindInt64(statement, 14, durable.deadline) && + bindText(statement, 15, mutationOperationVerificationPolicyName(durable.verificationPolicy)) && + bindText(statement, 16, mutationOperationStateName(durable.state)) && + bindText(statement, 17, durable.resultReference) && + bindInt64(statement, 18, durable.updatedAt); + const int step = bound ? sqlite3_step(statement) : SQLITE_ERROR; + if (statement != nullptr) sqlite3_finalize(statement); + return step == SQLITE_DONE; +} - return database_.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_mutation_operations_idempotency_scope " - "ON mutation_operations(actor_id,backend_id,resource_type,resource_id,action_family,idempotency_key);" - "CREATE INDEX IF NOT EXISTS idx_mutation_operations_backend_state " - "ON mutation_operations(backend_id,state,updated_at);"); +bool insertPayload(Database& database, const MutationOperationPayload& payload) +{ + sqlite3_stmt* statement = nullptr; + const char* sql = + "INSERT INTO mutation_operation_payloads(" + "operation_id,payload_type,payload_version,payload,payload_fingerprint)" + " VALUES(?,?,?,?,?);"; + const bool prepared = + sqlite3_prepare_v2(database.handle(), sql, -1, &statement, nullptr) == SQLITE_OK; + const bool bound = prepared + && bindText(statement, 1, payload.operationId) + && bindText(statement, 2, payload.payloadType) + && bindInt64(statement, 3, static_cast(payload.payloadVersion)) + && bindText(statement, 4, payload.payload) + && bindText(statement, 5, payload.payloadFingerprint); + const int step = bound ? sqlite3_step(statement) : SQLITE_ERROR; + if (statement != nullptr) sqlite3_finalize(statement); + return step == SQLITE_DONE; } -MutationOperationRepositoryResult MutationOperationRepository::reserve( - const MutationOperation& operation) +MutationOperationRepositoryResult reserveInternal( + Database& database, + const MutationOperation& operation, + const MutationOperationPayload* payload) { - if (!mutationOperationValidForCreate(operation)) + if (!mutationOperationValidForCreate(operation) + || (payload != nullptr + && (!validPayload(*payload) + || payload->operationId != operation.operationId))) + { return statusResult(MutationOperationRepositoryStatus::invalid); + } MutationOperation durable = operation; durable.operationRevision = "1"; - auto lease = database_.acquireTransactionLease(); - if (!database_.execute("BEGIN IMMEDIATE TRANSACTION;")) + auto lease = database.acquireTransactionLease(); + if (!database.execute("BEGIN IMMEDIATE TRANSACTION;")) return statusResult(MutationOperationRepositoryStatus::storageError); - auto rollback = [this]() { database_.execute("ROLLBACK;"); }; + auto rollback = [&database]() { database.execute("ROLLBACK;"); }; MutationOperation existing; bool found = false; - if (!selectById(database_, operation.operationId, existing, found)) + if (!selectById(database, operation.operationId, existing, found)) { rollback(); return statusResult(MutationOperationRepositoryStatus::storageError); } if (found) { + if (!sameLogicalOperation(existing, durable)) + { + rollback(); + return operationResult( + MutationOperationRepositoryStatus::operationConflict, + existing); + } + + if (payload != nullptr) + { + MutationOperationPayload existingPayload; + bool payloadFound = false; + if (!selectPayloadByOperationId( + database, operation.operationId, + existingPayload, payloadFound)) + { + rollback(); + return statusResult( + MutationOperationRepositoryStatus::storageError); + } + if (!payloadFound || !samePayload(existingPayload, *payload)) + { + rollback(); + return operationResult( + MutationOperationRepositoryStatus::operationConflict, + existing); + } + } + rollback(); return operationResult( - sameLogicalOperation(existing, durable) - ? MutationOperationRepositoryStatus::idempotentReplay - : MutationOperationRepositoryStatus::operationConflict, + MutationOperationRepositoryStatus::idempotentReplay, existing); } if (!selectByScope( - database_, operation.actorId, operation.backendId, + database, operation.actorId, operation.backendId, operation.resourceType, operation.resourceId, operation.actionFamily, operation.idempotencyKey, existing, found)) @@ -298,43 +435,85 @@ MutationOperationRepositoryResult MutationOperationRepository::reserve( MutationOperationRepositoryStatus::idempotencyConflict, existing); } - sqlite3_stmt* statement = nullptr; - const char* sql = - "INSERT INTO mutation_operations(" - "operation_id,operation_revision,idempotency_key,actor_id,backend_id,backend_generation," - "resource_type,resource_id,expected_revision,expected_resource_fingerprint,action_family," - "request_fingerprint,requested_at,deadline,verification_policy,state,result_reference,updated_at)" - " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"; - const bool prepared = - sqlite3_prepare_v2(database_.handle(), sql, -1, &statement, nullptr) == SQLITE_OK; - const bool bound = prepared && - bindText(statement, 1, durable.operationId) && - bindInt64(statement, 2, 1) && - bindText(statement, 3, durable.idempotencyKey) && - bindText(statement, 4, durable.actorId) && - bindText(statement, 5, durable.backendId) && - bindInt64(statement, 6, static_cast(durable.backendGeneration)) && - bindText(statement, 7, durable.resourceType) && - bindText(statement, 8, durable.resourceId) && - bindText(statement, 9, durable.expectedRevision) && - bindText(statement, 10, durable.expectedResourceFingerprint) && - bindText(statement, 11, durable.actionFamily) && - bindText(statement, 12, durable.requestFingerprint) && - bindInt64(statement, 13, durable.requestedAt) && - bindInt64(statement, 14, durable.deadline) && - bindText(statement, 15, mutationOperationVerificationPolicyName(durable.verificationPolicy)) && - bindText(statement, 16, mutationOperationStateName(durable.state)) && - bindText(statement, 17, durable.resultReference) && - bindInt64(statement, 18, durable.updatedAt); - const int step = bound ? sqlite3_step(statement) : SQLITE_ERROR; - if (statement != nullptr) sqlite3_finalize(statement); - if (step != SQLITE_DONE || !database_.execute("COMMIT;")) + if (!insertOperation(database, durable) + || (payload != nullptr && !insertPayload(database, *payload))) + { + rollback(); + return statusResult(MutationOperationRepositoryStatus::storageError); + } + + if (!database.execute("COMMIT;")) { rollback(); return statusResult(MutationOperationRepositoryStatus::storageError); } return operationResult(MutationOperationRepositoryStatus::ok, durable); } +} + +MutationOperationRepository::MutationOperationRepository(Database& database) + : database_(database) +{ +} + +bool MutationOperationRepository::ensureSchema() +{ + if (!database_.execute( + "CREATE TABLE IF NOT EXISTS mutation_operations (" + "operation_id TEXT PRIMARY KEY," + "operation_revision INTEGER NOT NULL," + "idempotency_key TEXT NOT NULL,actor_id TEXT NOT NULL,backend_id TEXT NOT NULL," + "backend_generation INTEGER NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL," + "expected_revision TEXT NOT NULL,expected_resource_fingerprint TEXT NOT NULL DEFAULT ''," + "action_family TEXT NOT NULL,request_fingerprint TEXT NOT NULL," + "requested_at INTEGER NOT NULL,deadline INTEGER NOT NULL,verification_policy TEXT NOT NULL," + "state TEXT NOT NULL,result_reference TEXT NOT NULL,updated_at INTEGER NOT NULL," + "CHECK(operation_revision>0),CHECK(backend_generation>0)," + "CHECK(verification_policy IN ('none','readback_required','event_confirmation','reconciliation_required'))," + "CHECK(state IN ('accepted','rejected','conflict','queued','dispatching','executed_unverified','succeeded','failed_before_dispatch','failed_verified','outcome_unknown','cancelled'))" + ");")) + return false; + + if (!hasColumn(database_, "mutation_operations", "expected_resource_fingerprint") && + !database_.execute( + "ALTER TABLE mutation_operations " + "ADD COLUMN expected_resource_fingerprint TEXT NOT NULL DEFAULT '';")) + return false; + + if (!database_.execute( + "CREATE TABLE IF NOT EXISTS mutation_operation_payloads (" + "operation_id TEXT PRIMARY KEY," + "payload_type TEXT NOT NULL," + "payload_version INTEGER NOT NULL," + "payload TEXT NOT NULL," + "payload_fingerprint TEXT NOT NULL," + "CHECK(payload_version>0)," + "FOREIGN KEY(operation_id) REFERENCES mutation_operations(operation_id) ON DELETE CASCADE" + ");")) + return false; + + return database_.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_mutation_operations_idempotency_scope " + "ON mutation_operations(actor_id,backend_id,resource_type,resource_id,action_family,idempotency_key);" + "CREATE INDEX IF NOT EXISTS idx_mutation_operations_backend_state " + "ON mutation_operations(backend_id,state,updated_at);" + "CREATE INDEX IF NOT EXISTS idx_mutation_operation_payloads_type_version " + "ON mutation_operation_payloads(payload_type,payload_version);" + ); +} + +MutationOperationRepositoryResult MutationOperationRepository::reserve( + const MutationOperation& operation) +{ + return reserveInternal(database_, operation, nullptr); +} + +MutationOperationRepositoryResult MutationOperationRepository::reserveWithPayload( + const MutationOperation& operation, + const MutationOperationPayload& payload) +{ + return reserveInternal(database_, operation, &payload); +} MutationOperationRepositoryResult MutationOperationRepository::findById( const std::string& operationId) @@ -350,6 +529,21 @@ MutationOperationRepositoryResult MutationOperationRepository::findById( : statusResult(MutationOperationRepositoryStatus::notFound); } +MutationOperationPayloadRepositoryResult +MutationOperationRepository::findPayloadByOperationId( + const std::string& operationId) +{ + if (!safeIdentity(operationId)) + return payloadStatusResult(MutationOperationRepositoryStatus::invalid); + MutationOperationPayload payload; + bool found = false; + if (!selectPayloadByOperationId(database_, operationId, payload, found)) + return payloadStatusResult(MutationOperationRepositoryStatus::storageError); + return found + ? payloadResult(MutationOperationRepositoryStatus::ok, payload) + : payloadStatusResult(MutationOperationRepositoryStatus::notFound); +} + MutationOperationRepositoryResult MutationOperationRepository::findByIdempotencyScope( const std::string& actorId, const std::string& backendId, @@ -467,4 +661,4 @@ MutationOperationRepositoryResult MutationOperationRepository::transition( return operationResult(MutationOperationRepositoryStatus::ok, current); } -} \ No newline at end of file +} diff --git a/core/operations/tests/test_mutation_operation_repository.cpp b/core/operations/tests/test_mutation_operation_repository.cpp index 039d8696c..0d4fc2b4f 100644 --- a/core/operations/tests/test_mutation_operation_repository.cpp +++ b/core/operations/tests/test_mutation_operation_repository.cpp @@ -37,6 +37,20 @@ MutationOperation makeOperation( return operation; } +MutationOperationPayload makePayload( + const std::string& operationId = "op-payload", + const std::string& body = "payload-v1", + const std::string& fingerprint = "payload-fingerprint-v1") +{ + MutationOperationPayload payload; + payload.operationId = operationId; + payload.payloadType = "test.operation.payload"; + payload.payloadVersion = 1; + payload.payload = body; + payload.payloadFingerprint = fingerprint; + return payload; +} + void assertState( const MutationOperationRepositoryResult& result, MutationOperationRepositoryStatus status, @@ -68,6 +82,7 @@ void assertLegacySchemaMigration() MutationOperationRepository repository(database); assert(repository.ensureSchema()); + assert(database.tableExists("mutation_operation_payloads")); auto operation = makeOperation( "op-migrated", "idem-migrated", "sha256:req-migrated"); @@ -81,18 +96,111 @@ void assertLegacySchemaMigration() assert(found.status == MutationOperationRepositoryStatus::ok); assert(found.operation.expectedResourceFingerprint == operation.expectedResourceFingerprint); + assert(repository.findPayloadByOperationId("op-migrated").status == + MutationOperationRepositoryStatus::notFound); +} + +void assertAtomicPayloadReservation() +{ + Database database; + assert(database.open(":memory:")); + MutationOperationRepository repository(database); + assert(repository.ensureSchema()); + + auto operation = makeOperation( + "op-payload", "idem-payload", "sha256:req-payload"); + operation.resourceType = "TimerAssignment"; + operation.resourceId = "assignment-1"; + operation.expectedRevision = "4"; + operation.expectedResourceFingerprint = + "native-timer-specification/1|desired"; + operation.actionFamily = "timer.create"; + + const auto payload = makePayload(); + const auto reserved = repository.reserveWithPayload(operation, payload); + assertState( + reserved, + MutationOperationRepositoryStatus::ok, + MutationOperationState::accepted, + "1"); + + const auto foundPayload = repository.findPayloadByOperationId("op-payload"); + assert(foundPayload.ok()); + assert(foundPayload.payload.operationId == "op-payload"); + assert(foundPayload.payload.payloadType == "test.operation.payload"); + assert(foundPayload.payload.payloadVersion == 1); + assert(foundPayload.payload.payload == "payload-v1"); + assert(foundPayload.payload.payloadFingerprint == + "payload-fingerprint-v1"); + + const auto replay = repository.reserveWithPayload(operation, payload); + assertState( + replay, + MutationOperationRepositoryStatus::idempotentReplay, + MutationOperationState::accepted, + "1"); + + auto changedBody = payload; + changedBody.payload = "payload-v1-changed"; + assert(repository.reserveWithPayload(operation, changedBody).status == + MutationOperationRepositoryStatus::operationConflict); + + auto changedFingerprint = payload; + changedFingerprint.payloadFingerprint = "payload-fingerprint-other"; + assert(repository.reserveWithPayload(operation, changedFingerprint).status == + MutationOperationRepositoryStatus::operationConflict); + + auto changedVersion = payload; + changedVersion.payloadVersion = 2; + assert(repository.reserveWithPayload(operation, changedVersion).status == + MutationOperationRepositoryStatus::operationConflict); + + auto wrongOperation = payload; + wrongOperation.operationId = "op-other"; + assert(repository.reserveWithPayload(operation, wrongOperation).status == + MutationOperationRepositoryStatus::invalid); + + auto emptyPayload = payload; + emptyPayload.payload.clear(); + assert(repository.reserveWithPayload( + makeOperation("op-empty", "idem-empty", "sha256:req-empty"), + emptyPayload).status == MutationOperationRepositoryStatus::invalid); + + auto oversized = makePayload( + "op-oversized", std::string(64 * 1024 + 1, 'x'), "fingerprint"); + auto oversizedOperation = makeOperation( + "op-oversized", "idem-oversized", "sha256:req-oversized"); + oversizedOperation.resourceId = "binding-oversized"; + assert(repository.reserveWithPayload(oversizedOperation, oversized).status == + MutationOperationRepositoryStatus::invalid); + + auto legacyOperation = makeOperation( + "op-no-payload", "idem-no-payload", "sha256:req-no-payload"); + legacyOperation.resourceId = "binding-no-payload"; + assert(repository.reserve(legacyOperation).status == + MutationOperationRepositoryStatus::ok); + assert(repository.findPayloadByOperationId("op-no-payload").status == + MutationOperationRepositoryStatus::notFound); + + auto latePayload = makePayload("op-no-payload"); + assert(repository.reserveWithPayload(legacyOperation, latePayload).status == + MutationOperationRepositoryStatus::operationConflict); + assert(repository.findPayloadByOperationId("op-no-payload").status == + MutationOperationRepositoryStatus::notFound); } } int main() { assertLegacySchemaMigration(); + assertAtomicPayloadReservation(); Database database; assert(database.open(":memory:")); MutationOperationRepository repository(database); assert(repository.ensureSchema()); assert(database.tableExists("mutation_operations")); + assert(database.tableExists("mutation_operation_payloads")); const auto reserved = repository.reserve(makeOperation()); assertState( diff --git a/docs/development/phase-64-mutation-operation-repository.md b/docs/development/phase-64-mutation-operation-repository.md index 40d561fe3..860ce1e94 100644 --- a/docs/development/phase-64-mutation-operation-repository.md +++ b/docs/development/phase-64-mutation-operation-repository.md @@ -1,18 +1,19 @@ -# Phase 64 Slice 20 — Shared Mutation Operation Repository +# Phase 64 — Shared Mutation Operation Repository ## Status -Bounded implementation slice stacked on Phase 64 Slice 19 / Draft PR #172. +Phase-64 shared Control-Plane persistence used by the guarded Timer mutation work. -This slice closes a prerequisite that became explicit while preparing the +This boundary closes a prerequisite that became explicit while preparing the Native Timer readback-to-operation handoff: the repository has the accepted ADR-0042 lifecycle contract and Phase-63 protected-write/idempotency decision contract, but **no existing durable generic operation repository** that can own that lifecycle across domains and process restarts. Creating a Timer-local operation table would establish a second lifecycle -authority and violate ADR-0042. Slice 20 therefore implements the missing shared -persistence boundary first. +authority and violate ADR-0042. The shared persistence boundary therefore owns +both the operation lifecycle and, when required, one immutable versioned +handler payload reserved atomically with that operation. ## Governing contract @@ -45,9 +46,9 @@ reconciliation_required ## Single lifecycle authority -The new `vdrsuite::operations::MutationOperation` is backend-neutral and -transport-neutral. The durable `MutationOperationRepository` is the **single -lifecycle authority** introduced by this slice. +The backend-neutral, transport-neutral `vdrsuite::operations::MutationOperation` +and durable `MutationOperationRepository` remain the **Single lifecycle authority** +for ADR-0042 mutation state. The stored immutable operation envelope contains: @@ -56,7 +57,7 @@ The stored immutable operation envelope contains: - actor identity; - backend ID and exact backend generation; - resource type and stable Suite resource ID; -- expected resource revision; +- expected resource revision and optional resource fingerprint; - action family; - normalized request fingerprint; - requested time and optional dispatch deadline; @@ -84,7 +85,7 @@ actorId + idempotencyKey ``` -`reserve()` creates only an `accepted` operation and owns initial revision `1`. +`reserve()` creates an `accepted` operation and owns initial revision `1`. An exact replay of the same logical operation returns the existing record and performs no second insert. Reusing the same scope with another operation or request fingerprint is an idempotency conflict. Reusing an operation ID for a @@ -93,6 +94,39 @@ different logical operation is an operation conflict. The request fingerprint is immutable; a later lifecycle transition never edits it or any scope/resource/generation field. +## Atomic immutable payload reservation + +Some mutations can reconstruct their dispatch handoff entirely from durable +resource state. Others, such as native Timer CREATE, need pre-dispatch values +which do not yet exist as a durable post-mutation resource. For those cases the +repository provides `reserveWithPayload()`. + +The payload envelope contains: + +- the exact `operationId`; +- stable `payloadType`; +- positive `payloadVersion`; +- immutable normalized serialized `payload`; +- immutable `payloadFingerprint`. + +The operation row and payload row are inserted inside the **same `BEGIN +IMMEDIATE` SQLite transaction**. There is therefore no accepted-operation state +from which the corresponding required CREATE handoff has not yet been made +durable. + +Exact idempotent replay requires both the logical operation and the complete +payload envelope to match. A missing payload, changed version, changed bytes or +changed payload fingerprint fails closed as an operation conflict. A payload +cannot be attached later to an operation previously reserved through the +payload-free API; the caller must have chosen the atomic reservation path before +acceptance. + +The repository treats payload bytes as opaque versioned handler input. It does +not know about Timer, Recording, Agent or VDR domain types. Domain code owns +serialization, version compatibility and semantic validation. + +Existing payload-free `reserve()` users remain valid and unchanged. + ## Revision-fenced lifecycle transitions `transition()` requires: @@ -111,6 +145,22 @@ fail closed. Terminal states do not transition further. +## Relationship to ADR-0043 + +ADR-0043 defines a future production job model with versioned immutable payloads, +atomic claims, attempts, claim fencing, leases, retries and sagas. The current +legacy Recording `JobRepository` does not implement that contract and is not +promoted into Timer execution. + +The immutable operation payload added here is deliberately narrower: it closes +the pre-dispatch crash gap for mutation-specific durable input while preserving +ADR-0042 as the operation lifecycle authority. It is not represented as a full +ADR-0043 job implementation and does not invent claim/attempt semantics. + +A later production job layer may reference the same `operationId` and copy or +reference the versioned payload under ADR-0043 rules without changing the +operation's caller-visible identity. + ## Relationship to Phase 63 Phase 63 already provides important lower-level pieces: @@ -121,43 +171,15 @@ Phase 63 already provides important lower-level pieces: - accountability evidence; - provider/generation fencing. -Those are not replaced. In particular, `BackendAgentCommandRepository` remains -the command transport/result owner and `AccountabilityEventRepository` remains -the append-only accountability owner. Neither is promoted into the mutation -operation lifecycle implicitly. - -The Phase-63 protected-write slice explicitly deferred SQLite idempotency -persistence; this slice supplies that missing common Control-Plane authority -without changing Agent behavior. - -## Relationship to Native Timer readback - -Slice 19 / PR #172 can prove and durably record the expected Native Timer delete -postcondition on `NativeTimerBinding`, including `lastVerifiedOperationId`. -It deliberately does not transition the ADR-0042 operation lifecycle. - -**Slice 21** may now add the narrow correlation service that: - -1. reloads the exact durable MutationOperation by operation ID; -2. requires the correct backend/resource/action identity and unresolved - `executed_unverified` or `outcome_unknown` state; -3. requires the matching durable NativeTimerBinding verification evidence from - Slice 19; -4. transitions this shared operation record to `succeeded` through the exact - repository revision fence. - -That later service must not infer success from transport acknowledgement or -from an uncorrelated missing Timer. +Those are not replaced. In particular, Agent command persistence remains the +remote command transport/result owner. It begins only after Control-Plane +pre-dispatch state is durable. ## Scope boundary -This slice intentionally adds only the shared domain model, SQLite repository, -focused regression, architecture guard and isolated Make test fragment. - -It adds: +This shared repository remains domain-neutral. It adds: - no Native Timer dependency in `core/operations`; -- no Timer lifecycle transition service yet; - no Agent command creation or dispatch; - no native Timer create/update/delete execution; - no provider or SuiteBridge changes; @@ -167,6 +189,5 @@ It adds: - no daemon/runtime wiring; - no `mutations=enabled` switch. -Because no installed runtime path changes, real yaVDR runtime acceptance is not -required for Slice 20. The focused repository regression and normal repository -CI are the acceptance boundary. +The focused repository regression and normal repository CI remain the acceptance +boundary for this persistence change. diff --git a/tools/check_phase64_mutation_operation_repository.py b/tools/check_phase64_mutation_operation_repository.py old mode 100644 new mode 100755 index a36b48ed9..2ab087b23 --- a/tools/check_phase64_mutation_operation_repository.py +++ b/tools/check_phase64_mutation_operation_repository.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Architecture guard for Phase 64 Slice 20 shared mutation operation persistence.""" +"""Architecture guard for Phase 64 shared mutation operation persistence.""" from pathlib import Path import sys @@ -19,7 +19,7 @@ def require(path: Path, markers: list[str]) -> None: if not path.is_file(): - failures.append(f"missing Slice-20 file: {path.relative_to(ROOT)}") + failures.append(f"missing mutation-operation file: {path.relative_to(ROOT)}") return text = path.read_text(encoding="utf-8") for marker in markers: @@ -64,9 +64,15 @@ def require(path: Path, markers: list[str]) -> None: require( REPO_H, [ + "struct MutationOperationPayload", + "payloadType", + "payloadVersion", + "payloadFingerprint", "class MutationOperationRepository", "reserve(", + "reserveWithPayload(", "findById(", + "findPayloadByOperationId(", "findByIdempotencyScope(", "transition(", "idempotentReplay", @@ -79,9 +85,14 @@ def require(path: Path, markers: list[str]) -> None: REPO_CPP, [ "CREATE TABLE IF NOT EXISTS mutation_operations", + "CREATE TABLE IF NOT EXISTS mutation_operation_payloads", "idx_mutation_operations_idempotency_scope", + "idx_mutation_operation_payloads_type_version", "actor_id,backend_id,resource_type,resource_id,action_family,idempotency_key", "BEGIN IMMEDIATE TRANSACTION", + "insertOperation(database, durable)", + "insertPayload(database, *payload)", + "samePayload(existingPayload, *payload)", "operation_revision=?", "mutationOperationTransitionAllowed", ], @@ -89,6 +100,12 @@ def require(path: Path, markers: list[str]) -> None: require( TEST, [ + "assertAtomicPayloadReservation", + "mutation_operation_payloads", + "reserveWithPayload", + "findPayloadByOperationId", + "payload-v1-changed", + "op-no-payload", "idempotentReplay", "idempotencyConflict", "operationConflict", @@ -105,7 +122,8 @@ def require(path: Path, markers: list[str]) -> None: "ADR-0042", "no existing durable generic operation repository", "Single lifecycle authority", - "Slice 21", + "Atomic immutable payload reservation", + "reserveWithPayload()", "No Timer-specific operation table", "no daemon/runtime wiring", ], @@ -150,7 +168,7 @@ def require(path: Path, markers: list[str]) -> None: text = source_manifest.read_text(encoding="utf-8") if "MutationOperation.cpp" in text or "MutationOperationRepository.cpp" in text: failures.append( - f"Slice-20 operation source is unexpectedly wired into runtime sources: {source_manifest.relative_to(ROOT)}" + f"mutation operation source is unexpectedly wired into runtime sources: {source_manifest.relative_to(ROOT)}" ) if failures: From 85ead8b1c106220bbe01e2b8887df79e7559a584 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 15:03:49 +0200 Subject: [PATCH 15/85] Phase 64: prepare durable managed timer create operation --- Makefile | 1 + .../NativeTimerCreateOperationPayload.h | 38 +++ ...veTimerCreateOperationPreparationService.h | 96 ++++++ .../src/NativeTimerCreateOperationPayload.cpp | 237 ++++++++++++++ ...TimerCreateOperationPreparationService.cpp | 229 ++++++++++++++ ...r_create_operation_preparation_service.cpp | 292 ++++++++++++++++++ ...tive-timer-create-operation-preparation.md | 96 ++++++ ...imer-create-operation-preparation-tests.mk | 25 ++ ...tive_timer_create_operation_preparation.py | 151 +++++++++ 9 files changed, 1165 insertions(+) create mode 100644 core/timers/include/NativeTimerCreateOperationPayload.h create mode 100644 core/timers/include/NativeTimerCreateOperationPreparationService.h create mode 100644 core/timers/src/NativeTimerCreateOperationPayload.cpp create mode 100644 core/timers/src/NativeTimerCreateOperationPreparationService.cpp create mode 100644 core/timers/tests/test_native_timer_create_operation_preparation_service.cpp create mode 100644 docs/development/phase-64-native-timer-create-operation-preparation.md create mode 100644 mk/phase64-native-timer-create-operation-preparation-tests.mk create mode 100755 tools/check_phase64_native_timer_create_operation_preparation.py diff --git a/Makefile b/Makefile index 46d8e3c09..5d2589d75 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,7 @@ include mk/phase64-native-timer-binding-absence-application-tests.mk include mk/phase64-native-timer-absence-readback-expectation-tests.mk include mk/phase64-native-timer-absence-readback-verification-tests.mk include mk/phase64-mutation-operation-repository-tests.mk +include mk/phase64-native-timer-create-operation-preparation-tests.mk include mk/phase64-native-timer-delete-operation-completion-tests.mk include mk/phase64-native-timer-delete-operation-preparation-tests.mk include mk/phase64-native-timer-delete-dispatch-tests.mk diff --git a/core/timers/include/NativeTimerCreateOperationPayload.h b/core/timers/include/NativeTimerCreateOperationPayload.h new file mode 100644 index 000000000..6508a79cb --- /dev/null +++ b/core/timers/include/NativeTimerCreateOperationPayload.h @@ -0,0 +1,38 @@ +#pragma once + +#include "NativeTimerSpecification.h" + +#include +#include + +namespace vdrsuite::timers +{ + +struct NativeTimerCreateOperationPayload +{ + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch = 0; + + std::string nativeTimerBindingId; + std::string backendId; + std::uint64_t backendGeneration = 0; + + NativeTimerSpecification expectedSpecification; +}; + +bool nativeTimerCreateOperationPayloadValid( + const NativeTimerCreateOperationPayload& payload); + +std::string serializeNativeTimerCreateOperationPayload( + const NativeTimerCreateOperationPayload& payload); + +bool parseNativeTimerCreateOperationPayload( + const std::string& serialized, + NativeTimerCreateOperationPayload& payload); + +std::string nativeTimerCreateOperationPayloadFingerprint( + const NativeTimerCreateOperationPayload& payload); + +} diff --git a/core/timers/include/NativeTimerCreateOperationPreparationService.h b/core/timers/include/NativeTimerCreateOperationPreparationService.h new file mode 100644 index 000000000..ee07b5f10 --- /dev/null +++ b/core/timers/include/NativeTimerCreateOperationPreparationService.h @@ -0,0 +1,96 @@ +#pragma once + +#include "MutationOperation.h" +#include "NativeTimerCreateOperationPayload.h" +#include "NativeTimerSpecification.h" + +#include +#include + +namespace vdrsuite::operations +{ +class MutationOperationRepository; +} + +namespace vdrsuite::timers +{ + +class TimerAssignmentRepository; +class TimerIntentRepository; + +enum class NativeTimerCreateOperationPreparationStatus +{ + prepared, + alreadyPrepared, + intentNotFound, + assignmentNotFound, + intentRevisionConflict, + intentStateConflict, + assignmentRevisionConflict, + assignmentEpochConflict, + assignmentStateConflict, + backendConflict, + generationConflict, + channelConflict, + bindingAlreadyPresent, + idempotencyConflict, + operationConflict, + operationStateConflict, + operationRepositoryError, + invalid, +}; + +struct NativeTimerCreateOperationPreparationRequest +{ + std::string operationId; + std::string idempotencyKey; + std::string actorId; + std::string requestFingerprint; + + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t expectedAssignmentEpoch = 0; + std::string nativeTimerBindingId; + + std::string expectedBackendId; + std::uint64_t expectedBackendGeneration = 0; + + NativeTimerSpecification expectedSpecification; + + std::int64_t requestedAt = 0; + std::int64_t deadline = 0; +}; + +struct NativeTimerCreateOperationPreparationResult +{ + NativeTimerCreateOperationPreparationStatus status = + NativeTimerCreateOperationPreparationStatus::operationRepositoryError; + vdrsuite::operations::MutationOperation operation; + NativeTimerCreateOperationPayload payload; + + bool ok() const + { + return status == NativeTimerCreateOperationPreparationStatus::prepared + || status == NativeTimerCreateOperationPreparationStatus::alreadyPrepared; + } +}; + +class NativeTimerCreateOperationPreparationService +{ +public: + NativeTimerCreateOperationPreparationService( + TimerIntentRepository& intentRepository, + TimerAssignmentRepository& assignmentRepository, + vdrsuite::operations::MutationOperationRepository& operationRepository); + + NativeTimerCreateOperationPreparationResult prepare( + const NativeTimerCreateOperationPreparationRequest& request); + +private: + TimerIntentRepository& intentRepository_; + TimerAssignmentRepository& assignmentRepository_; + vdrsuite::operations::MutationOperationRepository& operationRepository_; +}; + +} diff --git a/core/timers/src/NativeTimerCreateOperationPayload.cpp b/core/timers/src/NativeTimerCreateOperationPayload.cpp new file mode 100644 index 000000000..0c2b355ba --- /dev/null +++ b/core/timers/src/NativeTimerCreateOperationPayload.cpp @@ -0,0 +1,237 @@ +#include "NativeTimerCreateOperationPayload.h" + +#include +#include +#include +#include +#include +#include + +namespace vdrsuite::timers +{ +namespace +{ +constexpr std::size_t kMaxIdentityLength = 160; +constexpr const char* kPayloadPrefix = + "native-timer-create-operation-payload/1|"; +constexpr const char* kFingerprintPrefix = + "native-timer-create-operation-payload-fingerprint/1|"; + +bool validIdentity(const std::string& value) +{ + return !value.empty() && value.size() <= kMaxIdentityLength; +} + +std::string normalizedHhmm(const std::string& value) +{ + return std::string(4 - value.size(), '0') + value; +} + +void appendField(std::string& output, const std::string& value) +{ + output += std::to_string(value.size()); + output += ':'; + output += value; + output += '|'; +} + +void appendUnsigned(std::string& output, std::uint64_t value) +{ + appendField(output, std::to_string(value)); +} + +void appendSigned(std::string& output, std::int32_t value) +{ + appendField(output, std::to_string(value)); +} + +void appendBool(std::string& output, bool value) +{ + appendField(output, std::string(value ? "1" : "0")); +} + +std::string canonicalBody(const NativeTimerCreateOperationPayload& payload) +{ + if (!nativeTimerCreateOperationPayloadValid(payload)) return {}; + + std::string output; + appendField(output, payload.timerAssignmentId); + appendField(output, payload.expectedAssignmentRevision); + appendField(output, payload.expectedIntentRevision); + appendUnsigned(output, payload.assignmentEpoch); + appendField(output, payload.nativeTimerBindingId); + appendField(output, payload.backendId); + appendUnsigned(output, payload.backendGeneration); + + const auto& specification = payload.expectedSpecification; + appendField(output, specification.channelId); + appendField(output, specification.title); + appendField(output, specification.directory); + appendField(output, specification.day); + appendField(output, specification.weekdays); + appendField(output, normalizedHhmm(specification.startTime)); + appendField(output, normalizedHhmm(specification.endTime)); + appendSigned(output, specification.priority); + appendSigned(output, specification.lifetime); + appendBool(output, specification.enabled); + appendBool(output, specification.vps); + return output; +} + +bool readField( + const std::string& input, + std::size_t& position, + std::string& value) +{ + if (position >= input.size()) return false; + + std::size_t length = 0; + bool digitSeen = false; + while (position < input.size() && input[position] != ':') + { + const char ch = input[position++]; + if (ch < '0' || ch > '9') return false; + digitSeen = true; + const std::size_t digit = static_cast(ch - '0'); + if (length > (std::numeric_limits::max() - digit) / 10) + return false; + length = length * 10 + digit; + } + if (!digitSeen || position >= input.size() || input[position] != ':') + return false; + ++position; + if (length > input.size() - position) return false; + value = input.substr(position, length); + position += length; + if (position >= input.size() || input[position] != '|') return false; + ++position; + return true; +} + +bool parseUnsigned(const std::string& token, std::uint64_t& value) +{ + if (token.empty()) return false; + value = 0; + for (char ch : token) + { + if (ch < '0' || ch > '9') return false; + const std::uint64_t digit = static_cast(ch - '0'); + if (value > (std::numeric_limits::max() - digit) / 10) + return false; + value = value * 10 + digit; + } + return value > 0; +} + +bool parseSmallInt(const std::string& token, std::int32_t& value) +{ + if (token.empty() || token.size() > 3) return false; + int parsed = 0; + for (char ch : token) + { + if (ch < '0' || ch > '9') return false; + parsed = parsed * 10 + (ch - '0'); + } + if (parsed < 0 || parsed > 99) return false; + value = parsed; + return true; +} + +bool parseBool(const std::string& token, bool& value) +{ + if (token == "0") + { + value = false; + return true; + } + if (token == "1") + { + value = true; + return true; + } + return false; +} +} + +bool nativeTimerCreateOperationPayloadValid( + const NativeTimerCreateOperationPayload& payload) +{ + return validIdentity(payload.timerAssignmentId) + && validIdentity(payload.expectedAssignmentRevision) + && validIdentity(payload.expectedIntentRevision) + && payload.assignmentEpoch > 0 + && validIdentity(payload.nativeTimerBindingId) + && validIdentity(payload.backendId) + && payload.backendGeneration > 0 + && nativeTimerSpecificationValid(payload.expectedSpecification); +} + +std::string serializeNativeTimerCreateOperationPayload( + const NativeTimerCreateOperationPayload& payload) +{ + const std::string body = canonicalBody(payload); + if (body.empty()) return {}; + return std::string(kPayloadPrefix) + body; +} + +bool parseNativeTimerCreateOperationPayload( + const std::string& serialized, + NativeTimerCreateOperationPayload& payload) +{ + const std::string prefix(kPayloadPrefix); + if (serialized.compare(0, prefix.size(), prefix) != 0) return false; + + std::size_t position = prefix.size(); + std::vector fields; + fields.reserve(18); + while (position < serialized.size()) + { + std::string field; + if (!readField(serialized, position, field)) return false; + fields.push_back(std::move(field)); + if (fields.size() > 18) return false; + } + if (fields.size() != 18) return false; + + NativeTimerCreateOperationPayload parsed; + parsed.timerAssignmentId = fields[0]; + parsed.expectedAssignmentRevision = fields[1]; + parsed.expectedIntentRevision = fields[2]; + if (!parseUnsigned(fields[3], parsed.assignmentEpoch)) return false; + parsed.nativeTimerBindingId = fields[4]; + parsed.backendId = fields[5]; + if (!parseUnsigned(fields[6], parsed.backendGeneration)) return false; + + auto& specification = parsed.expectedSpecification; + specification.channelId = fields[7]; + specification.title = fields[8]; + specification.directory = fields[9]; + specification.day = fields[10]; + specification.weekdays = fields[11]; + specification.startTime = fields[12]; + specification.endTime = fields[13]; + if (!parseSmallInt(fields[14], specification.priority) + || !parseSmallInt(fields[15], specification.lifetime) + || !parseBool(fields[16], specification.enabled) + || !parseBool(fields[17], specification.vps)) + { + return false; + } + + if (!nativeTimerCreateOperationPayloadValid(parsed)) return false; + if (serializeNativeTimerCreateOperationPayload(parsed) != serialized) + return false; + + payload = std::move(parsed); + return true; +} + +std::string nativeTimerCreateOperationPayloadFingerprint( + const NativeTimerCreateOperationPayload& payload) +{ + const std::string body = canonicalBody(payload); + if (body.empty()) return {}; + return std::string(kFingerprintPrefix) + body; +} + +} diff --git a/core/timers/src/NativeTimerCreateOperationPreparationService.cpp b/core/timers/src/NativeTimerCreateOperationPreparationService.cpp new file mode 100644 index 000000000..c670e4c4a --- /dev/null +++ b/core/timers/src/NativeTimerCreateOperationPreparationService.cpp @@ -0,0 +1,229 @@ +#include "NativeTimerCreateOperationPreparationService.h" + +#include "MutationOperationRepository.h" +#include "TimerAssignmentRepository.h" +#include "TimerIntentRepository.h" + +#include +#include + +namespace vdrsuite::timers +{ +namespace +{ +constexpr std::size_t kMaxIdentityLength = 160; +constexpr std::size_t kMaxRequestFingerprintLength = 512; +constexpr const char* kPayloadType = "native.timer.create"; +constexpr std::uint32_t kPayloadVersion = 1; + +bool validIdentity(const std::string& value) +{ + return !value.empty() && value.size() <= kMaxIdentityLength; +} + +bool validRequest(const NativeTimerCreateOperationPreparationRequest& request) +{ + return validIdentity(request.operationId) + && validIdentity(request.idempotencyKey) + && validIdentity(request.actorId) + && !request.requestFingerprint.empty() + && request.requestFingerprint.size() <= kMaxRequestFingerprintLength + && validIdentity(request.timerAssignmentId) + && validIdentity(request.expectedAssignmentRevision) + && validIdentity(request.expectedIntentRevision) + && request.expectedAssignmentEpoch > 0 + && validIdentity(request.nativeTimerBindingId) + && validIdentity(request.expectedBackendId) + && request.expectedBackendGeneration > 0 + && nativeTimerSpecificationValid(request.expectedSpecification) + && request.requestedAt > 0 + && (request.deadline == 0 || request.deadline >= request.requestedAt); +} + +NativeTimerCreateOperationPreparationResult result( + NativeTimerCreateOperationPreparationStatus status, + const vdrsuite::operations::MutationOperation& operation = {}, + const NativeTimerCreateOperationPayload& payload = {}) +{ + NativeTimerCreateOperationPreparationResult value; + value.status = status; + value.operation = operation; + value.payload = payload; + return value; +} +} + +NativeTimerCreateOperationPreparationService:: +NativeTimerCreateOperationPreparationService( + TimerIntentRepository& intentRepository, + TimerAssignmentRepository& assignmentRepository, + vdrsuite::operations::MutationOperationRepository& operationRepository) + : intentRepository_(intentRepository), + assignmentRepository_(assignmentRepository), + operationRepository_(operationRepository) +{ +} + +NativeTimerCreateOperationPreparationResult +NativeTimerCreateOperationPreparationService::prepare( + const NativeTimerCreateOperationPreparationRequest& request) +{ + using namespace vdrsuite::operations; + + if (!validRequest(request)) + return result(NativeTimerCreateOperationPreparationStatus::invalid); + + const auto assignmentResult = + assignmentRepository_.findById(request.timerAssignmentId); + if (assignmentResult.status == TimerAssignmentRepositoryStatus::notFound) + return result( + NativeTimerCreateOperationPreparationStatus::assignmentNotFound); + if (!assignmentResult.ok()) + return result( + NativeTimerCreateOperationPreparationStatus::operationRepositoryError); + + const TimerAssignment& assignment = assignmentResult.assignment; + + const auto intentResult = intentRepository_.findById(assignment.timerIntentId); + if (intentResult.status == TimerIntentRepositoryStatus::notFound) + return result(NativeTimerCreateOperationPreparationStatus::intentNotFound); + if (!intentResult.ok()) + return result( + NativeTimerCreateOperationPreparationStatus::operationRepositoryError); + + const TimerIntent& intent = intentResult.intent; + + if (intent.intentRevision != request.expectedIntentRevision + || assignment.intentRevision != request.expectedIntentRevision) + { + return result( + NativeTimerCreateOperationPreparationStatus::intentRevisionConflict); + } + if (!timerIntentAssignable(intent.state)) + return result( + NativeTimerCreateOperationPreparationStatus::intentStateConflict); + if (assignment.assignmentRevision != request.expectedAssignmentRevision) + return result( + NativeTimerCreateOperationPreparationStatus::assignmentRevisionConflict); + if (assignment.assignmentEpoch != request.expectedAssignmentEpoch) + return result( + NativeTimerCreateOperationPreparationStatus::assignmentEpochConflict); + if (assignment.state != TimerAssignmentState::provisioning) + return result( + NativeTimerCreateOperationPreparationStatus::assignmentStateConflict); + if (!assignment.nativeTimerBindingId.empty()) + return result( + NativeTimerCreateOperationPreparationStatus::bindingAlreadyPresent); + if (assignment.backendId != request.expectedBackendId) + return result(NativeTimerCreateOperationPreparationStatus::backendConflict); + if (assignment.backendGeneration != request.expectedBackendGeneration) + return result( + NativeTimerCreateOperationPreparationStatus::generationConflict); + if (assignment.channelBinding.backendChannelId + != request.expectedSpecification.channelId) + { + return result(NativeTimerCreateOperationPreparationStatus::channelConflict); + } + + NativeTimerCreateOperationPayload createPayload; + createPayload.timerAssignmentId = assignment.timerAssignmentId; + createPayload.expectedAssignmentRevision = assignment.assignmentRevision; + createPayload.expectedIntentRevision = intent.intentRevision; + createPayload.assignmentEpoch = assignment.assignmentEpoch; + createPayload.nativeTimerBindingId = request.nativeTimerBindingId; + createPayload.backendId = assignment.backendId; + createPayload.backendGeneration = assignment.backendGeneration; + createPayload.expectedSpecification = request.expectedSpecification; + + const std::string serialized = + serializeNativeTimerCreateOperationPayload(createPayload); + const std::string payloadFingerprint = + nativeTimerCreateOperationPayloadFingerprint(createPayload); + const std::string specificationFingerprint = + nativeTimerSpecificationFingerprint(request.expectedSpecification); + if (serialized.empty() + || payloadFingerprint.empty() + || specificationFingerprint.empty()) + { + return result(NativeTimerCreateOperationPreparationStatus::invalid); + } + + MutationOperation operation; + operation.operationId = request.operationId; + operation.idempotencyKey = request.idempotencyKey; + operation.actorId = request.actorId; + operation.backendId = assignment.backendId; + operation.backendGeneration = assignment.backendGeneration; + operation.resourceType = "TimerAssignment"; + operation.resourceId = assignment.timerAssignmentId; + operation.expectedRevision = assignment.assignmentRevision; + operation.expectedResourceFingerprint = specificationFingerprint; + operation.actionFamily = "timer.create"; + operation.requestFingerprint = request.requestFingerprint; + operation.requestedAt = request.requestedAt; + operation.deadline = request.deadline; + operation.verificationPolicy = + MutationOperationVerificationPolicy::readbackRequired; + operation.state = MutationOperationState::accepted; + operation.updatedAt = request.requestedAt; + + MutationOperationPayload durablePayload; + durablePayload.operationId = request.operationId; + durablePayload.payloadType = kPayloadType; + durablePayload.payloadVersion = kPayloadVersion; + durablePayload.payload = serialized; + durablePayload.payloadFingerprint = payloadFingerprint; + + const auto reserved = + operationRepository_.reserveWithPayload(operation, durablePayload); + switch (reserved.status) + { + case MutationOperationRepositoryStatus::ok: + return result( + NativeTimerCreateOperationPreparationStatus::prepared, + reserved.operation, + createPayload); + case MutationOperationRepositoryStatus::idempotentReplay: + if (reserved.operation.state != MutationOperationState::accepted) + { + return result( + NativeTimerCreateOperationPreparationStatus::operationStateConflict, + reserved.operation, + createPayload); + } + return result( + NativeTimerCreateOperationPreparationStatus::alreadyPrepared, + reserved.operation, + createPayload); + case MutationOperationRepositoryStatus::idempotencyConflict: + return result( + NativeTimerCreateOperationPreparationStatus::idempotencyConflict, + reserved.operation, + createPayload); + case MutationOperationRepositoryStatus::operationConflict: + return result( + NativeTimerCreateOperationPreparationStatus::operationConflict, + reserved.operation, + createPayload); + case MutationOperationRepositoryStatus::invalid: + return result( + NativeTimerCreateOperationPreparationStatus::invalid, + reserved.operation, + createPayload); + case MutationOperationRepositoryStatus::notFound: + case MutationOperationRepositoryStatus::revisionConflict: + case MutationOperationRepositoryStatus::stateConflict: + case MutationOperationRepositoryStatus::storageError: + return result( + NativeTimerCreateOperationPreparationStatus::operationRepositoryError, + reserved.operation, + createPayload); + } + + return result( + NativeTimerCreateOperationPreparationStatus::operationRepositoryError, + reserved.operation, + createPayload); +} + +} diff --git a/core/timers/tests/test_native_timer_create_operation_preparation_service.cpp b/core/timers/tests/test_native_timer_create_operation_preparation_service.cpp new file mode 100644 index 000000000..56815b0f7 --- /dev/null +++ b/core/timers/tests/test_native_timer_create_operation_preparation_service.cpp @@ -0,0 +1,292 @@ +#include "Database.h" +#include "MutationOperationRepository.h" +#include "NativeTimerCreateOperationPayload.h" +#include "NativeTimerCreateOperationPreparationService.h" +#include "TimerAssignmentRepository.h" +#include "TimerIntentRepository.h" + +#include +#include + +using namespace vdrsuite::operations; +using namespace vdrsuite::timers; + +namespace +{ +NativeTimerSpecification specification() +{ + NativeTimerSpecification value; + value.channelId = "S19.2E-1-1019-10301"; + value.title = "Managed Timer"; + value.directory = "VDR-Suite"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "930"; + value.endTime = "1015"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +TimerIntent activeIntent(TimerIntentRepository& repository) +{ + TimerIntent intent; + intent.timerIntentId = "intent:1"; + intent.state = TimerIntentState::draft; + intent.createdByActorId = "actor:create"; + intent.spec.intentType = TimerIntentType::manualWindow; + intent.spec.ownerActorId = "actor:owner"; + intent.spec.channelRequirement.canonicalChannelId = "channel:ard"; + intent.spec.schedule.startAt = 1000; + intent.spec.schedule.stopAt = 2000; + intent.spec.schedule.timezone = "Europe/Berlin"; + intent.createdAt = 100; + intent.updatedAt = 100; + intent.expiresAt = 3000; + const auto created = repository.create(intent); + assert(created.ok()); + + TimerIntent active = created.intent; + active.state = TimerIntentState::active; + active.updatedAt = 101; + const auto updated = repository.update(active, created.intent.intentRevision); + assert(updated.ok()); + return updated.intent; +} + +TimerAssignment selectedAssignment( + TimerAssignmentRepository& repository, + const TimerIntent& intent) +{ + TimerAssignment assignment; + assignment.timerAssignmentId = "assignment:1"; + assignment.timerIntentId = intent.timerIntentId; + assignment.intentRevision = intent.intentRevision; + assignment.backendId = "backend:1"; + assignment.backendGeneration = 7; + assignment.state = TimerAssignmentState::selected; + assignment.role = TimerAssignmentRole::primary; + assignment.channelBinding.canonicalChannelId = "channel:ard"; + assignment.channelBinding.backendChannelId = "S19.2E-1-1019-10301"; + assignment.channelBinding.mappingSource = "canonical-channel-map"; + assignment.channelBinding.mappingRevision = "mapping:7"; + assignment.capabilityRevision = "capability:7"; + assignment.backendHealthRevision = "health:7"; + assignment.decisionPolicyVersion = "policy:1"; + assignment.decisionEvidence.reasons = {"eligible_backend"}; + assignment.createdAt = 200; + assignment.updatedAt = 200; + const auto created = repository.create(assignment); + assert(created.ok()); + return created.assignment; +} + +TimerAssignment provisioningAssignment( + TimerAssignmentRepository& repository, + const TimerAssignment& selected) +{ + TimerAssignment next = selected; + next.state = TimerAssignmentState::provisioning; + next.updatedAt = 201; + const auto updated = repository.update(next, selected.assignmentRevision); + assert(updated.ok()); + return updated.assignment; +} + +NativeTimerCreateOperationPreparationRequest requestFor( + const TimerIntent& intent, + const TimerAssignment& assignment, + const std::string& operationId = "operation:create:1", + const std::string& idempotencyKey = "idempotency:create:1") +{ + NativeTimerCreateOperationPreparationRequest request; + request.operationId = operationId; + request.idempotencyKey = idempotencyKey; + request.actorId = "actor:owner"; + request.requestFingerprint = "request:create:fingerprint:1"; + request.timerAssignmentId = assignment.timerAssignmentId; + request.expectedAssignmentRevision = assignment.assignmentRevision; + request.expectedIntentRevision = intent.intentRevision; + request.expectedAssignmentEpoch = assignment.assignmentEpoch; + request.nativeTimerBindingId = "binding:reserved:1"; + request.expectedBackendId = assignment.backendId; + request.expectedBackendGeneration = assignment.backendGeneration; + request.expectedSpecification = specification(); + request.requestedAt = 300; + request.deadline = 900; + return request; +} + +void assertPayloadCodec() +{ + NativeTimerCreateOperationPayload payload; + payload.timerAssignmentId = "assignment:1"; + payload.expectedAssignmentRevision = "2"; + payload.expectedIntentRevision = "2"; + payload.assignmentEpoch = 3; + payload.nativeTimerBindingId = "binding:reserved:1"; + payload.backendId = "backend:1"; + payload.backendGeneration = 7; + payload.expectedSpecification = specification(); + + assert(nativeTimerCreateOperationPayloadValid(payload)); + const std::string serialized = + serializeNativeTimerCreateOperationPayload(payload); + const std::string fingerprint = + nativeTimerCreateOperationPayloadFingerprint(payload); + assert(!serialized.empty()); + assert(!fingerprint.empty()); + + NativeTimerCreateOperationPayload parsed; + assert(parseNativeTimerCreateOperationPayload(serialized, parsed)); + assert(parsed.timerAssignmentId == payload.timerAssignmentId); + assert(parsed.expectedAssignmentRevision == "2"); + assert(parsed.expectedIntentRevision == "2"); + assert(parsed.assignmentEpoch == 3); + assert(parsed.nativeTimerBindingId == "binding:reserved:1"); + assert(parsed.backendId == "backend:1"); + assert(parsed.backendGeneration == 7); + assert(parsed.expectedSpecification.startTime == "0930"); + assert(nativeTimerSpecificationFingerprint(parsed.expectedSpecification) == + nativeTimerSpecificationFingerprint(payload.expectedSpecification)); + + auto padded = payload; + padded.expectedSpecification.startTime = "0930"; + assert(serializeNativeTimerCreateOperationPayload(padded) == serialized); + assert(nativeTimerCreateOperationPayloadFingerprint(padded) == fingerprint); + + assert(!parseNativeTimerCreateOperationPayload( + serialized.substr(0, serialized.size() - 1), parsed)); + assert(!parseNativeTimerCreateOperationPayload( + "native-timer-create-operation-payload/2|", parsed)); +} +} + +int main() +{ + assertPayloadCodec(); + + Database database; + assert(database.open(":memory:")); + TimerIntentRepository intentRepository(database); + TimerAssignmentRepository assignmentRepository(database); + MutationOperationRepository operationRepository(database); + assert(intentRepository.ensureSchema()); + assert(assignmentRepository.ensureSchema()); + assert(operationRepository.ensureSchema()); + + const TimerIntent intent = activeIntent(intentRepository); + const TimerAssignment selected = selectedAssignment(assignmentRepository, intent); + + NativeTimerCreateOperationPreparationService service( + intentRepository, assignmentRepository, operationRepository); + + const auto selectedRequest = requestFor(intent, selected); + assert(service.prepare(selectedRequest).status == + NativeTimerCreateOperationPreparationStatus::assignmentStateConflict); + + const TimerAssignment provisioning = + provisioningAssignment(assignmentRepository, selected); + const auto request = requestFor(intent, provisioning); + + const auto prepared = service.prepare(request); + assert(prepared.status == + NativeTimerCreateOperationPreparationStatus::prepared); + assert(prepared.operation.operationRevision == "1"); + assert(prepared.operation.state == MutationOperationState::accepted); + assert(prepared.operation.resourceType == "TimerAssignment"); + assert(prepared.operation.resourceId == provisioning.timerAssignmentId); + assert(prepared.operation.expectedRevision == + provisioning.assignmentRevision); + assert(prepared.operation.expectedResourceFingerprint == + nativeTimerSpecificationFingerprint(request.expectedSpecification)); + assert(prepared.operation.actionFamily == "timer.create"); + assert(prepared.operation.backendId == "backend:1"); + assert(prepared.operation.backendGeneration == 7); + assert(prepared.payload.nativeTimerBindingId == "binding:reserved:1"); + + const auto durablePayload = + operationRepository.findPayloadByOperationId(request.operationId); + assert(durablePayload.ok()); + assert(durablePayload.payload.payloadType == "native.timer.create"); + assert(durablePayload.payload.payloadVersion == 1); + assert(durablePayload.payload.payloadFingerprint == + nativeTimerCreateOperationPayloadFingerprint(prepared.payload)); + + NativeTimerCreateOperationPayload recovered; + assert(parseNativeTimerCreateOperationPayload( + durablePayload.payload.payload, recovered)); + assert(recovered.timerAssignmentId == provisioning.timerAssignmentId); + assert(recovered.expectedAssignmentRevision == + provisioning.assignmentRevision); + assert(recovered.expectedIntentRevision == intent.intentRevision); + assert(recovered.assignmentEpoch == provisioning.assignmentEpoch); + assert(recovered.nativeTimerBindingId == "binding:reserved:1"); + assert(recovered.backendId == provisioning.backendId); + assert(recovered.backendGeneration == provisioning.backendGeneration); + + // A new service instance can recover the exact immutable CREATE handoff. + MutationOperationRepository restartedOperationRepository(database); + NativeTimerCreateOperationPreparationService restartedService( + intentRepository, assignmentRepository, restartedOperationRepository); + const auto replay = restartedService.prepare(request); + assert(replay.status == + NativeTimerCreateOperationPreparationStatus::alreadyPrepared); + assert(replay.operation.operationRevision == "1"); + + auto staleRevision = request; + staleRevision.expectedAssignmentRevision = selected.assignmentRevision; + assert(service.prepare(staleRevision).status == + NativeTimerCreateOperationPreparationStatus::assignmentRevisionConflict); + + auto staleIntent = request; + staleIntent.expectedIntentRevision = "1"; + assert(service.prepare(staleIntent).status == + NativeTimerCreateOperationPreparationStatus::intentRevisionConflict); + + auto staleEpoch = request; + ++staleEpoch.expectedAssignmentEpoch; + assert(service.prepare(staleEpoch).status == + NativeTimerCreateOperationPreparationStatus::assignmentEpochConflict); + + auto wrongBackend = request; + wrongBackend.expectedBackendId = "backend:other"; + assert(service.prepare(wrongBackend).status == + NativeTimerCreateOperationPreparationStatus::backendConflict); + + auto wrongGeneration = request; + ++wrongGeneration.expectedBackendGeneration; + assert(service.prepare(wrongGeneration).status == + NativeTimerCreateOperationPreparationStatus::generationConflict); + + auto wrongChannel = request; + wrongChannel.expectedSpecification.channelId = "channel:wrong"; + assert(service.prepare(wrongChannel).status == + NativeTimerCreateOperationPreparationStatus::channelConflict); + + auto changedDesiredState = request; + changedDesiredState.expectedSpecification.title = "Changed Timer"; + assert(service.prepare(changedDesiredState).status == + NativeTimerCreateOperationPreparationStatus::operationConflict); + + auto competingOperation = request; + competingOperation.operationId = "operation:create:other"; + assert(service.prepare(competingOperation).status == + NativeTimerCreateOperationPreparationStatus::idempotencyConflict); + + const auto dispatching = operationRepository.transition( + request.operationId, + "1", + MutationOperationState::accepted, + MutationOperationState::dispatching, + "", + 301); + assert(dispatching.ok()); + assert(service.prepare(request).status == + NativeTimerCreateOperationPreparationStatus::operationStateConflict); + + std::cout << "test_native_timer_create_operation_preparation_service passed\n"; + return 0; +} diff --git a/docs/development/phase-64-native-timer-create-operation-preparation.md b/docs/development/phase-64-native-timer-create-operation-preparation.md new file mode 100644 index 000000000..bac47af17 --- /dev/null +++ b/docs/development/phase-64-native-timer-create-operation-preparation.md @@ -0,0 +1,96 @@ +# Phase 64 — Native Timer CREATE Operation Preparation + +This component closes the pre-dispatch durability boundary for managed native +Timer CREATE. It performs **no native mutation** and does not dispatch an Agent +command. + +## Why CREATE needs a durable payload + +DELETE can reconstruct its immutable dispatch handoff from the durable +`MutationOperation` plus an already existing `NativeTimerBinding`. CREATE cannot: +before native creation there is no native Timer identity and no binding row yet. +The pre-reserved Suite binding identity and desired native Timer specification +must therefore survive a process crash independently of RAM. + +The shared `MutationOperationRepository::reserveWithPayload()` provides the +required **atomic operation + payload reservation**. The accepted operation and +its immutable versioned CREATE payload become durable in one SQLite transaction. +There is no accepted operation with a missing CREATE handoff. + +## Preparation fences + +Before reservation the service reloads the current durable `TimerAssignment` +and owning `TimerIntent` and requires all of the following: + +- exact current TimerIntent revision on both intent and assignment; +- a currently assignable TimerIntent state; +- exact assignment revision; +- exact repository-issued assignment epoch; +- assignment state `provisioning`; +- no existing `nativeTimerBindingId` on the assignment; +- exact selected backend ID and backend generation; +- desired specification channel equal to the assignment's durable backend + channel binding; +- a valid pre-reserved stable `nativeTimerBindingId`; +- a valid backend-neutral `NativeTimerSpecification`. + +A stale intent, assignment revision, assignment epoch, backend generation or +channel mapping fails closed before operation acceptance. + +## Immutable CREATE payload + +`NativeTimerCreateOperationPayload` version 1 contains only the information +needed to reconstruct the future CREATE dispatch after restart: + +- TimerAssignment identity and expected assignment revision; +- expected TimerIntent revision; +- assignment epoch; +- pre-reserved NativeTimerBinding identity; +- backend ID and generation; +- complete desired `NativeTimerSpecification`. + +The codec uses a deterministic length-prefixed representation. HHMM values are +canonicalized, so semantically identical `930` and `0930` inputs produce the +same serialized payload and fingerprint. The parser rejects malformed, +non-canonical or version-mismatched payloads and reserializes the decoded value +to prove canonical equality. + +The generic repository stores this as payload type `native.timer.create`, +payload version `1`, immutable bytes and immutable payload fingerprint. + +## ADR-0042 operation identity + +CREATE targets the durable `TimerAssignment`, because the NativeTimerBinding +does not exist yet: + +```text +resourceType = TimerAssignment +resourceId = timerAssignmentId +expectedRevision = provisioning assignmentRevision +expectedResourceFingerprint = NativeTimerSpecification fingerprint +actionFamily = timer.create +verificationPolicy = readback_required +``` + +The normal operation idempotency scope remains actor/backend/resource/action + +idempotency key. An exact replay with the exact payload returns +`alreadyPrepared`; changed desired state or changed reserved CREATE handoff is +an operation conflict. + +## Crash/restart rule + +After restart the immutable payload is loaded by `operationId`; no caller must +reconstruct it from UI input or guess a new binding identity. This is a +prerequisite for the later durable dispatch/start path and **no blind retry** +rule. + +If an operation has already advanced beyond `accepted`, preparation does not +silently recreate or replace the payload. Later dispatch/reconciliation logic +owns that operation state. + +## Scope boundary + +This component does not dispatch, call Agent, SuiteBridge or VDR, advertise a +write command, create a native Timer, or mark an assignment bound. It only +produces durable pre-dispatch authority. Real runtime mutation remains closed +until the later execution path and exact-candidate yaVDR acceptance are ready. diff --git a/mk/phase64-native-timer-create-operation-preparation-tests.mk b/mk/phase64-native-timer-create-operation-preparation-tests.mk new file mode 100644 index 000000000..435aa2814 --- /dev/null +++ b/mk/phase64-native-timer-create-operation-preparation-tests.mk @@ -0,0 +1,25 @@ +.PHONY: test-phase64-native-timer-create-operation-preparation-architecture test-phase64-native-timer-create-operation-preparation + +test-phase64-native-timer-create-operation-preparation-architecture: + python3 tools/check_phase64_native_timer_create_operation_preparation.py + +test-phase64-native-timer-create-operation-preparation: test-phase64-native-timer-create-operation-preparation-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/operations/include -Icore/timers/include \ + $(SQLITE_SRC) \ + core/operations/src/MutationOperation.cpp \ + core/operations/src/MutationOperationRepository.cpp \ + core/timers/src/TimerIntent.cpp \ + core/timers/src/TimerIntentRepository.cpp \ + core/timers/src/TimerAssignment.cpp \ + core/timers/src/TimerAssignmentRepository.cpp \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/src/NativeTimerCreateOperationPayload.cpp \ + core/timers/src/NativeTimerCreateOperationPreparationService.cpp \ + core/timers/tests/test_native_timer_create_operation_preparation_service.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_create_operation_preparation_service + $(BUILD_DIR)/test_native_timer_create_operation_preparation_service + +test-fast: test-phase64-native-timer-create-operation-preparation +test-architecture: test-phase64-native-timer-create-operation-preparation-architecture diff --git a/tools/check_phase64_native_timer_create_operation_preparation.py b/tools/check_phase64_native_timer_create_operation_preparation.py new file mode 100755 index 000000000..69a4dcd23 --- /dev/null +++ b/tools/check_phase64_native_timer_create_operation_preparation.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +paths = { + "payload_h": "core/timers/include/NativeTimerCreateOperationPayload.h", + "payload_cpp": "core/timers/src/NativeTimerCreateOperationPayload.cpp", + "service_h": "core/timers/include/NativeTimerCreateOperationPreparationService.h", + "service_cpp": "core/timers/src/NativeTimerCreateOperationPreparationService.cpp", + "test": "core/timers/tests/test_native_timer_create_operation_preparation_service.cpp", + "doc": "docs/development/phase-64-native-timer-create-operation-preparation.md", + "mk": "mk/phase64-native-timer-create-operation-preparation-tests.mk", + "makefile": "Makefile", +} +for relative in paths.values(): + if not (ROOT / relative).is_file(): + raise SystemExit(f"missing native Timer CREATE preparation file: {relative}") + +payload_h = (ROOT / paths["payload_h"]).read_text(encoding="utf-8") +payload_cpp = (ROOT / paths["payload_cpp"]).read_text(encoding="utf-8") +service_h = (ROOT / paths["service_h"]).read_text(encoding="utf-8") +service_cpp = (ROOT / paths["service_cpp"]).read_text(encoding="utf-8") +test = (ROOT / paths["test"]).read_text(encoding="utf-8") +doc = (ROOT / paths["doc"]).read_text(encoding="utf-8") +mk = (ROOT / paths["mk"]).read_text(encoding="utf-8") +makefile = (ROOT / paths["makefile"]).read_text(encoding="utf-8") + +for token in [ + "NativeTimerCreateOperationPayload", + "expectedAssignmentRevision", + "expectedIntentRevision", + "assignmentEpoch", + "nativeTimerBindingId", + "backendGeneration", + "NativeTimerSpecification", + "serializeNativeTimerCreateOperationPayload", + "parseNativeTimerCreateOperationPayload", + "nativeTimerCreateOperationPayloadFingerprint", +]: + if token not in payload_h: + raise SystemExit(f"missing CREATE payload header marker: {token}") + +for token in [ + 'native-timer-create-operation-payload/1|', + 'native-timer-create-operation-payload-fingerprint/1|', + "normalizedHhmm", + "fields.reserve(18)", + "serializeNativeTimerCreateOperationPayload(parsed) != serialized", +]: + if token not in payload_cpp: + raise SystemExit(f"missing CREATE payload codec marker: {token}") + +for token in [ + "NativeTimerCreateOperationPreparationStatus", + "expectedAssignmentEpoch", + "expectedBackendId", + "class NativeTimerCreateOperationPreparationService", +]: + if token not in service_h: + raise SystemExit(f"missing CREATE preparation header marker: {token}") + +for token in [ + "intentRepository_.findById(assignment.timerIntentId)", + "timerIntentAssignable(intent.state)", + "assignment.assignmentRevision != request.expectedAssignmentRevision", + "assignment.assignmentEpoch != request.expectedAssignmentEpoch", + "assignment.state != TimerAssignmentState::provisioning", + "!assignment.nativeTimerBindingId.empty()", + "assignment.channelBinding.backendChannelId", + 'operation.resourceType = "TimerAssignment"', + 'operation.actionFamily = "timer.create"', + "MutationOperationVerificationPolicy::readbackRequired", + "operationRepository_.reserveWithPayload(operation, durablePayload)", +]: + if token not in service_cpp: + raise SystemExit(f"missing CREATE preparation source marker: {token}") + +for forbidden in [ + "BackendAgent", "SuiteBridge", "SVDRP", "RestfulApi", "IHttpClient", + "VdrTimerOperationRequest", "mutations=enabled", +]: + if forbidden in payload_h + payload_cpp + service_h + service_cpp: + raise SystemExit(f"premature CREATE preparation boundary crossing: {forbidden}") + +for token in [ + "assertPayloadCodec", + "assignmentStateConflict", + "findPayloadByOperationId", + "alreadyPrepared", + "assignmentRevisionConflict", + "intentRevisionConflict", + "assignmentEpochConflict", + "backendConflict", + "generationConflict", + "channelConflict", + "operationConflict", + "idempotencyConflict", + "operationStateConflict", + "test_native_timer_create_operation_preparation_service passed", +]: + if token not in test: + raise SystemExit(f"missing CREATE preparation regression marker: {token}") + +for token in [ + "atomic operation + payload reservation", + "provisioning", + "TimerIntent", + "assignment epoch", + "restart", + "no blind retry", + "does not dispatch", +]: + if token not in doc: + raise SystemExit(f"missing CREATE preparation documentation marker: {token}") + +for token in [ + "test-phase64-native-timer-create-operation-preparation-architecture", + "test-phase64-native-timer-create-operation-preparation:", + "test-fast: test-phase64-native-timer-create-operation-preparation", + "test-architecture: test-phase64-native-timer-create-operation-preparation-architecture", +]: + if token not in mk: + raise SystemExit(f"missing CREATE preparation make marker: {token}") + +if "include mk/phase64-native-timer-create-operation-preparation-tests.mk" not in makefile: + raise SystemExit("CREATE preparation make fragment is not included") + +# This slice must remain a Control-Plane contract only; runtime consumption is +# opened later together with durable dispatch/start fencing. +for scan_root in [ + ROOT / "apps", ROOT / "api", ROOT / "core" / "agent", + ROOT / "core" / "daemon", ROOT / "core" / "http", + ROOT / "core" / "runtime", ROOT / "core" / "vdr", + ROOT / "vdr-plugin-suite-bridge", +]: + if not scan_root.exists(): + continue + for path in scan_root.rglob("*"): + if not path.is_file() or path.suffix not in { + ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".inc", ".mk" + }: + continue + text = path.read_text(encoding="utf-8", errors="ignore") + if "NativeTimerCreateOperationPreparationService" in text: + raise SystemExit( + "premature CREATE preparation runtime wiring: " + + str(path.relative_to(ROOT)) + ) + +print("Phase-64 native Timer CREATE operation preparation check passed") From f4ba00555a86a685795be86321b82e7ee26f6f24 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 15:13:52 +0200 Subject: [PATCH 16/85] Phase 64: reserve Agent commands before activation --- Makefile | 1 + .../include/BackendAgentCommandReservation.h | 95 ++++ .../src/BackendAgentCommandReservation.cpp | 483 ++++++++++++++++++ ...test_backend_agent_command_reservation.cpp | 116 +++++ ...se-64-backend-agent-command-reservation.md | 50 ++ ...backend-agent-command-reservation-tests.mk | 24 + ...ase64_backend_agent_command_reservation.py | 99 ++++ 7 files changed, 868 insertions(+) create mode 100644 core/agent/include/BackendAgentCommandReservation.h create mode 100644 core/agent/src/BackendAgentCommandReservation.cpp create mode 100644 core/agent/tests/test_backend_agent_command_reservation.cpp create mode 100644 docs/development/phase-64-backend-agent-command-reservation.md create mode 100644 mk/phase64-backend-agent-command-reservation-tests.mk create mode 100755 tools/check_phase64_backend_agent_command_reservation.py diff --git a/Makefile b/Makefile index 5d2589d75..0154e12f3 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ include mk/phase62-idle-runtime-acceptance.mk include mk/phase62-retention-runtime-acceptance.mk include mk/phase63-runtime-acceptance.mk include mk/agent-tests.mk +include mk/phase64-backend-agent-command-reservation-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk diff --git a/core/agent/include/BackendAgentCommandReservation.h b/core/agent/include/BackendAgentCommandReservation.h new file mode 100644 index 000000000..b81f1d3f3 --- /dev/null +++ b/core/agent/include/BackendAgentCommandReservation.h @@ -0,0 +1,95 @@ +#pragma once + +#include "BackendAgentCommand.h" +#include "BackendAgentLocalProvider.h" + +#include +#include + +class BackendAgentCommandRepository; +class Database; + +enum class BackendAgentCommandReservationStatus +{ + reserved, + alreadyReserved, + activated, + alreadyActivated, + notFound, + conflict, + storageError, + invalid, +}; + +struct BackendAgentCommandReservation +{ + BackendAgentCommandAssignment assignment; + bool localProviderSelectionPresent = false; + vdrsuite::agent::BackendAgentLocalProviderSelection localProviderSelection; + std::string assignmentFingerprint; + std::string localProviderSelectionIdentity; +}; + +struct BackendAgentCommandReservationResult +{ + BackendAgentCommandReservationStatus status = + BackendAgentCommandReservationStatus::storageError; + BackendAgentCommandReservation reservation; + + bool ok() const + { + return status == BackendAgentCommandReservationStatus::reserved + || status == BackendAgentCommandReservationStatus::alreadyReserved; + } +}; + +struct BackendAgentCommandActivationResult +{ + BackendAgentCommandReservationStatus status = + BackendAgentCommandReservationStatus::storageError; + BackendAgentCommandAssignment assignment; + + bool ok() const + { + return status == BackendAgentCommandReservationStatus::activated + || status == BackendAgentCommandReservationStatus::alreadyActivated; + } +}; + +class BackendAgentCommandReservationRepository +{ +public: + explicit BackendAgentCommandReservationRepository(Database& database); + + bool ensureSchema(); + + BackendAgentCommandReservationResult reserve( + const BackendAgentCommandAssignment& assignment, + const vdrsuite::agent::BackendAgentLocalProviderSelection* selection = nullptr); + + BackendAgentCommandReservationResult findByCommandId( + const std::string& commandId) const; + + BackendAgentCommandReservationResult findForOperation( + const std::string& backendId, + const std::string& operationId, + const std::string& commandType) const; + +private: + Database& database_; +}; + +class BackendAgentCommandReservationActivationService +{ +public: + BackendAgentCommandReservationActivationService( + BackendAgentCommandReservationRepository& reservationRepository, + BackendAgentCommandRepository& commandRepository); + + BackendAgentCommandActivationResult activate( + const std::string& commandId); + +private: + BackendAgentCommandReservationRepository& reservationRepository_; + BackendAgentCommandRepository& commandRepository_; +}; diff --git a/core/agent/src/BackendAgentCommandReservation.cpp b/core/agent/src/BackendAgentCommandReservation.cpp new file mode 100644 index 000000000..2d87e9ac4 --- /dev/null +++ b/core/agent/src/BackendAgentCommandReservation.cpp @@ -0,0 +1,483 @@ +#include "BackendAgentCommandReservation.h" + +#include "BackendAgentCommandDelivery.h" +#include "Database.h" + +#include + +#include +#include + +namespace +{ +bool bindText(sqlite3_stmt* statement, int index, const std::string& value) +{ + return sqlite3_bind_text( + statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK; +} + +bool bindInt64(sqlite3_stmt* statement, int index, std::int64_t value) +{ + return sqlite3_bind_int64(statement, index, value) == SQLITE_OK; +} + +std::string columnText(sqlite3_stmt* statement, int column) +{ + const unsigned char* value = sqlite3_column_text(statement, column); + return value == nullptr + ? std::string() + : std::string(reinterpret_cast(value)); +} + +BackendAgentCommandReservationResult reservationResult( + BackendAgentCommandReservationStatus status, + const BackendAgentCommandReservation& reservation = {}) +{ + BackendAgentCommandReservationResult value; + value.status = status; + value.reservation = reservation; + return value; +} + +BackendAgentCommandActivationResult activationResult( + BackendAgentCommandReservationStatus status, + const BackendAgentCommandAssignment& assignment = {}) +{ + BackendAgentCommandActivationResult value; + value.status = status; + value.assignment = assignment; + return value; +} + +bool validReservationInput( + const BackendAgentCommandAssignment& assignment, + const vdrsuite::agent::BackendAgentLocalProviderSelection* selection) +{ + if (!backendAgentCommandValidAssignment(assignment)) return false; + if (selection == nullptr) return true; + return vdrsuite::agent::backendAgentLocalProviderValidSelection(*selection) + && selection->backendId == assignment.backendId + && selection->requiredCapability == assignment.commandType + && !vdrsuite::agent::backendAgentLocalProviderSelectionIdentity(*selection).empty(); +} + +BackendAgentCommandReservation makeReservation( + const BackendAgentCommandAssignment& assignment, + const vdrsuite::agent::BackendAgentLocalProviderSelection* selection) +{ + BackendAgentCommandReservation value; + value.assignment = assignment; + value.assignment.present = true; + value.assignmentFingerprint = backendAgentCommandFingerprint(assignment); + if (selection != nullptr) + { + value.localProviderSelectionPresent = true; + value.localProviderSelection = *selection; + value.localProviderSelectionIdentity = + vdrsuite::agent::backendAgentLocalProviderSelectionIdentity(*selection); + } + return value; +} + +bool sameReservation( + const BackendAgentCommandReservation& left, + const BackendAgentCommandReservation& right) +{ + return left.assignment.commandId == right.assignment.commandId + && left.assignmentFingerprint == right.assignmentFingerprint + && left.localProviderSelectionPresent == right.localProviderSelectionPresent + && left.localProviderSelectionIdentity == right.localProviderSelectionIdentity; +} + +constexpr const char* kReservationColumns = + "command_id,backend_id,operation_id,command_type,assignment_fingerprint," + "protocol_version,request_id,correlation_id,job_id,attempt_id,claim_epoch," + "agent_id,agent_instance_id,backend_generation,payload_version,payload," + "request_fingerprint,verification_policy,assigned_at,deadline," + "selection_present,selection_identity,authority_domain,provider_id,provider_kind," + "ownership_generation,provider_instance_epoch,provider_generation," + "capability_revision,required_capability"; + +bool readReservation( + sqlite3_stmt* statement, + BackendAgentCommandReservation& reservation) +{ + auto& assignment = reservation.assignment; + assignment.present = true; + assignment.commandId = columnText(statement, 0); + assignment.backendId = columnText(statement, 1); + assignment.operationId = columnText(statement, 2); + assignment.commandType = columnText(statement, 3); + reservation.assignmentFingerprint = columnText(statement, 4); + assignment.protocolVersion = columnText(statement, 5); + assignment.requestId = columnText(statement, 6); + assignment.correlationId = columnText(statement, 7); + assignment.jobId = columnText(statement, 8); + assignment.attemptId = columnText(statement, 9); + assignment.claimEpoch = static_cast(sqlite3_column_int64(statement, 10)); + assignment.agentId = columnText(statement, 11); + assignment.agentInstanceId = columnText(statement, 12); + assignment.backendGeneration = static_cast(sqlite3_column_int64(statement, 13)); + assignment.payloadVersion = static_cast(sqlite3_column_int64(statement, 14)); + assignment.payload = columnText(statement, 15); + assignment.requestFingerprint = columnText(statement, 16); + assignment.verificationPolicy = columnText(statement, 17); + assignment.assignedAt = sqlite3_column_int64(statement, 18); + assignment.deadline = sqlite3_column_int64(statement, 19); + + reservation.localProviderSelectionPresent = sqlite3_column_int(statement, 20) != 0; + reservation.localProviderSelectionIdentity = columnText(statement, 21); + if (reservation.localProviderSelectionPresent) + { + auto& selection = reservation.localProviderSelection; + selection.backendId = assignment.backendId; + selection.authorityDomain = columnText(statement, 22); + selection.providerId = columnText(statement, 23); + selection.providerKind = columnText(statement, 24); + selection.ownershipGeneration = + static_cast(sqlite3_column_int64(statement, 25)); + selection.providerInstanceEpoch = columnText(statement, 26); + selection.providerGeneration = + static_cast(sqlite3_column_int64(statement, 27)); + selection.capabilityRevision = + static_cast(sqlite3_column_int64(statement, 28)); + selection.requiredCapability = columnText(statement, 29); + } + + if (!backendAgentCommandValidAssignment(assignment) + || reservation.assignmentFingerprint != backendAgentCommandFingerprint(assignment)) + { + return false; + } + if (!reservation.localProviderSelectionPresent) + return reservation.localProviderSelectionIdentity.empty(); + + return vdrsuite::agent::backendAgentLocalProviderValidSelection( + reservation.localProviderSelection) + && reservation.localProviderSelection.backendId == assignment.backendId + && reservation.localProviderSelection.requiredCapability == assignment.commandType + && reservation.localProviderSelectionIdentity == + vdrsuite::agent::backendAgentLocalProviderSelectionIdentity( + reservation.localProviderSelection); +} + +bool selectReservation( + Database& database, + const std::string& whereClause, + const std::string& first, + const std::string& second, + const std::string& third, + BackendAgentCommandReservation& reservation, + bool& found) +{ + found = false; + sqlite3_stmt* statement = nullptr; + const std::string sql = std::string("SELECT ") + kReservationColumns + + " FROM backend_agent_command_reservations WHERE " + whereClause + + " LIMIT 1;"; + if (sqlite3_prepare_v2( + database.handle(), sql.c_str(), -1, &statement, nullptr) != SQLITE_OK) + return false; + bool bound = bindText(statement, 1, first); + if (!second.empty()) bound = bound && bindText(statement, 2, second); + if (!third.empty()) bound = bound && bindText(statement, 3, third); + if (!bound) + { + sqlite3_finalize(statement); + return false; + } + const int step = sqlite3_step(statement); + if (step == SQLITE_ROW) + { + found = readReservation(statement, reservation); + sqlite3_finalize(statement); + return found; + } + sqlite3_finalize(statement); + return step == SQLITE_DONE; +} + +bool insertReservation( + Database& database, + const BackendAgentCommandReservation& reservation) +{ + const auto& a = reservation.assignment; + const auto& s = reservation.localProviderSelection; + sqlite3_stmt* statement = nullptr; + const std::string sql = + std::string("INSERT INTO backend_agent_command_reservations(") + + kReservationColumns + + ") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"; + const bool prepared = sqlite3_prepare_v2( + database.handle(), sql.c_str(), -1, &statement, nullptr) == SQLITE_OK; + const bool bound = prepared + && bindText(statement, 1, a.commandId) + && bindText(statement, 2, a.backendId) + && bindText(statement, 3, a.operationId) + && bindText(statement, 4, a.commandType) + && bindText(statement, 5, reservation.assignmentFingerprint) + && bindText(statement, 6, a.protocolVersion) + && bindText(statement, 7, a.requestId) + && bindText(statement, 8, a.correlationId) + && bindText(statement, 9, a.jobId) + && bindText(statement, 10, a.attemptId) + && bindInt64(statement, 11, static_cast(a.claimEpoch)) + && bindText(statement, 12, a.agentId) + && bindText(statement, 13, a.agentInstanceId) + && bindInt64(statement, 14, static_cast(a.backendGeneration)) + && bindInt64(statement, 15, static_cast(a.payloadVersion)) + && bindText(statement, 16, a.payload) + && bindText(statement, 17, a.requestFingerprint) + && bindText(statement, 18, a.verificationPolicy) + && bindInt64(statement, 19, a.assignedAt) + && bindInt64(statement, 20, a.deadline) + && bindInt64(statement, 21, reservation.localProviderSelectionPresent ? 1 : 0) + && bindText(statement, 22, reservation.localProviderSelectionIdentity) + && bindText(statement, 23, reservation.localProviderSelectionPresent ? s.authorityDomain : std::string()) + && bindText(statement, 24, reservation.localProviderSelectionPresent ? s.providerId : std::string()) + && bindText(statement, 25, reservation.localProviderSelectionPresent ? s.providerKind : std::string()) + && bindInt64(statement, 26, reservation.localProviderSelectionPresent ? static_cast(s.ownershipGeneration) : 0) + && bindText(statement, 27, reservation.localProviderSelectionPresent ? s.providerInstanceEpoch : std::string()) + && bindInt64(statement, 28, reservation.localProviderSelectionPresent ? static_cast(s.providerGeneration) : 0) + && bindInt64(statement, 29, reservation.localProviderSelectionPresent ? static_cast(s.capabilityRevision) : 0) + && bindText(statement, 30, reservation.localProviderSelectionPresent ? s.requiredCapability : std::string()); + const int step = bound ? sqlite3_step(statement) : SQLITE_ERROR; + if (statement != nullptr) sqlite3_finalize(statement); + return step == SQLITE_DONE; +} + +bool activeMatchesReservation( + BackendAgentCommandRepository& commandRepository, + const BackendAgentCommandReservation& reservation, + BackendAgentCommandAssignment& active) +{ + const auto found = commandRepository.findAssignmentForOperation( + reservation.assignment.backendId, + reservation.assignment.operationId, + reservation.assignment.commandType); + if (!found.has_value()) return false; + active = *found; + if (active.commandId != reservation.assignment.commandId + || backendAgentCommandFingerprint(active) != reservation.assignmentFingerprint) + { + return false; + } + + const auto selection = + commandRepository.localProviderSelectionForCommand(active.commandId); + if (!reservation.localProviderSelectionPresent) + return !selection.has_value(); + return selection.has_value() + && vdrsuite::agent::backendAgentLocalProviderSelectionIdentity(*selection) + == reservation.localProviderSelectionIdentity; +} +} + +BackendAgentCommandReservationRepository::BackendAgentCommandReservationRepository( + Database& database) + : database_(database) +{ +} + +bool BackendAgentCommandReservationRepository::ensureSchema() +{ + return database_.execute( + "CREATE TABLE IF NOT EXISTS backend_agent_command_reservations (" + "command_id TEXT PRIMARY KEY,backend_id TEXT NOT NULL,operation_id TEXT NOT NULL," + "command_type TEXT NOT NULL,assignment_fingerprint TEXT NOT NULL," + "protocol_version TEXT NOT NULL,request_id TEXT NOT NULL,correlation_id TEXT NOT NULL," + "job_id TEXT NOT NULL,attempt_id TEXT NOT NULL,claim_epoch INTEGER NOT NULL," + "agent_id TEXT NOT NULL,agent_instance_id TEXT NOT NULL,backend_generation INTEGER NOT NULL," + "payload_version INTEGER NOT NULL,payload TEXT NOT NULL,request_fingerprint TEXT NOT NULL," + "verification_policy TEXT NOT NULL,assigned_at INTEGER NOT NULL,deadline INTEGER NOT NULL," + "selection_present INTEGER NOT NULL,selection_identity TEXT NOT NULL," + "authority_domain TEXT NOT NULL,provider_id TEXT NOT NULL,provider_kind TEXT NOT NULL," + "ownership_generation INTEGER NOT NULL,provider_instance_epoch TEXT NOT NULL," + "provider_generation INTEGER NOT NULL,capability_revision INTEGER NOT NULL," + "required_capability TEXT NOT NULL," + "UNIQUE(backend_id,operation_id,command_type)," + "CHECK(claim_epoch>0),CHECK(backend_generation>0),CHECK(payload_version>0)," + "CHECK(selection_present IN (0,1))" + ");" + "CREATE INDEX IF NOT EXISTS idx_backend_agent_command_reservations_operation " + "ON backend_agent_command_reservations(backend_id,operation_id,command_type);" + ); +} + +BackendAgentCommandReservationResult +BackendAgentCommandReservationRepository::reserve( + const BackendAgentCommandAssignment& assignment, + const vdrsuite::agent::BackendAgentLocalProviderSelection* selection) +{ + if (!validReservationInput(assignment, selection)) + return reservationResult(BackendAgentCommandReservationStatus::invalid); + + const BackendAgentCommandReservation requested = + makeReservation(assignment, selection); + + auto lease = database_.acquireTransactionLease(); + if (!database_.execute("BEGIN IMMEDIATE TRANSACTION;")) + return reservationResult(BackendAgentCommandReservationStatus::storageError); + auto rollback = [this]() { database_.execute("ROLLBACK;"); }; + + BackendAgentCommandReservation existing; + bool found = false; + if (!selectReservation( + database_, + "backend_id=? AND operation_id=? AND command_type=?", + assignment.backendId, + assignment.operationId, + assignment.commandType, + existing, + found)) + { + rollback(); + return reservationResult(BackendAgentCommandReservationStatus::storageError); + } + if (found) + { + rollback(); + return reservationResult( + sameReservation(existing, requested) + ? BackendAgentCommandReservationStatus::alreadyReserved + : BackendAgentCommandReservationStatus::conflict, + existing); + } + + if (!selectReservation( + database_, "command_id=?", + assignment.commandId, "", "", + existing, found)) + { + rollback(); + return reservationResult(BackendAgentCommandReservationStatus::storageError); + } + if (found) + { + rollback(); + return reservationResult( + sameReservation(existing, requested) + ? BackendAgentCommandReservationStatus::alreadyReserved + : BackendAgentCommandReservationStatus::conflict, + existing); + } + + if (!insertReservation(database_, requested)) + { + rollback(); + return reservationResult(BackendAgentCommandReservationStatus::storageError); + } + if (!database_.execute("COMMIT;")) + { + rollback(); + return reservationResult(BackendAgentCommandReservationStatus::storageError); + } + return reservationResult( + BackendAgentCommandReservationStatus::reserved, + requested); +} + +BackendAgentCommandReservationResult +BackendAgentCommandReservationRepository::findByCommandId( + const std::string& commandId) const +{ + if (!backendAgentCommandSafeIdentifier(commandId)) + return reservationResult(BackendAgentCommandReservationStatus::invalid); + BackendAgentCommandReservation reservation; + bool found = false; + if (!selectReservation( + database_, "command_id=?", commandId, "", "", + reservation, found)) + { + return reservationResult(BackendAgentCommandReservationStatus::storageError); + } + return found + ? reservationResult(BackendAgentCommandReservationStatus::reserved, reservation) + : reservationResult(BackendAgentCommandReservationStatus::notFound); +} + +BackendAgentCommandReservationResult +BackendAgentCommandReservationRepository::findForOperation( + const std::string& backendId, + const std::string& operationId, + const std::string& commandType) const +{ + if (!backendAgentCommandSafeIdentifier(backendId) + || !backendAgentCommandSafeIdentifier(operationId) + || !backendAgentCommandSafeIdentifier(commandType)) + { + return reservationResult(BackendAgentCommandReservationStatus::invalid); + } + BackendAgentCommandReservation reservation; + bool found = false; + if (!selectReservation( + database_, + "backend_id=? AND operation_id=? AND command_type=?", + backendId, operationId, commandType, + reservation, found)) + { + return reservationResult(BackendAgentCommandReservationStatus::storageError); + } + return found + ? reservationResult(BackendAgentCommandReservationStatus::reserved, reservation) + : reservationResult(BackendAgentCommandReservationStatus::notFound); +} + +BackendAgentCommandReservationActivationService:: +BackendAgentCommandReservationActivationService( + BackendAgentCommandReservationRepository& reservationRepository, + BackendAgentCommandRepository& commandRepository) + : reservationRepository_(reservationRepository), + commandRepository_(commandRepository) +{ +} + +BackendAgentCommandActivationResult +BackendAgentCommandReservationActivationService::activate( + const std::string& commandId) +{ + const auto found = reservationRepository_.findByCommandId(commandId); + if (found.status == BackendAgentCommandReservationStatus::notFound) + return activationResult(BackendAgentCommandReservationStatus::notFound); + if (!found.ok()) + { + return activationResult( + found.status == BackendAgentCommandReservationStatus::invalid + ? BackendAgentCommandReservationStatus::invalid + : BackendAgentCommandReservationStatus::storageError); + } + + const BackendAgentCommandReservation& reservation = found.reservation; + BackendAgentCommandAssignment active; + if (activeMatchesReservation(commandRepository_, reservation, active)) + { + return activationResult( + BackendAgentCommandReservationStatus::alreadyActivated, + active); + } + + const auto existing = commandRepository_.findAssignmentForOperation( + reservation.assignment.backendId, + reservation.assignment.operationId, + reservation.assignment.commandType); + if (existing.has_value()) + return activationResult(BackendAgentCommandReservationStatus::conflict, *existing); + + const auto* selection = reservation.localProviderSelectionPresent + ? &reservation.localProviderSelection + : nullptr; + if (commandRepository_.insertAssignment(reservation.assignment, selection)) + { + return activationResult( + BackendAgentCommandReservationStatus::activated, + reservation.assignment); + } + + if (activeMatchesReservation(commandRepository_, reservation, active)) + { + return activationResult( + BackendAgentCommandReservationStatus::alreadyActivated, + active); + } + return activationResult(BackendAgentCommandReservationStatus::storageError); +} diff --git a/core/agent/tests/test_backend_agent_command_reservation.cpp b/core/agent/tests/test_backend_agent_command_reservation.cpp new file mode 100644 index 000000000..057160bc5 --- /dev/null +++ b/core/agent/tests/test_backend_agent_command_reservation.cpp @@ -0,0 +1,116 @@ +#include "BackendAgentCommandDelivery.h" +#include "BackendAgentCommandReservation.h" +#include "Database.h" + +#include +#include + +namespace +{ +BackendAgentCommandAssignment assignment( + const std::string& commandId = "command:1", + const std::string& operationId = "operation:1", + std::int64_t deadline = 200) +{ + BackendAgentCommandAssignment value; + value.present = true; + value.requestId = "request:1"; + value.correlationId = "correlation:1"; + value.operationId = operationId; + value.jobId = "job:1"; + value.attemptId = "attempt:1"; + value.claimEpoch = 1; + value.commandId = commandId; + value.backendId = "backend:1"; + value.agentId = "agent:1"; + value.agentInstanceId = "instance:1"; + value.backendGeneration = 7; + value.commandType = "probe.noop"; + value.payloadVersion = 1; + value.payload = "{}"; + value.verificationPolicy = "none"; + value.assignedAt = 100; + value.deadline = deadline; + value.requestFingerprint = backendAgentCommandFingerprint(value); + assert(backendAgentCommandValidAssignment(value)); + return value; +} +} + +int main() +{ + Database database; + assert(database.open(":memory:")); + + BackendAgentCommandRepository commandRepository(database); + BackendAgentCommandReservationRepository reservationRepository(database); + assert(commandRepository.ensureSchema()); + assert(reservationRepository.ensureSchema()); + assert(database.tableExists("backend_agent_command_reservations")); + + const auto requested = assignment(); + const auto reserved = reservationRepository.reserve(requested); + assert(reserved.status == BackendAgentCommandReservationStatus::reserved); + assert(reserved.reservation.assignment.commandId == "command:1"); + assert(reserved.reservation.assignmentFingerprint == + backendAgentCommandFingerprint(requested)); + assert(!reserved.reservation.localProviderSelectionPresent); + + // Reservation is deliberately not visible to normal Agent polling/delivery. + assert(!commandRepository.findAssignmentForOperation( + "backend:1", "operation:1", "probe.noop").has_value()); + + const auto foundById = reservationRepository.findByCommandId("command:1"); + assert(foundById.ok()); + const auto foundByOperation = reservationRepository.findForOperation( + "backend:1", "operation:1", "probe.noop"); + assert(foundByOperation.ok()); + + const auto replay = reservationRepository.reserve(requested); + assert(replay.status == BackendAgentCommandReservationStatus::alreadyReserved); + + auto changed = assignment("command:other", "operation:1", 250); + changed.jobId = "job:other"; + changed.attemptId = "attempt:other"; + changed.requestFingerprint = backendAgentCommandFingerprint(changed); + assert(backendAgentCommandValidAssignment(changed)); + assert(reservationRepository.reserve(changed).status == + BackendAgentCommandReservationStatus::conflict); + + auto collision = assignment("command:1", "operation:other", 250); + collision.jobId = "job:collision"; + collision.attemptId = "attempt:collision"; + collision.requestFingerprint = backendAgentCommandFingerprint(collision); + assert(backendAgentCommandValidAssignment(collision)); + assert(reservationRepository.reserve(collision).status == + BackendAgentCommandReservationStatus::conflict); + + BackendAgentCommandReservationActivationService activation( + reservationRepository, commandRepository); + const auto activated = activation.activate("command:1"); + assert(activated.status == BackendAgentCommandReservationStatus::activated); + assert(activated.assignment.commandId == "command:1"); + + const auto active = commandRepository.findAssignmentForOperation( + "backend:1", "operation:1", "probe.noop"); + assert(active.has_value()); + assert(active->commandId == "command:1"); + assert(active->requestFingerprint == requested.requestFingerprint); + + const auto activationReplay = activation.activate("command:1"); + assert(activationReplay.status == + BackendAgentCommandReservationStatus::alreadyActivated); + + // The immutable reservation remains available for crash recovery/audit. + assert(reservationRepository.findByCommandId("command:1").ok()); + + auto invalid = requested; + invalid.commandId.clear(); + assert(reservationRepository.reserve(invalid).status == + BackendAgentCommandReservationStatus::invalid); + assert(activation.activate("missing").status == + BackendAgentCommandReservationStatus::notFound); + + std::cout << "test_backend_agent_command_reservation passed\n"; + return 0; +} diff --git a/docs/development/phase-64-backend-agent-command-reservation.md b/docs/development/phase-64-backend-agent-command-reservation.md new file mode 100644 index 000000000..50efb766e --- /dev/null +++ b/docs/development/phase-64-backend-agent-command-reservation.md @@ -0,0 +1,50 @@ +# Phase 64 — Backend Agent Command Reservation + +Managed native CREATE needs one crash-safe handoff between Control-Plane +operation state and the already durable Agent command delivery subsystem. +Inserting directly into `backend_agent_commands` is not sufficient because those +rows are immediately eligible for normal Agent polling. + +This component adds an immutable **not pollable** reservation table in front of +the existing delivery table. It does not change the established poll/receipt/ +result protocol and it does not add another command lifecycle. + +The intended ordering is: + +```text +reserve -> dispatching -> activate +``` + +1. The complete `BackendAgentCommandAssignment` and optional exact local-provider + selection are durably reserved. No Agent poll can see this row. +2. The owning mutation operation is revision-fenced into `dispatching`. +3. The exact reservation is activated by inserting it into the existing Agent + command repository. + +Every crash boundary is recoverable: + +- crash before reservation: the accepted operation can still reserve once; +- crash after reservation but before `dispatching`: the command remains not + pollable and the same immutable reservation is reused; +- crash after `dispatching` but before activation: recovery activates the + existing reservation rather than constructing or sending another command; +- crash after activation: existing Agent command delivery, receipt/result + durability and Agent-side durable-starting rules own recovery. + +The reservation is unique by `(backendId, operationId, commandType)` as well as +stable `commandId`. Exact replay returns `alreadyReserved`; a changed command or +selection in the same scope fails as a conflict. Assignment fingerprints and +local-provider selection identities are stored and revalidated on read. + +Activation is idempotent. If the same command is already present in the existing +Agent command repository, the service returns `alreadyActivated`. A different +active command in the same operation scope fails closed. + +Reservations are intentionally retained after activation as immutable recovery +and audit evidence. They are not exposed to normal polling and are not a second +result/state machine. + +This primitive is generic: it contains no Timer CREATE payload semantics. The +CREATE orchestration later supplies a fully validated, generation/provider- +fenced command assignment. This layer only closes the delivery crash gap needed +for the **no blind retry** rule. diff --git a/mk/phase64-backend-agent-command-reservation-tests.mk b/mk/phase64-backend-agent-command-reservation-tests.mk new file mode 100644 index 000000000..7f5c7ebe6 --- /dev/null +++ b/mk/phase64-backend-agent-command-reservation-tests.mk @@ -0,0 +1,24 @@ +.PHONY: test-phase64-backend-agent-command-reservation-architecture test-phase64-backend-agent-command-reservation + +test-phase64-backend-agent-command-reservation-architecture: + python3 tools/check_phase64_backend_agent_command_reservation.py + +test-phase64-backend-agent-command-reservation: test-phase64-backend-agent-command-reservation-architecture + $(BUILD_CXX) $(CXXFLAGS) \ + $(SQLITE_SRC) \ + core/security/src/AccountabilityEventRepository.cpp \ + core/security/src/CredentialVerifierRepository.cpp \ + core/security/src/SecurityIdentityRepository.cpp \ + core/security/src/SecurityIdentityProvisioningRepository.cpp \ + core/vdr/src/VdrConfig.cpp \ + core/vdr/src/BackendRegistry.cpp \ + core/vdr/src/BackendRegistryService.cpp \ + $(AGENT_CONTROL_PLANE_DOMAIN_SRC) \ + core/agent/src/BackendAgentCommandReservation.cpp \ + core/agent/tests/test_backend_agent_command_reservation.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_backend_agent_command_reservation + $(BUILD_DIR)/test_backend_agent_command_reservation + +test-fast: test-phase64-backend-agent-command-reservation +test-architecture: test-phase64-backend-agent-command-reservation-architecture diff --git a/tools/check_phase64_backend_agent_command_reservation.py b/tools/check_phase64_backend_agent_command_reservation.py new file mode 100755 index 000000000..cf2aba76f --- /dev/null +++ b/tools/check_phase64_backend_agent_command_reservation.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +paths = { + "header": "core/agent/include/BackendAgentCommandReservation.h", + "source": "core/agent/src/BackendAgentCommandReservation.cpp", + "test": "core/agent/tests/test_backend_agent_command_reservation.cpp", + "doc": "docs/development/phase-64-backend-agent-command-reservation.md", + "mk": "mk/phase64-backend-agent-command-reservation-tests.mk", + "makefile": "Makefile", +} +for relative in paths.values(): + if not (ROOT / relative).is_file(): + raise SystemExit(f"missing backend Agent command reservation file: {relative}") + +header = (ROOT / paths["header"]).read_text(encoding="utf-8") +source = (ROOT / paths["source"]).read_text(encoding="utf-8") +test = (ROOT / paths["test"]).read_text(encoding="utf-8") +doc = (ROOT / paths["doc"]).read_text(encoding="utf-8") +mk = (ROOT / paths["mk"]).read_text(encoding="utf-8") +makefile = (ROOT / paths["makefile"]).read_text(encoding="utf-8") + +for token in [ + "BackendAgentCommandReservationStatus", + "alreadyReserved", + "activated", + "alreadyActivated", + "BackendAgentCommandReservation", + "BackendAgentCommandReservationRepository", + "BackendAgentCommandReservationActivationService", +]: + if token not in header: + raise SystemExit(f"missing command reservation header marker: {token}") + +for token in [ + "backend_agent_command_reservations", + "UNIQUE(backend_id,operation_id,command_type)", + "BEGIN IMMEDIATE TRANSACTION", + "backendAgentCommandFingerprint(assignment)", + "backendAgentLocalProviderSelectionIdentity", + "findAssignmentForOperation", + "insertAssignment(reservation.assignment, selection)", + "alreadyActivated", +]: + if token not in source: + raise SystemExit(f"missing command reservation source marker: {token}") + +for token in [ + "not visible to normal Agent polling/delivery", + "alreadyReserved", + "conflict", + "activated", + "alreadyActivated", + "test_backend_agent_command_reservation passed", +]: + if token not in test: + raise SystemExit(f"missing command reservation regression marker: {token}") + +for token in [ + "not pollable", + "reserve -> dispatching -> activate", + "crash", + "immutable", + "no blind retry", + "existing Agent command delivery", +]: + if token not in doc: + raise SystemExit(f"missing command reservation documentation marker: {token}") + +for token in [ + "test-phase64-backend-agent-command-reservation-architecture", + "test-phase64-backend-agent-command-reservation:", + "test-fast: test-phase64-backend-agent-command-reservation", + "test-architecture: test-phase64-backend-agent-command-reservation-architecture", +]: + if token not in mk: + raise SystemExit(f"missing command reservation make marker: {token}") + +if "include mk/phase64-backend-agent-command-reservation-tests.mk" not in makefile: + raise SystemExit("backend Agent command reservation make fragment is not included") + +# Reservation is a persistence primitive only at this point. Productive runtime +# use is opened later together with the exact CREATE command contract. +for manifest in [ + ROOT / "mk/agent-sources.mk", + ROOT / "mk/backend-agent-runtime.mk", + ROOT / "mk/daemon-sources.mk", +]: + if manifest.is_file() and "BackendAgentCommandReservation.cpp" in manifest.read_text( + encoding="utf-8", errors="ignore" + ): + raise SystemExit( + "premature command reservation runtime wiring: " + + str(manifest.relative_to(ROOT)) + ) + +print("Phase-64 backend Agent command reservation check passed") From 41d204613a8383317856880028867e385a97224b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 15:17:11 +0200 Subject: [PATCH 17/85] Allow Agent command reservation repository SQLite boundary --- tools/check_architecture.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/check_architecture.py b/tools/check_architecture.py index fdeffceb1..98e00cd9e 100755 --- a/tools/check_architecture.py +++ b/tools/check_architecture.py @@ -41,6 +41,7 @@ SQLITE_SPLIT_REPOSITORY_FILES = { "core/agent/src/BackendAgentCommandDelivery.cpp", + "core/agent/src/BackendAgentCommandReservation.cpp", "core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp", "core/recordings/src/ManualRecordingMetadataRepositoryFacade.cpp", } @@ -176,6 +177,7 @@ def check_sqlite_boundary_contract() -> list[str]: "core/metadata/src/MetadataEntityRepository.cpp", "core/security/src/SecurityIdentityRepository.cpp", "core/agent/src/BackendAgentCommandDelivery.cpp", + "core/agent/src/BackendAgentCommandReservation.cpp", "core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp", "core/timers/src/TimerIntentRepository.cpp", "core/operations/src/MutationOperationRepository.cpp", From a3caec7d5daf243777926f4a18c7a5634353caad Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 15:53:06 +0200 Subject: [PATCH 18/85] Fix Phase 64 command reservation test linkage --- mk/phase64-backend-agent-command-reservation-tests.mk | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mk/phase64-backend-agent-command-reservation-tests.mk b/mk/phase64-backend-agent-command-reservation-tests.mk index 7f5c7ebe6..f19ddebef 100644 --- a/mk/phase64-backend-agent-command-reservation-tests.mk +++ b/mk/phase64-backend-agent-command-reservation-tests.mk @@ -14,6 +14,8 @@ test-phase64-backend-agent-command-reservation: test-phase64-backend-agent-comma core/vdr/src/BackendRegistry.cpp \ core/vdr/src/BackendRegistryService.cpp \ $(AGENT_CONTROL_PLANE_DOMAIN_SRC) \ + core/agent/src/BackendAgentNativeTimerDelete.cpp \ + core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp \ core/agent/src/BackendAgentCommandReservation.cpp \ core/agent/tests/test_backend_agent_command_reservation.cpp \ $(LDFLAGS) \ From 94052e2da45e57070cf4f32f66003cfabd4ed683 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:00:52 +0200 Subject: [PATCH 19/85] Add strict Phase 64 native Timer CREATE contract --- .../include/BackendAgentNativeTimerCreate.h | 100 +++++++ .../BackendAgentNativeTimerCreatePayload.h | 34 +++ .../src/BackendAgentNativeTimerCreate.cpp | 174 +++++++++++++ .../BackendAgentNativeTimerCreatePayload.cpp | 245 ++++++++++++++++++ ...end_agent_native_timer_create_contract.cpp | 159 ++++++++++++ ...ative-timer-create-agent-contract-tests.mk | 19 ++ ...se64_native_timer_create_agent_contract.py | 62 +++++ 7 files changed, 793 insertions(+) create mode 100644 core/agent/include/BackendAgentNativeTimerCreate.h create mode 100644 core/agent/include/BackendAgentNativeTimerCreatePayload.h create mode 100644 core/agent/src/BackendAgentNativeTimerCreate.cpp create mode 100644 core/agent/src/BackendAgentNativeTimerCreatePayload.cpp create mode 100644 core/agent/tests/test_backend_agent_native_timer_create_contract.cpp create mode 100644 mk/phase64-native-timer-create-agent-contract-tests.mk create mode 100755 tools/check_phase64_native_timer_create_agent_contract.py diff --git a/core/agent/include/BackendAgentNativeTimerCreate.h b/core/agent/include/BackendAgentNativeTimerCreate.h new file mode 100644 index 000000000..1eff3302b --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerCreate.h @@ -0,0 +1,100 @@ +#pragma once + +#include "BackendAgentLocalProvider.h" + +#include +#include + +namespace vdrsuite::agent +{ + +constexpr const char* kBackendAgentNativeTimerCreateCommandType = "vdr.timer.create"; +constexpr const char* kBackendAgentNativeTimerCreateAuthorityDomain = "vdr.timer"; +constexpr const char* kBackendAgentNativeTimerCreateCapability = "vdr.timer.create"; +constexpr const char* kBackendAgentNativeTimerCreateProviderId = "suitebridge:local"; +constexpr const char* kBackendAgentNativeTimerCreateProviderKind = "suitebridge"; +constexpr std::uint64_t kBackendAgentNativeTimerCreatePayloadVersion = 1; + +struct BackendAgentNativeTimerCreateSpecification +{ + std::string channelId; + std::string title; + std::string directory; + std::string day; + std::string weekdays = "-------"; + std::string startTime; + std::string endTime; + std::int32_t priority = 50; + std::int32_t lifetime = 99; + bool enabled = true; + bool vps = false; +}; + +struct BackendAgentNativeTimerCreateCommand +{ + std::string commandId; + std::string requestFingerprint; + std::string operationId; + std::string operationRevision; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch = 0; + std::string nativeTimerBindingId; + std::string expectedSpecificationFingerprint; + std::string jobId; + std::string attemptId; + std::uint64_t claimEpoch = 0; + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; + std::int64_t controlPlaneClaimedAt = 0; + BackendAgentNativeTimerCreateSpecification specification; + BackendAgentLocalProviderSelection localProviderSelection; +}; + +enum class BackendAgentNativeTimerCreateOutcomeCategory +{ + rejectedWithoutEffect, + acceptedUnverified, + outcomeUnknown, +}; + +struct BackendAgentNativeTimerCreateEvidence +{ + std::string commandId; + std::string requestFingerprint; + std::string operationId; + std::string operationRevision; + std::string timerAssignmentId; + std::string nativeTimerBindingId; + std::string jobId; + std::string attemptId; + std::uint64_t claimEpoch = 0; + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; + std::string providerInstanceEpoch; + std::int64_t localStartingPersistedAt = 0; + BackendAgentNativeTimerCreateOutcomeCategory outcome = + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown; + std::int64_t dispatchStartedAt = 0; + std::int64_t completedAt = 0; + std::string evidenceReference; +}; + +bool backendAgentNativeTimerCreateSpecificationValid( + const BackendAgentNativeTimerCreateSpecification& specification); +std::string backendAgentNativeTimerCreateSpecificationFingerprint( + const BackendAgentNativeTimerCreateSpecification& specification); +bool backendAgentNativeTimerCreateValidCommand( + const BackendAgentNativeTimerCreateCommand& command, + std::string& reasonCode); +bool backendAgentNativeTimerCreateEvidenceMatches( + const BackendAgentNativeTimerCreateEvidence& evidence, + const BackendAgentNativeTimerCreateCommand& command, + std::string& reasonCode); + +} // namespace vdrsuite::agent diff --git a/core/agent/include/BackendAgentNativeTimerCreatePayload.h b/core/agent/include/BackendAgentNativeTimerCreatePayload.h new file mode 100644 index 000000000..12d96a52b --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerCreatePayload.h @@ -0,0 +1,34 @@ +#pragma once + +#include "BackendAgentNativeTimerCreate.h" + +#include +#include + +namespace vdrsuite::agent +{ + +struct BackendAgentNativeTimerCreatePayload +{ + std::string operationRevision; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch = 0; + std::string nativeTimerBindingId; + std::int64_t controlPlaneClaimedAt = 0; + std::string expectedSpecificationFingerprint; + BackendAgentNativeTimerCreateSpecification specification; + BackendAgentLocalProviderSelection localProviderSelection; +}; + +bool backendAgentNativeTimerCreatePayloadValid( + const BackendAgentNativeTimerCreatePayload& payload); +std::string backendAgentNativeTimerCreatePayload( + const BackendAgentNativeTimerCreatePayload& payload); +bool backendAgentNativeTimerCreateParsePayload( + const std::string& encoded, + BackendAgentNativeTimerCreatePayload& payload, + std::string& reasonCode); + +} // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentNativeTimerCreate.cpp b/core/agent/src/BackendAgentNativeTimerCreate.cpp new file mode 100644 index 000000000..889e20cf8 --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerCreate.cpp @@ -0,0 +1,174 @@ +#include "BackendAgentNativeTimerCreate.h" + +#include "BackendAgentCommand.h" + +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace +{ +constexpr std::size_t kMaxIdentityLength = 160; +constexpr std::size_t kMaxTextLength = 1024; + +bool boundedText(const std::string& value, std::size_t maximum, bool allowEmpty = true) +{ + return (allowEmpty || !value.empty()) && + backendAgentCommandSafeText(value, maximum); +} + +bool validHhmm(const std::string& value) +{ + if (value.empty() || value.size() > 4) return false; + for (unsigned char character : value) + if (std::isdigit(character) == 0) return false; + const std::string normalized = std::string(4 - value.size(), '0') + value; + const int hour = (normalized[0] - '0') * 10 + normalized[1] - '0'; + const int minute = (normalized[2] - '0') * 10 + normalized[3] - '0'; + return hour <= 23 && minute <= 59; +} + +std::string normalizedHhmm(const std::string& value) +{ + return std::string(4 - value.size(), '0') + value; +} + +void append(std::string& output, const std::string& value) +{ + output += std::to_string(value.size()); + output += ':'; + output += value; + output += '|'; +} + +void append(std::string& output, std::int32_t value) +{ + append(output, std::to_string(value)); +} + +void append(std::string& output, bool value) +{ + append(output, std::string(value ? "1" : "0")); +} + +bool exactProviderSelection(const BackendAgentLocalProviderSelection& selection) +{ + return backendAgentLocalProviderValidSelection(selection) && + selection.authorityDomain == kBackendAgentNativeTimerCreateAuthorityDomain && + selection.providerId == kBackendAgentNativeTimerCreateProviderId && + selection.providerKind == kBackendAgentNativeTimerCreateProviderKind && + selection.requiredCapability == kBackendAgentNativeTimerCreateCapability; +} +} + +bool backendAgentNativeTimerCreateSpecificationValid( + const BackendAgentNativeTimerCreateSpecification& specification) +{ + if (!boundedText(specification.channelId, kMaxIdentityLength, false) || + !boundedText(specification.title, kMaxTextLength) || + !boundedText(specification.directory, kMaxTextLength) || + !boundedText(specification.day, kMaxIdentityLength) || + specification.weekdays.size() != 7 || + !validHhmm(specification.startTime) || + !validHhmm(specification.endTime) || + specification.priority < 0 || specification.priority > 99 || + specification.lifetime < 0 || specification.lifetime > 99) + return false; + + for (unsigned char character : specification.weekdays) + if (character != '-' && std::isalpha(character) == 0) return false; + return true; +} + +std::string backendAgentNativeTimerCreateSpecificationFingerprint( + const BackendAgentNativeTimerCreateSpecification& specification) +{ + if (!backendAgentNativeTimerCreateSpecificationValid(specification)) return {}; + std::string fingerprint = "native-timer-specification/1|"; + append(fingerprint, specification.channelId); + append(fingerprint, specification.title); + append(fingerprint, specification.directory); + append(fingerprint, specification.day); + append(fingerprint, specification.weekdays); + append(fingerprint, normalizedHhmm(specification.startTime)); + append(fingerprint, normalizedHhmm(specification.endTime)); + append(fingerprint, specification.priority); + append(fingerprint, specification.lifetime); + append(fingerprint, specification.enabled); + append(fingerprint, specification.vps); + return fingerprint; +} + +bool backendAgentNativeTimerCreateValidCommand( + const BackendAgentNativeTimerCreateCommand& command, + std::string& reasonCode) +{ + const std::string fingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint(command.specification); + if (!backendAgentCommandSafeIdentifier(command.commandId) || + !backendAgentCommandSafeIdentifier(command.requestFingerprint) || + !backendAgentCommandSafeIdentifier(command.operationId) || + !backendAgentCommandSafeIdentifier(command.operationRevision) || + !backendAgentCommandSafeIdentifier(command.timerAssignmentId) || + !backendAgentCommandSafeIdentifier(command.expectedAssignmentRevision) || + !backendAgentCommandSafeIdentifier(command.expectedIntentRevision) || + command.assignmentEpoch == 0 || + !backendAgentCommandSafeIdentifier(command.nativeTimerBindingId) || + fingerprint.empty() || command.expectedSpecificationFingerprint != fingerprint || + !backendAgentCommandSafeIdentifier(command.jobId) || + !backendAgentCommandSafeIdentifier(command.attemptId) || + command.claimEpoch == 0 || + !backendAgentCommandSafeIdentifier(command.backendId) || + !backendAgentCommandSafeIdentifier(command.agentId) || + !backendAgentCommandSafeIdentifier(command.agentInstanceId) || + command.backendGeneration == 0 || command.controlPlaneClaimedAt <= 0 || + !exactProviderSelection(command.localProviderSelection) || + command.localProviderSelection.backendId != command.backendId) + { + reasonCode = "invalid_native_timer_create_command"; + return false; + } + reasonCode.clear(); + return true; +} + +bool backendAgentNativeTimerCreateEvidenceMatches( + const BackendAgentNativeTimerCreateEvidence& evidence, + const BackendAgentNativeTimerCreateCommand& command, + std::string& reasonCode) +{ + std::string commandReason; + if (!backendAgentNativeTimerCreateValidCommand(command, commandReason) || + evidence.commandId != command.commandId || + evidence.requestFingerprint != command.requestFingerprint || + evidence.operationId != command.operationId || + evidence.operationRevision != command.operationRevision || + evidence.timerAssignmentId != command.timerAssignmentId || + evidence.nativeTimerBindingId != command.nativeTimerBindingId || + evidence.jobId != command.jobId || evidence.attemptId != command.attemptId || + evidence.claimEpoch != command.claimEpoch || + evidence.backendId != command.backendId || evidence.agentId != command.agentId || + evidence.agentInstanceId != command.agentInstanceId || + evidence.backendGeneration != command.backendGeneration || + evidence.providerInstanceEpoch != command.localProviderSelection.providerInstanceEpoch || + evidence.localStartingPersistedAt <= 0 || evidence.completedAt <= 0 || + evidence.completedAt < evidence.localStartingPersistedAt || + (evidence.dispatchStartedAt > 0 && + (evidence.dispatchStartedAt < evidence.localStartingPersistedAt || + evidence.completedAt < evidence.dispatchStartedAt)) || + (evidence.outcome == BackendAgentNativeTimerCreateOutcomeCategory::rejectedWithoutEffect && + evidence.dispatchStartedAt != 0) || + (evidence.outcome != BackendAgentNativeTimerCreateOutcomeCategory::rejectedWithoutEffect && + evidence.dispatchStartedAt <= 0) || + !backendAgentCommandSafeText(evidence.evidenceReference, 1024)) + { + reasonCode = "native_timer_create_evidence_mismatch"; + return false; + } + reasonCode.clear(); + return true; +} + +} // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentNativeTimerCreatePayload.cpp b/core/agent/src/BackendAgentNativeTimerCreatePayload.cpp new file mode 100644 index 000000000..9883cc925 --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerCreatePayload.cpp @@ -0,0 +1,245 @@ +#include "BackendAgentNativeTimerCreatePayload.h" + +#include "BackendAgentCommand.h" + +#include +#include +#include +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace +{ +constexpr const char* kPayloadPrefix = "native-timer-create-agent-payload/1|"; +constexpr std::size_t kFieldCount = 28; + +void appendField(std::string& output, const std::string& value) +{ + output += std::to_string(value.size()); + output += ':'; + output += value; + output += '|'; +} +void appendUnsigned(std::string& output, std::uint64_t value) +{ + appendField(output, std::to_string(value)); +} +void appendSigned(std::string& output, std::int32_t value) +{ + appendField(output, std::to_string(value)); +} +void appendBool(std::string& output, bool value) +{ + appendField(output, value ? "1" : "0"); +} +std::string normalizedHhmm(const std::string& value) +{ + return std::string(4 - value.size(), '0') + value; +} + +bool exactProviderSelection(const BackendAgentLocalProviderSelection& selection) +{ + return backendAgentLocalProviderValidSelection(selection) && + selection.authorityDomain == kBackendAgentNativeTimerCreateAuthorityDomain && + selection.providerId == kBackendAgentNativeTimerCreateProviderId && + selection.providerKind == kBackendAgentNativeTimerCreateProviderKind && + selection.requiredCapability == kBackendAgentNativeTimerCreateCapability; +} + +bool readField(const std::string& input, std::size_t& position, std::string& value) +{ + if (position >= input.size()) return false; + std::size_t length = 0; + bool digitSeen = false; + while (position < input.size() && input[position] != ':') + { + const char character = input[position++]; + if (character < '0' || character > '9') return false; + digitSeen = true; + const std::size_t digit = static_cast(character - '0'); + if (length > (std::numeric_limits::max() - digit) / 10) + return false; + length = length * 10 + digit; + } + if (!digitSeen || position >= input.size() || input[position] != ':') return false; + ++position; + if (length > input.size() - position) return false; + value = input.substr(position, length); + position += length; + if (position >= input.size() || input[position] != '|') return false; + ++position; + return true; +} + +bool parseUnsigned(const std::string& token, std::uint64_t& value, bool positive = true) +{ + if (token.empty()) return false; + value = 0; + for (char character : token) + { + if (character < '0' || character > '9') return false; + const std::uint64_t digit = static_cast(character - '0'); + if (value > (std::numeric_limits::max() - digit) / 10) + return false; + value = value * 10 + digit; + } + return !positive || value > 0; +} + +bool parseSmallInt(const std::string& token, std::int32_t& value) +{ + std::uint64_t parsed = 0; + if (!parseUnsigned(token, parsed, false) || parsed > 99) return false; + value = static_cast(parsed); + return true; +} + +bool parseBool(const std::string& token, bool& value) +{ + if (token == "0") { value = false; return true; } + if (token == "1") { value = true; return true; } + return false; +} +} + +bool backendAgentNativeTimerCreatePayloadValid( + const BackendAgentNativeTimerCreatePayload& payload) +{ + return backendAgentCommandSafeIdentifier(payload.operationRevision) && + backendAgentCommandSafeIdentifier(payload.timerAssignmentId) && + backendAgentCommandSafeIdentifier(payload.expectedAssignmentRevision) && + backendAgentCommandSafeIdentifier(payload.expectedIntentRevision) && + payload.assignmentEpoch > 0 && + backendAgentCommandSafeIdentifier(payload.nativeTimerBindingId) && + payload.controlPlaneClaimedAt > 0 && + backendAgentNativeTimerCreateSpecificationValid(payload.specification) && + payload.expectedSpecificationFingerprint == + backendAgentNativeTimerCreateSpecificationFingerprint(payload.specification) && + exactProviderSelection(payload.localProviderSelection); +} + +std::string backendAgentNativeTimerCreatePayload( + const BackendAgentNativeTimerCreatePayload& payload) +{ + if (!backendAgentNativeTimerCreatePayloadValid(payload)) return {}; + std::string output(kPayloadPrefix); + appendField(output, payload.operationRevision); + appendField(output, payload.timerAssignmentId); + appendField(output, payload.expectedAssignmentRevision); + appendField(output, payload.expectedIntentRevision); + appendUnsigned(output, payload.assignmentEpoch); + appendField(output, payload.nativeTimerBindingId); + appendUnsigned(output, static_cast(payload.controlPlaneClaimedAt)); + appendField(output, payload.expectedSpecificationFingerprint); + + const auto& specification = payload.specification; + appendField(output, specification.channelId); + appendField(output, specification.title); + appendField(output, specification.directory); + appendField(output, specification.day); + appendField(output, specification.weekdays); + appendField(output, normalizedHhmm(specification.startTime)); + appendField(output, normalizedHhmm(specification.endTime)); + appendSigned(output, specification.priority); + appendSigned(output, specification.lifetime); + appendBool(output, specification.enabled); + appendBool(output, specification.vps); + + const auto& selection = payload.localProviderSelection; + appendField(output, selection.backendId); + appendField(output, selection.authorityDomain); + appendField(output, selection.providerId); + appendField(output, selection.providerKind); + appendUnsigned(output, selection.ownershipGeneration); + appendField(output, selection.providerInstanceEpoch); + appendUnsigned(output, selection.providerGeneration); + appendUnsigned(output, selection.capabilityRevision); + appendField(output, selection.requiredCapability); + return output; +} + +bool backendAgentNativeTimerCreateParsePayload( + const std::string& encoded, + BackendAgentNativeTimerCreatePayload& payload, + std::string& reasonCode) +{ + const std::string prefix(kPayloadPrefix); + if (encoded.compare(0, prefix.size(), prefix) != 0) + { + reasonCode = "invalid_native_timer_create_payload"; + return false; + } + std::size_t position = prefix.size(); + std::vector fields; + fields.reserve(kFieldCount); + while (position < encoded.size()) + { + std::string field; + if (!readField(encoded, position, field) || fields.size() >= kFieldCount) + { + reasonCode = "invalid_native_timer_create_payload"; + return false; + } + fields.push_back(std::move(field)); + } + if (fields.size() != kFieldCount) + { + reasonCode = "invalid_native_timer_create_payload"; + return false; + } + + BackendAgentNativeTimerCreatePayload candidate; + candidate.operationRevision = fields[0]; + candidate.timerAssignmentId = fields[1]; + candidate.expectedAssignmentRevision = fields[2]; + candidate.expectedIntentRevision = fields[3]; + if (!parseUnsigned(fields[4], candidate.assignmentEpoch)) + { reasonCode = "invalid_native_timer_create_payload"; return false; } + candidate.nativeTimerBindingId = fields[5]; + { + std::uint64_t claimedAt = 0; + if (!parseUnsigned(fields[6], claimedAt) || + claimedAt > static_cast(std::numeric_limits::max())) + { reasonCode = "invalid_native_timer_create_payload"; return false; } + candidate.controlPlaneClaimedAt = static_cast(claimedAt); + } + candidate.expectedSpecificationFingerprint = fields[7]; + auto& specification = candidate.specification; + specification.channelId = fields[8]; + specification.title = fields[9]; + specification.directory = fields[10]; + specification.day = fields[11]; + specification.weekdays = fields[12]; + specification.startTime = fields[13]; + specification.endTime = fields[14]; + if (!parseSmallInt(fields[15], specification.priority) || + !parseSmallInt(fields[16], specification.lifetime) || + !parseBool(fields[17], specification.enabled) || + !parseBool(fields[18], specification.vps)) + { reasonCode = "invalid_native_timer_create_payload"; return false; } + + auto& selection = candidate.localProviderSelection; + selection.backendId = fields[19]; + selection.authorityDomain = fields[20]; + selection.providerId = fields[21]; + selection.providerKind = fields[22]; + if (!parseUnsigned(fields[23], selection.ownershipGeneration)) + { reasonCode = "invalid_native_timer_create_payload"; return false; } + selection.providerInstanceEpoch = fields[24]; + if (!parseUnsigned(fields[25], selection.providerGeneration) || + !parseUnsigned(fields[26], selection.capabilityRevision)) + { reasonCode = "invalid_native_timer_create_payload"; return false; } + selection.requiredCapability = fields[27]; + + if (!backendAgentNativeTimerCreatePayloadValid(candidate) || + backendAgentNativeTimerCreatePayload(candidate) != encoded) + { reasonCode = "invalid_native_timer_create_payload"; return false; } + payload = std::move(candidate); + reasonCode.clear(); + return true; +} + +} // namespace vdrsuite::agent diff --git a/core/agent/tests/test_backend_agent_native_timer_create_contract.cpp b/core/agent/tests/test_backend_agent_native_timer_create_contract.cpp new file mode 100644 index 000000000..79e9ab65a --- /dev/null +++ b/core/agent/tests/test_backend_agent_native_timer_create_contract.cpp @@ -0,0 +1,159 @@ +#include "BackendAgentCommand.h" +#include "BackendAgentNativeTimerCreate.h" +#include "BackendAgentNativeTimerCreatePayload.h" + +#include +#include + +using namespace vdrsuite::agent; + +namespace +{ +BackendAgentNativeTimerCreateSpecification specification() +{ + BackendAgentNativeTimerCreateSpecification value; + value.channelId = "C-1-2-3"; + value.title = "Phase 64 CREATE"; + value.directory = "Tests"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "915"; + value.endTime = "1045"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +BackendAgentLocalProviderSelection selection() +{ + BackendAgentLocalProviderSelection value; + value.backendId = "backend_1"; + value.authorityDomain = kBackendAgentNativeTimerCreateAuthorityDomain; + value.providerId = kBackendAgentNativeTimerCreateProviderId; + value.providerKind = kBackendAgentNativeTimerCreateProviderKind; + value.ownershipGeneration = 4; + value.providerInstanceEpoch = "provider_epoch_9"; + value.providerGeneration = 7; + value.capabilityRevision = 11; + value.requiredCapability = kBackendAgentNativeTimerCreateCapability; + return value; +} + +BackendAgentNativeTimerCreatePayload payload() +{ + BackendAgentNativeTimerCreatePayload value; + value.operationRevision = "1"; + value.timerAssignmentId = "assignment_1"; + value.expectedAssignmentRevision = "3"; + value.expectedIntentRevision = "8"; + value.assignmentEpoch = 2; + value.nativeTimerBindingId = "binding_1"; + value.controlPlaneClaimedAt = 100; + value.specification = specification(); + value.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint(value.specification); + value.localProviderSelection = selection(); + return value; +} +} + +int main() +{ + const auto expectedSpecification = specification(); + assert(backendAgentNativeTimerCreateSpecificationValid(expectedSpecification)); + const std::string fingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint(expectedSpecification); + assert(fingerprint.find("native-timer-specification/1|") == 0); + + const auto expectedPayload = payload(); + assert(backendAgentNativeTimerCreatePayloadValid(expectedPayload)); + const std::string encoded = backendAgentNativeTimerCreatePayload(expectedPayload); + assert(!encoded.empty()); + + BackendAgentNativeTimerCreatePayload parsed; + std::string reason; + assert(backendAgentNativeTimerCreateParsePayload(encoded, parsed, reason)); + assert(parsed.timerAssignmentId == expectedPayload.timerAssignmentId); + assert(parsed.nativeTimerBindingId == expectedPayload.nativeTimerBindingId); + assert(parsed.expectedSpecificationFingerprint == fingerprint); + assert(parsed.specification.startTime == "0915"); + assert(backendAgentNativeTimerCreatePayload(parsed) == encoded); + + BackendAgentNativeTimerCreatePayload badProvider = expectedPayload; + badProvider.localProviderSelection.requiredCapability = "vdr.timer.delete"; + assert(backendAgentNativeTimerCreatePayload(badProvider).empty()); + assert(!backendAgentNativeTimerCreateParsePayload(encoded + "x", parsed, reason)); + + BackendAgentCommandAssignment assignment; + assignment.present = true; + assignment.requestId = "req_1"; + assignment.correlationId = "req_1"; + assignment.operationId = "op_1"; + assignment.jobId = "job_1"; + assignment.attemptId = "att_1"; + assignment.claimEpoch = 1; + assignment.commandId = "cmd_1"; + assignment.backendId = "backend_1"; + assignment.agentId = "agent_1"; + assignment.agentInstanceId = "instance_1"; + assignment.backendGeneration = 5; + assignment.commandType = kBackendAgentNativeTimerCreateCommandType; + assignment.payloadVersion = kBackendAgentNativeTimerCreatePayloadVersion; + assignment.payload = encoded; + assignment.verificationPolicy = "readback_required"; + assignment.assignedAt = 101; + assignment.deadline = 300; + assignment.requestFingerprint = backendAgentCommandFingerprint(assignment); + assert(backendAgentCommandValidAssignment(assignment)); + + BackendAgentNativeTimerCreateCommand command; + command.commandId = assignment.commandId; + command.requestFingerprint = assignment.requestFingerprint; + command.operationId = assignment.operationId; + command.operationRevision = expectedPayload.operationRevision; + command.timerAssignmentId = expectedPayload.timerAssignmentId; + command.expectedAssignmentRevision = expectedPayload.expectedAssignmentRevision; + command.expectedIntentRevision = expectedPayload.expectedIntentRevision; + command.assignmentEpoch = expectedPayload.assignmentEpoch; + command.nativeTimerBindingId = expectedPayload.nativeTimerBindingId; + command.expectedSpecificationFingerprint = fingerprint; + command.jobId = assignment.jobId; + command.attemptId = assignment.attemptId; + command.claimEpoch = assignment.claimEpoch; + command.backendId = assignment.backendId; + command.agentId = assignment.agentId; + command.agentInstanceId = assignment.agentInstanceId; + command.backendGeneration = assignment.backendGeneration; + command.controlPlaneClaimedAt = expectedPayload.controlPlaneClaimedAt; + command.specification = expectedPayload.specification; + command.localProviderSelection = expectedPayload.localProviderSelection; + assert(backendAgentNativeTimerCreateValidCommand(command, reason)); + + BackendAgentNativeTimerCreateEvidence evidence; + evidence.commandId = command.commandId; + evidence.requestFingerprint = command.requestFingerprint; + evidence.operationId = command.operationId; + evidence.operationRevision = command.operationRevision; + evidence.timerAssignmentId = command.timerAssignmentId; + evidence.nativeTimerBindingId = command.nativeTimerBindingId; + evidence.jobId = command.jobId; + evidence.attemptId = command.attemptId; + evidence.claimEpoch = command.claimEpoch; + evidence.backendId = command.backendId; + evidence.agentId = command.agentId; + evidence.agentInstanceId = command.agentInstanceId; + evidence.backendGeneration = command.backendGeneration; + evidence.providerInstanceEpoch = command.localProviderSelection.providerInstanceEpoch; + evidence.localStartingPersistedAt = 110; + evidence.outcome = BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified; + evidence.dispatchStartedAt = 111; + evidence.completedAt = 112; + evidence.evidenceReference = "suitebridge-create-accepted"; + assert(backendAgentNativeTimerCreateEvidenceMatches(evidence, command, reason)); + evidence.providerInstanceEpoch = "stale_epoch"; + assert(!backendAgentNativeTimerCreateEvidenceMatches(evidence, command, reason)); + + return 0; +} diff --git a/mk/phase64-native-timer-create-agent-contract-tests.mk b/mk/phase64-native-timer-create-agent-contract-tests.mk new file mode 100644 index 000000000..ac1671b3f --- /dev/null +++ b/mk/phase64-native-timer-create-agent-contract-tests.mk @@ -0,0 +1,19 @@ +.PHONY: test-phase64-native-timer-create-agent-contract-architecture test-phase64-native-timer-create-agent-contract + +test-phase64-native-timer-create-agent-contract-architecture: + python3 tools/check_phase64_native_timer_create_agent_contract.py + +test-phase64-native-timer-create-agent-contract: test-phase64-native-timer-create-agent-contract-architecture + $(BUILD_CXX) $(CXXFLAGS) \ + core/agent/src/BackendAgentLocalProvider.cpp \ + core/agent/src/BackendAgentNativeProbe.cpp \ + core/agent/src/BackendAgentNativeTimerCreate.cpp \ + core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ + core/agent/src/BackendAgentCommand.cpp \ + core/agent/tests/test_backend_agent_native_timer_create_contract.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_backend_agent_native_timer_create_contract + $(BUILD_DIR)/test_backend_agent_native_timer_create_contract + +test-fast: test-phase64-native-timer-create-agent-contract +test-architecture: test-phase64-native-timer-create-agent-contract-architecture diff --git a/tools/check_phase64_native_timer_create_agent_contract.py b/tools/check_phase64_native_timer_create_agent_contract.py new file mode 100755 index 000000000..59978deb5 --- /dev/null +++ b/tools/check_phase64_native_timer_create_agent_contract.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +required = { + "core/agent/include/BackendAgentNativeTimerCreate.h": [ + 'kBackendAgentNativeTimerCreateCommandType = "vdr.timer.create"', + 'kBackendAgentNativeTimerCreateAuthorityDomain = "vdr.timer"', + 'kBackendAgentNativeTimerCreateProviderId = "suitebridge:local"', + "expectedAssignmentRevision", + "expectedIntentRevision", + "assignmentEpoch", + "nativeTimerBindingId", + "expectedSpecificationFingerprint", + "localProviderSelection", + ], + "core/agent/include/BackendAgentNativeTimerCreatePayload.h": [ + "BackendAgentNativeTimerCreatePayload", + "backendAgentNativeTimerCreatePayloadValid", + "backendAgentNativeTimerCreateParsePayload", + ], + "core/agent/src/BackendAgentNativeTimerCreatePayload.cpp": [ + 'native-timer-create-agent-payload/1|', + "kFieldCount = 28", + "backendAgentNativeTimerCreatePayload(candidate) != encoded", + ], + "core/agent/src/BackendAgentCommand.cpp": [ + 'BackendAgentNativeTimerCreatePayload.h', + "kBackendAgentNativeTimerCreateCommandType", + "backendAgentNativeTimerCreateParsePayload", + ], +} + +errors = [] +for relative, markers in required.items(): + path = ROOT / relative + if not path.exists(): + errors.append(f"missing {relative}") + continue + text = path.read_text(encoding="utf-8") + for marker in markers: + if marker not in text: + errors.append(f"{relative}: missing marker {marker}") + +agent_sources = (ROOT / "mk/agent-sources.mk").read_text(encoding="utf-8") +for source in ( + "core/agent/src/BackendAgentNativeTimerCreate.cpp", + "core/agent/src/BackendAgentNativeTimerCreatePayload.cpp", +): + if source not in agent_sources: + errors.append(f"mk/agent-sources.mk: missing {source}") + +makefile = (ROOT / "Makefile").read_text(encoding="utf-8") +if "include mk/phase64-native-timer-create-agent-contract-tests.mk" not in makefile: + errors.append("Makefile: missing Phase 64 CREATE Agent contract tests include") + +if errors: + for error in errors: + print(error) + raise SystemExit(1) +print("Phase 64 native Timer CREATE Agent contract architecture guard passed") From 73f9befa11640389b16373e70f07ba727d9fbd01 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:01:16 +0200 Subject: [PATCH 20/85] Link Phase 64 native Timer CREATE contract --- mk/agent-sources.mk | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mk/agent-sources.mk b/mk/agent-sources.mk index fb2c4edae..81cf44e0e 100644 --- a/mk/agent-sources.mk +++ b/mk/agent-sources.mk @@ -33,6 +33,8 @@ AGENT_NATIVE_PROBE_SRC := \ AGENT_COMMAND_DOMAIN_SRC := \ core/agent/src/BackendAgentCommand.cpp \ + core/agent/src/BackendAgentNativeTimerCreate.cpp \ + core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ $(AGENT_NATIVE_PROBE_SRC) AGENT_COMMAND_JSON_SRC := \ From c45986ad0511c7b14da46c3cc82a811fca1a239d Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:01:42 +0200 Subject: [PATCH 21/85] Validate strict Phase 64 native Timer CREATE payload --- core/agent/src/BackendAgentCommand.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/agent/src/BackendAgentCommand.cpp b/core/agent/src/BackendAgentCommand.cpp index d008f97b6..4c6ee2c26 100644 --- a/core/agent/src/BackendAgentCommand.cpp +++ b/core/agent/src/BackendAgentCommand.cpp @@ -1,5 +1,6 @@ #include "BackendAgentCommand.h" #include "BackendAgentNativeProbe.h" +#include "BackendAgentNativeTimerCreatePayload.h" #include "BackendAgentNativeTimerDeletePayload.h" #include @@ -67,6 +68,19 @@ bool validCommandPayload(const BackendAgentCommandAssignment& value) value.payload, payload, reason); } } + if (value.commandType == + vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType && + value.payloadVersion == + vdrsuite::agent::kBackendAgentNativeTimerCreatePayloadVersion && + value.verificationPolicy == "readback_required") + { + vdrsuite::agent::BackendAgentNativeTimerCreatePayload payload; + std::string reason; + return vdrsuite::agent::backendAgentNativeTimerCreateParsePayload( + value.payload, payload, reason) && + payload.localProviderSelection.backendId == value.backendId && + payload.controlPlaneClaimedAt <= value.assignedAt; + } if (value.commandType == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType && value.payloadVersion == From f5146cfe8601f55f5c3d6dbd3b6686f4f2e20397 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:01:55 +0200 Subject: [PATCH 22/85] Test Phase 64 native Timer CREATE Agent contract --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 0154e12f3..bdd012afd 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,7 @@ include mk/phase62-retention-runtime-acceptance.mk include mk/phase63-runtime-acceptance.mk include mk/agent-tests.mk include mk/phase64-backend-agent-command-reservation-tests.mk +include mk/phase64-native-timer-create-agent-contract-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk From d20ec4a779b1310fae6912a8a33b1dd16cf0c361 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:05:32 +0200 Subject: [PATCH 23/85] Reserve Phase 64 native Timer CREATE command durably --- ...BackendAgentNativeTimerCreateReservation.h | 60 ++++ ...ckendAgentNativeTimerCreateReservation.cpp | 307 ++++++++++++++++++ ..._agent_native_timer_create_reservation.cpp | 257 +++++++++++++++ ...4-native-timer-create-reservation-tests.mk | 27 ++ ...phase64_native_timer_create_reservation.py | 57 ++++ 5 files changed, 708 insertions(+) create mode 100644 core/agent/include/BackendAgentNativeTimerCreateReservation.h create mode 100644 core/agent/src/BackendAgentNativeTimerCreateReservation.cpp create mode 100644 core/agent/tests/test_backend_agent_native_timer_create_reservation.cpp create mode 100644 mk/phase64-native-timer-create-reservation-tests.mk create mode 100755 tools/check_phase64_native_timer_create_reservation.py diff --git a/core/agent/include/BackendAgentNativeTimerCreateReservation.h b/core/agent/include/BackendAgentNativeTimerCreateReservation.h new file mode 100644 index 000000000..ac38c7dcf --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerCreateReservation.h @@ -0,0 +1,60 @@ +#pragma once + +#include "BackendAgentCommand.h" +#include "BackendAgentNativeTimerCreate.h" +#include "SecurityIdentity.h" + +#include +#include + +class BackendAgentCommandRepository; +class BackendAgentCommandReservationRepository; +class BackendAgentRepository; + +namespace vdrsuite::agent +{ + +struct BackendAgentNativeTimerCreateReservationRequest +{ + std::string operationId; + std::string operationRevision; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch = 0; + std::string nativeTimerBindingId; + std::string backendId; + std::uint64_t backendGeneration = 0; + BackendAgentNativeTimerCreateSpecification expectedSpecification; + std::string expectedSpecificationFingerprint; +}; + +struct BackendAgentNativeTimerCreateReservationResult +{ + bool accepted = false; + bool replayed = false; + std::string reasonCode; + BackendAgentCommandAssignment assignment; +}; + +class BackendAgentNativeTimerCreateReservationService +{ +public: + BackendAgentNativeTimerCreateReservationService( + BackendAgentCommandRepository& commandRepository, + BackendAgentCommandReservationRepository& reservationRepository, + BackendAgentRepository& agentRepository); + + BackendAgentNativeTimerCreateReservationResult reserve( + const RequestSecurityContext& context, + const BackendAgentNativeTimerCreateReservationRequest& request, + std::int64_t now, + std::int64_t deadline); + +private: + BackendAgentCommandRepository& commandRepository_; + BackendAgentCommandReservationRepository& reservationRepository_; + BackendAgentRepository& agentRepository_; +}; + +} // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentNativeTimerCreateReservation.cpp b/core/agent/src/BackendAgentNativeTimerCreateReservation.cpp new file mode 100644 index 000000000..4949021d4 --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerCreateReservation.cpp @@ -0,0 +1,307 @@ +#include "BackendAgentNativeTimerCreateReservation.h" + +#include "BackendAgentCommandDelivery.h" +#include "BackendAgentCommandReservation.h" +#include "BackendAgentLifecycle.h" +#include "BackendAgentNativeTimerCreatePayload.h" + +#include +#include + +namespace +{ +using namespace vdrsuite::agent; + +BackendAgentNativeTimerCreateReservationResult rejected( + const std::string& reasonCode) +{ + BackendAgentNativeTimerCreateReservationResult result; + result.reasonCode = reasonCode; + return result; +} + +bool requestValid( + const RequestSecurityContext& context, + const BackendAgentNativeTimerCreateReservationRequest& request, + std::int64_t now, + std::int64_t deadline) +{ + return context.authenticated() && context.actor.type == ActorType::System && + backendAgentCommandSafeIdentifier(request.operationId) && + backendAgentCommandSafeIdentifier(request.operationRevision) && + backendAgentCommandSafeIdentifier(request.timerAssignmentId) && + backendAgentCommandSafeIdentifier(request.expectedAssignmentRevision) && + backendAgentCommandSafeIdentifier(request.expectedIntentRevision) && + request.assignmentEpoch > 0 && + backendAgentCommandSafeIdentifier(request.nativeTimerBindingId) && + backendAgentCommandSafeIdentifier(request.backendId) && + request.backendGeneration > 0 && + backendAgentNativeTimerCreateSpecificationValid(request.expectedSpecification) && + request.expectedSpecificationFingerprint == + backendAgentNativeTimerCreateSpecificationFingerprint( + request.expectedSpecification) && + now > 0 && deadline > now && deadline - now <= 3600; +} + +BackendAgentNativeTimerCreateCommand domainCommand( + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerCreatePayload& payload) +{ + BackendAgentNativeTimerCreateCommand command; + command.commandId = assignment.commandId; + command.requestFingerprint = assignment.requestFingerprint; + command.operationId = assignment.operationId; + command.operationRevision = payload.operationRevision; + command.timerAssignmentId = payload.timerAssignmentId; + command.expectedAssignmentRevision = payload.expectedAssignmentRevision; + command.expectedIntentRevision = payload.expectedIntentRevision; + command.assignmentEpoch = payload.assignmentEpoch; + command.nativeTimerBindingId = payload.nativeTimerBindingId; + command.expectedSpecificationFingerprint = payload.expectedSpecificationFingerprint; + command.jobId = assignment.jobId; + command.attemptId = assignment.attemptId; + command.claimEpoch = assignment.claimEpoch; + command.backendId = assignment.backendId; + command.agentId = assignment.agentId; + command.agentInstanceId = assignment.agentInstanceId; + command.backendGeneration = assignment.backendGeneration; + command.controlPlaneClaimedAt = payload.controlPlaneClaimedAt; + command.specification = payload.specification; + command.localProviderSelection = payload.localProviderSelection; + return command; +} + +bool requestMatches( + const BackendAgentNativeTimerCreateReservationRequest& request, + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerCreatePayload& payload) +{ + return assignment.commandType == kBackendAgentNativeTimerCreateCommandType && + assignment.payloadVersion == kBackendAgentNativeTimerCreatePayloadVersion && + assignment.verificationPolicy == "readback_required" && + assignment.operationId == request.operationId && + assignment.backendId == request.backendId && + assignment.backendGeneration == request.backendGeneration && + payload.operationRevision == request.operationRevision && + payload.timerAssignmentId == request.timerAssignmentId && + payload.expectedAssignmentRevision == request.expectedAssignmentRevision && + payload.expectedIntentRevision == request.expectedIntentRevision && + payload.assignmentEpoch == request.assignmentEpoch && + payload.nativeTimerBindingId == request.nativeTimerBindingId && + payload.expectedSpecificationFingerprint == + request.expectedSpecificationFingerprint && + backendAgentNativeTimerCreateSpecificationFingerprint(payload.specification) == + request.expectedSpecificationFingerprint; +} + +bool exactExistingReservation( + BackendAgentCommandRepository& commandRepository, + const BackendAgentRecord& agent, + const BackendAgentNativeTimerCreateReservationRequest& request, + const BackendAgentCommandReservation& reservation, + std::string& reasonCode) +{ + const auto& assignment = reservation.assignment; + BackendAgentNativeTimerCreatePayload payload; + if (!backendAgentNativeTimerCreateParsePayload( + assignment.payload, payload, reasonCode) || + !requestMatches(request, assignment, payload) || + !backendAgentCommandValidAssignment(assignment)) + { + reasonCode = "native_timer_create_reservation_conflict"; + return false; + } + if (assignment.agentId != agent.agentId || + assignment.agentInstanceId != agent.agentInstanceId || + assignment.backendGeneration != agent.backendGeneration) + { + reasonCode = "native_timer_create_agent_fence_stale"; + return false; + } + if (!reservation.localProviderSelectionPresent || + reservation.localProviderSelectionIdentity != + backendAgentLocalProviderSelectionIdentity(payload.localProviderSelection) || + !backendAgentLocalProviderSameFence( + reservation.localProviderSelection, payload.localProviderSelection)) + { + reasonCode = "native_timer_create_provider_selection_missing"; + return false; + } + + const auto current = commandRepository.selectLocalProvider( + request.backendId, + agent.agentId, + agent.agentInstanceId, + agent.backendGeneration, + kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateCapability, + reasonCode); + if (!current.has_value()) return false; + if (!backendAgentLocalProviderSameFence(*current, payload.localProviderSelection)) + { + reasonCode = "native_timer_create_provider_selection_stale"; + return false; + } + + const auto command = domainCommand(assignment, payload); + if (!backendAgentNativeTimerCreateValidCommand(command, reasonCode)) + { + reasonCode = "native_timer_create_reservation_contract_invalid"; + return false; + } + return true; +} + +BackendAgentNativeTimerCreateReservationResult accepted( + const BackendAgentCommandAssignment& assignment, + bool replayed, + const std::string& reasonCode) +{ + BackendAgentNativeTimerCreateReservationResult result; + result.accepted = true; + result.replayed = replayed; + result.reasonCode = reasonCode; + result.assignment = assignment; + return result; +} +} + +namespace vdrsuite::agent +{ + +BackendAgentNativeTimerCreateReservationService:: +BackendAgentNativeTimerCreateReservationService( + BackendAgentCommandRepository& commandRepository, + BackendAgentCommandReservationRepository& reservationRepository, + BackendAgentRepository& agentRepository) + : commandRepository_(commandRepository), + reservationRepository_(reservationRepository), + agentRepository_(agentRepository) +{ +} + +BackendAgentNativeTimerCreateReservationResult +BackendAgentNativeTimerCreateReservationService::reserve( + const RequestSecurityContext& context, + const BackendAgentNativeTimerCreateReservationRequest& request, + std::int64_t now, + std::int64_t deadline) +{ + if (!requestValid(context, request, now, deadline)) + return rejected("invalid_native_timer_create_reservation_request"); + + const auto agent = agentRepository_.findAgentForBackend(request.backendId); + if (!agent.has_value() || agent->revoked || agent->incompatible || + agent->agentInstanceId.empty() || agent->backendGeneration == 0 || + agent->leaseExpiresAt < now) + return rejected("active_agent_lease_required"); + if (agent->backendGeneration != request.backendGeneration) + return rejected("native_timer_create_backend_generation_conflict"); + + if (!reservationRepository_.ensureSchema()) + return rejected("native_timer_create_reservation_schema_failed"); + + const auto existing = reservationRepository_.findForOperation( + request.backendId, + request.operationId, + kBackendAgentNativeTimerCreateCommandType); + if (existing.ok()) + { + std::string reason; + if (!exactExistingReservation( + commandRepository_, *agent, request, existing.reservation, reason)) + return rejected(reason); + return accepted( + existing.reservation.assignment, + true, + "native_timer_create_reservation_replayed"); + } + if (existing.status != BackendAgentCommandReservationStatus::notFound) + { + return rejected( + existing.status == BackendAgentCommandReservationStatus::invalid + ? "invalid_native_timer_create_reservation_request" + : "native_timer_create_reservation_lookup_failed"); + } + + if (commandRepository_.findAssignmentForOperation( + request.backendId, + request.operationId, + kBackendAgentNativeTimerCreateCommandType).has_value()) + return rejected("native_timer_create_active_assignment_conflict"); + + std::string reason; + const auto selection = commandRepository_.selectLocalProvider( + request.backendId, + agent->agentId, + agent->agentInstanceId, + agent->backendGeneration, + kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateCapability, + reason); + if (!selection.has_value()) return rejected(reason); + + BackendAgentNativeTimerCreatePayload payload; + payload.operationRevision = request.operationRevision; + payload.timerAssignmentId = request.timerAssignmentId; + payload.expectedAssignmentRevision = request.expectedAssignmentRevision; + payload.expectedIntentRevision = request.expectedIntentRevision; + payload.assignmentEpoch = request.assignmentEpoch; + payload.nativeTimerBindingId = request.nativeTimerBindingId; + payload.controlPlaneClaimedAt = now; + payload.expectedSpecificationFingerprint = request.expectedSpecificationFingerprint; + payload.specification = request.expectedSpecification; + payload.localProviderSelection = *selection; + + BackendAgentCommandAssignment assignment; + assignment.present = true; + assignment.requestId = backendAgentGenerateOpaqueId("req_", 8); + assignment.correlationId = assignment.requestId; + assignment.operationId = request.operationId; + assignment.jobId = backendAgentGenerateOpaqueId("job_", 12); + assignment.attemptId = backendAgentGenerateOpaqueId("att_", 12); + assignment.claimEpoch = 1; + assignment.commandId = backendAgentGenerateOpaqueId("cmd_", 12); + assignment.backendId = request.backendId; + assignment.agentId = agent->agentId; + assignment.agentInstanceId = agent->agentInstanceId; + assignment.backendGeneration = agent->backendGeneration; + assignment.commandType = kBackendAgentNativeTimerCreateCommandType; + assignment.payloadVersion = kBackendAgentNativeTimerCreatePayloadVersion; + assignment.payload = backendAgentNativeTimerCreatePayload(payload); + assignment.verificationPolicy = "readback_required"; + assignment.assignedAt = now; + assignment.deadline = deadline; + assignment.requestFingerprint = backendAgentCommandFingerprint(assignment); + + const auto command = domainCommand(assignment, payload); + if (assignment.payload.empty() || + !backendAgentNativeTimerCreateValidCommand(command, reason) || + !backendAgentCommandValidAssignment(assignment)) + return rejected("native_timer_create_reservation_contract_invalid"); + + const auto reserved = reservationRepository_.reserve(assignment, &*selection); + if (reserved.status == BackendAgentCommandReservationStatus::reserved) + return accepted( + reserved.reservation.assignment, + false, + "native_timer_create_reserved"); + if (reserved.status == BackendAgentCommandReservationStatus::alreadyReserved) + { + if (!exactExistingReservation( + commandRepository_, *agent, request, reserved.reservation, reason)) + return rejected(reason); + return accepted( + reserved.reservation.assignment, + true, + "native_timer_create_reservation_replayed"); + } + if (reserved.status == BackendAgentCommandReservationStatus::conflict) + return rejected("native_timer_create_reservation_conflict"); + return rejected( + reserved.status == BackendAgentCommandReservationStatus::invalid + ? "native_timer_create_reservation_contract_invalid" + : "native_timer_create_reservation_persist_failed"); +} + +} // namespace vdrsuite::agent diff --git a/core/agent/tests/test_backend_agent_native_timer_create_reservation.cpp b/core/agent/tests/test_backend_agent_native_timer_create_reservation.cpp new file mode 100644 index 000000000..bf47284bf --- /dev/null +++ b/core/agent/tests/test_backend_agent_native_timer_create_reservation.cpp @@ -0,0 +1,257 @@ +#include "BackendAgentCommandDelivery.h" +#include "BackendAgentCommandReservation.h" +#include "BackendAgentLifecycle.h" +#include "BackendAgentNativeTimerCreate.h" +#include "BackendAgentNativeTimerCreatePayload.h" +#include "BackendAgentNativeTimerCreateReservation.h" +#include "Database.h" + +#include +#include +#include + +namespace +{ +RequestSecurityContext context(ActorType type, const std::string& actor) +{ + RequestSecurityContext value; + value.requestId = "req_timer_create_reservation_test"; + value.correlationId = "corr_timer_create_reservation_test"; + value.authenticationState = AuthenticationState::Authenticated; + value.actor = ActorIdentity{actor, type, "test", true}; + value.device = DeviceIdentity{"dev_timer_create_reservation", true}; + value.credential = CredentialIdentity{ + "cred_timer_create_reservation", true, false, false}; + value.permissionGrantResolution = PermissionGrantResolutionState::Resolved; + return value; +} + +vdrsuite::agent::BackendAgentNativeTimerCreateSpecification specification() +{ + vdrsuite::agent::BackendAgentNativeTimerCreateSpecification value; + value.channelId = "C-1-2-3"; + value.title = "Phase 64 reserved CREATE"; + value.directory = "Tests"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1030"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +vdrsuite::agent::BackendAgentLocalProviderFacts providerFacts( + const std::string& epoch = "pie_timer_create_1", + std::uint64_t generation = 3, + std::uint64_t capabilityRevision = 4, + bool includeCreate = true) +{ + vdrsuite::agent::BackendAgentLocalProviderFacts facts; + facts.providerId = "suitebridge:local"; + facts.providerKind = "suitebridge"; + facts.providerInstanceEpoch = epoch; + facts.providerGeneration = generation; + facts.capabilityRevision = capabilityRevision; + facts.available = true; + facts.capabilities = includeCreate + ? std::vector{"vdr.timer.create"} + : std::vector{"vdr.native.probe"}; + return facts; +} + +void observeProvider( + BackendAgentCommandRepository& commands, + const vdrsuite::agent::BackendAgentLocalProviderFacts& facts, + std::int64_t now, + const std::string& agentInstanceId = "inst_timer_create") +{ + BackendAgentCommandPollRequest poll; + poll.backendId = "default"; + poll.agentInstanceId = agentInstanceId; + poll.backendGeneration = 7; + // CREATE stays deliberately absent from executable command advertisement. + poll.supportedCommandTypes = {"probe.noop"}; + poll.localProviders = {facts}; + const auto result = commands.poll(poll, "agt_timer_create", now); + assert(result.accepted); + assert(!result.assignment.present); +} + +vdrsuite::agent::BackendAgentNativeTimerCreateReservationRequest request( + const std::string& operationId = "op_timer_create_1") +{ + using namespace vdrsuite::agent; + BackendAgentNativeTimerCreateReservationRequest value; + value.operationId = operationId; + value.operationRevision = "1"; + value.timerAssignmentId = "ta_timer_create_1"; + value.expectedAssignmentRevision = "2"; + value.expectedIntentRevision = "5"; + value.assignmentEpoch = 3; + value.nativeTimerBindingId = "ntb_timer_create_1"; + value.backendId = "default"; + value.backendGeneration = 7; + value.expectedSpecification = specification(); + value.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint( + value.expectedSpecification); + return value; +} +} + +int main() +{ + using namespace vdrsuite::agent; + + Database database; + assert(database.open(":memory:")); + BackendAgentRepository agents(database); + BackendAgentCommandRepository commands(database); + BackendAgentCommandReservationRepository reservations(database); + assert(agents.ensureSchema()); + assert(commands.ensureSchema()); + assert(reservations.ensureSchema()); + assert(database.execute( + "INSERT INTO backend_agents(agent_id,backend_id,actor_id,device_id," + "credential_id,credential_generation,agent_instance_id," + "backend_generation,protocol_version,software_version," + "heartbeat_sequence,capability_revision,last_connected_at," + "last_heartbeat_at,lease_expires_at,created_at,updated_at) VALUES(" + "'agt_timer_create','default','actor_timer_create','dev_timer_create'," + "'cred_timer_create',1,'inst_timer_create',7,'vdr-suite-agent/1'," + "'test',2,1,100,100,1000,1,1);")); + + BackendAgentNativeTimerCreateReservationService service( + commands, reservations, agents); + const auto system = context(ActorType::System, "system_timer_create"); + const auto agentActor = context(ActorType::Agent, "actor_timer_create"); + + observeProvider(commands, providerFacts(), 101); + const auto noOwnership = service.reserve(system, request(), 120, 500); + assert(!noOwnership.accepted); + assert(noOwnership.reasonCode == "local_provider_ownership_required"); + + BackendAgentLocalProviderOwnership ownership; + std::string reason; + assert(commands.setLocalProviderOwnership( + "default", "vdr.timer", "suitebridge:local", "suitebridge", + {"vdr.timer.create"}, 102, ownership, reason)); + + const auto reserved = service.reserve(system, request(), 120, 500); + assert(reserved.accepted); + assert(!reserved.replayed); + assert(reserved.reasonCode == "native_timer_create_reserved"); + assert(reserved.assignment.commandType == "vdr.timer.create"); + assert(reserved.assignment.payloadVersion == 1); + assert(reserved.assignment.verificationPolicy == "readback_required"); + assert(reserved.assignment.operationId == "op_timer_create_1"); + assert(reserved.assignment.backendGeneration == 7); + assert(backendAgentCommandValidAssignment(reserved.assignment)); + + // Reservation is durable but must remain invisible to Agent polling until + // the control plane has durably transitioned the MutationOperation to dispatching. + assert(!commands.findAssignmentForOperation( + "default", "op_timer_create_1", "vdr.timer.create").has_value()); + const auto durable = reservations.findForOperation( + "default", "op_timer_create_1", "vdr.timer.create"); + assert(durable.ok()); + assert(durable.reservation.assignment.commandId == reserved.assignment.commandId); + assert(durable.reservation.localProviderSelectionPresent); + assert(!commands.hasCapability( + "default", "agt_timer_create", "inst_timer_create", 7, + "vdr.timer.create")); + + BackendAgentNativeTimerCreatePayload payload; + assert(backendAgentNativeTimerCreateParsePayload( + reserved.assignment.payload, payload, reason)); + assert(payload.operationRevision == "1"); + assert(payload.timerAssignmentId == "ta_timer_create_1"); + assert(payload.expectedAssignmentRevision == "2"); + assert(payload.expectedIntentRevision == "5"); + assert(payload.assignmentEpoch == 3); + assert(payload.nativeTimerBindingId == "ntb_timer_create_1"); + assert(payload.controlPlaneClaimedAt == 120); + assert(payload.expectedSpecificationFingerprint == + request().expectedSpecificationFingerprint); + assert(payload.localProviderSelection.authorityDomain == "vdr.timer"); + assert(payload.localProviderSelection.requiredCapability == "vdr.timer.create"); + assert(payload.localProviderSelection.providerInstanceEpoch == + "pie_timer_create_1"); + + const auto replay = service.reserve(system, request(), 121, 501); + assert(replay.accepted); + assert(replay.replayed); + assert(replay.reasonCode == "native_timer_create_reservation_replayed"); + assert(replay.assignment.commandId == reserved.assignment.commandId); + assert(replay.assignment.jobId == reserved.assignment.jobId); + assert(replay.assignment.attemptId == reserved.assignment.attemptId); + assert(replay.assignment.payload == reserved.assignment.payload); + + auto changedBinding = request(); + changedBinding.nativeTimerBindingId = "ntb_timer_create_other"; + const auto bindingConflict = service.reserve(system, changedBinding, 122, 502); + assert(!bindingConflict.accepted); + assert(bindingConflict.reasonCode == "native_timer_create_reservation_conflict"); + + auto changedSpecification = request(); + changedSpecification.expectedSpecification.title = "Changed title"; + changedSpecification.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint( + changedSpecification.expectedSpecification); + const auto specificationConflict = + service.reserve(system, changedSpecification, 122, 502); + assert(!specificationConflict.accepted); + assert(specificationConflict.reasonCode == + "native_timer_create_reservation_conflict"); + + auto wrongGeneration = request("op_timer_create_generation"); + wrongGeneration.backendGeneration = 8; + const auto fencedGeneration = + service.reserve(system, wrongGeneration, 123, 503); + assert(!fencedGeneration.accepted); + assert(fencedGeneration.reasonCode == + "native_timer_create_backend_generation_conflict"); + + const auto wrongActor = service.reserve( + agentActor, request("op_timer_create_actor"), 124, 504); + assert(!wrongActor.accepted); + assert(wrongActor.reasonCode == + "invalid_native_timer_create_reservation_request"); + + observeProvider(commands, providerFacts("pie_timer_create_2", 4, 5), 130); + const auto staleReplay = service.reserve(system, request(), 131, 511); + assert(!staleReplay.accepted); + assert(staleReplay.reasonCode == + "native_timer_create_provider_selection_stale"); + + const auto next = service.reserve( + system, request("op_timer_create_2"), 132, 512); + assert(next.accepted); + assert(!next.replayed); + assert(next.assignment.commandId != reserved.assignment.commandId); + assert(!commands.findAssignmentForOperation( + "default", "op_timer_create_2", "vdr.timer.create").has_value()); + + observeProvider( + commands, providerFacts("pie_timer_create_3", 5, 6, false), 140); + const auto noCapability = service.reserve( + system, request("op_timer_create_3"), 141, 521); + assert(!noCapability.accepted); + assert(noCapability.reasonCode == + "local_provider_capability_not_observed"); + + assert(database.execute( + "UPDATE backend_agents SET agent_instance_id='inst_timer_create_2'," + "lease_expires_at=1000,updated_at=150 WHERE agent_id='agt_timer_create';")); + observeProvider( + commands, providerFacts("pie_timer_create_4", 6, 7), 150, + "inst_timer_create_2"); + const auto staleAgent = service.reserve(system, request(), 151, 531); + assert(!staleAgent.accepted); + assert(staleAgent.reasonCode == "native_timer_create_agent_fence_stale"); + + return 0; +} diff --git a/mk/phase64-native-timer-create-reservation-tests.mk b/mk/phase64-native-timer-create-reservation-tests.mk new file mode 100644 index 000000000..af60440ba --- /dev/null +++ b/mk/phase64-native-timer-create-reservation-tests.mk @@ -0,0 +1,27 @@ +.PHONY: test-phase64-native-timer-create-reservation-architecture test-phase64-native-timer-create-reservation + +test-phase64-native-timer-create-reservation-architecture: + python3 tools/check_phase64_native_timer_create_reservation.py + +test-phase64-native-timer-create-reservation: test-phase64-native-timer-create-reservation-architecture + $(BUILD_CXX) $(CXXFLAGS) \ + $(SQLITE_SRC) \ + core/security/src/AccountabilityEventRepository.cpp \ + core/security/src/CredentialVerifierRepository.cpp \ + core/security/src/SecurityIdentityRepository.cpp \ + core/security/src/SecurityIdentityProvisioningRepository.cpp \ + core/vdr/src/VdrConfig.cpp \ + core/vdr/src/BackendRegistry.cpp \ + core/vdr/src/BackendRegistryService.cpp \ + $(AGENT_CONTROL_PLANE_DOMAIN_SRC) \ + core/agent/src/BackendAgentNativeTimerDelete.cpp \ + core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp \ + core/agent/src/BackendAgentCommandReservation.cpp \ + core/agent/src/BackendAgentNativeTimerCreateReservation.cpp \ + core/agent/tests/test_backend_agent_native_timer_create_reservation.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_backend_agent_native_timer_create_reservation + $(BUILD_DIR)/test_backend_agent_native_timer_create_reservation + +test-fast: test-phase64-native-timer-create-reservation +test-architecture: test-phase64-native-timer-create-reservation-architecture diff --git a/tools/check_phase64_native_timer_create_reservation.py b/tools/check_phase64_native_timer_create_reservation.py new file mode 100755 index 000000000..869489ffb --- /dev/null +++ b/tools/check_phase64_native_timer_create_reservation.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +required = { + "core/agent/include/BackendAgentNativeTimerCreateReservation.h": [ + "BackendAgentNativeTimerCreateReservationRequest", + "expectedAssignmentRevision", + "expectedIntentRevision", + "assignmentEpoch", + "nativeTimerBindingId", + "expectedSpecificationFingerprint", + "BackendAgentNativeTimerCreateReservationService", + ], + "core/agent/src/BackendAgentNativeTimerCreateReservation.cpp": [ + "reservationRepository_.findForOperation", + "reservationRepository_.reserve(assignment, &*selection)", + "native_timer_create_provider_selection_stale", + "native_timer_create_active_assignment_conflict", + "kBackendAgentNativeTimerCreateCapability", + ], + "core/agent/tests/test_backend_agent_native_timer_create_reservation.cpp": [ + "Reservation is durable but must remain invisible", + "native_timer_create_reservation_replayed", + "native_timer_create_provider_selection_stale", + ], +} + +errors = [] +for relative, markers in required.items(): + path = ROOT / relative + if not path.exists(): + errors.append(f"missing {relative}") + continue + text = path.read_text(encoding="utf-8") + for marker in markers: + if marker not in text: + errors.append(f"{relative}: missing marker {marker}") + +source = (ROOT / "core/agent/src/BackendAgentNativeTimerCreateReservation.cpp").read_text( + encoding="utf-8" +) +if "insertAssignment(" in source: + errors.append("CREATE reservation must not directly activate/poll an Agent assignment") +if ".activate(" in source: + errors.append("CREATE reservation must not activate its command reservation") + +makefile = (ROOT / "Makefile").read_text(encoding="utf-8") +if "include mk/phase64-native-timer-create-reservation-tests.mk" not in makefile: + errors.append("Makefile: missing CREATE reservation tests include") + +if errors: + for error in errors: + print(error) + raise SystemExit(1) +print("Phase 64 native Timer CREATE reservation architecture guard passed") From 74f57c1049ca839850e654253c05b4c02028b90f Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:05:50 +0200 Subject: [PATCH 24/85] Test Phase 64 native Timer CREATE reservation --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index bdd012afd..03d0e3429 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ include mk/phase63-runtime-acceptance.mk include mk/agent-tests.mk include mk/phase64-backend-agent-command-reservation-tests.mk include mk/phase64-native-timer-create-agent-contract-tests.mk +include mk/phase64-native-timer-create-reservation-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk From 9ebe2fff5a672b980b7a03db14cdcb7db98f0cd9 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:10:14 +0200 Subject: [PATCH 25/85] Fence Phase 64 CREATE dispatch to exact reservation --- .../NativeTimerCreateDispatchService.h | 85 ++++++ .../src/NativeTimerCreateDispatchService.cpp | 280 ++++++++++++++++++ ...t_native_timer_create_dispatch_service.cpp | 188 ++++++++++++ ...ative-timer-create-dispatch-state-tests.mk | 21 ++ ...se64_native_timer_create_dispatch_state.py | 55 ++++ 5 files changed, 629 insertions(+) create mode 100644 core/timers/include/NativeTimerCreateDispatchService.h create mode 100644 core/timers/src/NativeTimerCreateDispatchService.cpp create mode 100644 core/timers/tests/test_native_timer_create_dispatch_service.cpp create mode 100644 mk/phase64-native-timer-create-dispatch-state-tests.mk create mode 100755 tools/check_phase64_native_timer_create_dispatch_state.py diff --git a/core/timers/include/NativeTimerCreateDispatchService.h b/core/timers/include/NativeTimerCreateDispatchService.h new file mode 100644 index 000000000..e11aaed4e --- /dev/null +++ b/core/timers/include/NativeTimerCreateDispatchService.h @@ -0,0 +1,85 @@ +#pragma once + +#include "MutationOperation.h" + +#include +#include + +namespace vdrsuite::operations +{ +class MutationOperationRepository; +} + +namespace vdrsuite::timers +{ + +struct NativeTimerCreateCommandReservationReference +{ + std::string commandId; + std::string requestFingerprint; +}; + +bool nativeTimerCreateCommandReservationReferenceValid( + const NativeTimerCreateCommandReservationReference& reference); +std::string serializeNativeTimerCreateCommandReservationReference( + const NativeTimerCreateCommandReservationReference& reference); +bool parseNativeTimerCreateCommandReservationReference( + const std::string& serialized, + NativeTimerCreateCommandReservationReference& reference); + +enum class NativeTimerCreateDispatchClaimStatus +{ + claimed, + alreadyClaimed, + operationNotFound, + payloadNotFound, + operationStateConflict, + operationRevisionConflict, + identityConflict, + payloadConflict, + deadlineExpired, + operationRepositoryError, + invalid, +}; + +struct NativeTimerCreateDispatchClaimRequest +{ + std::string operationId; + std::string expectedOperationRevision; + std::string timerAssignmentId; + std::string nativeTimerBindingId; + std::string backendId; + std::uint64_t backendGeneration = 0; + std::string expectedSpecificationFingerprint; + NativeTimerCreateCommandReservationReference reservation; +}; + +struct NativeTimerCreateDispatchClaimResult +{ + NativeTimerCreateDispatchClaimStatus status = + NativeTimerCreateDispatchClaimStatus::operationRepositoryError; + vdrsuite::operations::MutationOperation operation; + NativeTimerCreateCommandReservationReference reservation; + + bool ok() const + { + return status == NativeTimerCreateDispatchClaimStatus::claimed || + status == NativeTimerCreateDispatchClaimStatus::alreadyClaimed; + } +}; + +class NativeTimerCreateDispatchService +{ +public: + explicit NativeTimerCreateDispatchService( + vdrsuite::operations::MutationOperationRepository& operationRepository); + + NativeTimerCreateDispatchClaimResult claimAfterReservation( + const NativeTimerCreateDispatchClaimRequest& request, + std::int64_t claimedAt); + +private: + vdrsuite::operations::MutationOperationRepository& operationRepository_; +}; + +} // namespace vdrsuite::timers diff --git a/core/timers/src/NativeTimerCreateDispatchService.cpp b/core/timers/src/NativeTimerCreateDispatchService.cpp new file mode 100644 index 000000000..1a9245265 --- /dev/null +++ b/core/timers/src/NativeTimerCreateDispatchService.cpp @@ -0,0 +1,280 @@ +#include "NativeTimerCreateDispatchService.h" + +#include "MutationOperationRepository.h" +#include "NativeTimerCreateOperationPayload.h" +#include "NativeTimerSpecification.h" + +#include +#include +#include +#include + +namespace vdrsuite::timers +{ +namespace +{ +constexpr const char* kReferencePrefix = + "native-timer-create-command-reservation/1|"; +constexpr std::size_t kMaxIdentityLength = 160; +constexpr std::size_t kMaxFingerprintLength = 512; +constexpr std::size_t kMaxResourceFingerprintLength = 4096; + +bool boundedIdentity(const std::string& value) +{ + return !value.empty() && value.size() <= kMaxIdentityLength; +} + +void appendField(std::string& output, const std::string& value) +{ + output += std::to_string(value.size()); + output += ':'; + output += value; + output += '|'; +} + +bool readField(const std::string& input, std::size_t& position, std::string& value) +{ + if (position >= input.size()) return false; + std::size_t length = 0; + bool digitSeen = false; + while (position < input.size() && input[position] != ':') + { + const char character = input[position++]; + if (character < '0' || character > '9') return false; + digitSeen = true; + const std::size_t digit = static_cast(character - '0'); + if (length > (std::numeric_limits::max() - digit) / 10) + return false; + length = length * 10 + digit; + } + if (!digitSeen || position >= input.size() || input[position] != ':') return false; + ++position; + if (length > input.size() - position) return false; + value = input.substr(position, length); + position += length; + if (position >= input.size() || input[position] != '|') return false; + ++position; + return true; +} + +NativeTimerCreateDispatchClaimResult result( + NativeTimerCreateDispatchClaimStatus status, + const vdrsuite::operations::MutationOperation& operation = {}, + const NativeTimerCreateCommandReservationReference& reservation = {}) +{ + NativeTimerCreateDispatchClaimResult value; + value.status = status; + value.operation = operation; + value.reservation = reservation; + return value; +} + +bool requestValid(const NativeTimerCreateDispatchClaimRequest& request) +{ + return boundedIdentity(request.operationId) && + boundedIdentity(request.expectedOperationRevision) && + boundedIdentity(request.timerAssignmentId) && + boundedIdentity(request.nativeTimerBindingId) && + boundedIdentity(request.backendId) && request.backendGeneration > 0 && + !request.expectedSpecificationFingerprint.empty() && + request.expectedSpecificationFingerprint.size() <= + kMaxResourceFingerprintLength && + nativeTimerCreateCommandReservationReferenceValid(request.reservation); +} +} + +bool nativeTimerCreateCommandReservationReferenceValid( + const NativeTimerCreateCommandReservationReference& reference) +{ + return boundedIdentity(reference.commandId) && + !reference.requestFingerprint.empty() && + reference.requestFingerprint.size() <= kMaxFingerprintLength; +} + +std::string serializeNativeTimerCreateCommandReservationReference( + const NativeTimerCreateCommandReservationReference& reference) +{ + if (!nativeTimerCreateCommandReservationReferenceValid(reference)) return {}; + std::string serialized(kReferencePrefix); + appendField(serialized, reference.commandId); + appendField(serialized, reference.requestFingerprint); + return serialized.size() <= 512 ? serialized : std::string{}; +} + +bool parseNativeTimerCreateCommandReservationReference( + const std::string& serialized, + NativeTimerCreateCommandReservationReference& reference) +{ + const std::string prefix(kReferencePrefix); + if (serialized.compare(0, prefix.size(), prefix) != 0 || + serialized.size() > 512) + return false; + std::size_t position = prefix.size(); + NativeTimerCreateCommandReservationReference candidate; + if (!readField(serialized, position, candidate.commandId) || + !readField(serialized, position, candidate.requestFingerprint) || + position != serialized.size() || + !nativeTimerCreateCommandReservationReferenceValid(candidate) || + serializeNativeTimerCreateCommandReservationReference(candidate) != serialized) + return false; + reference = candidate; + return true; +} + +NativeTimerCreateDispatchService::NativeTimerCreateDispatchService( + vdrsuite::operations::MutationOperationRepository& operationRepository) + : operationRepository_(operationRepository) +{ +} + +NativeTimerCreateDispatchClaimResult +NativeTimerCreateDispatchService::claimAfterReservation( + const NativeTimerCreateDispatchClaimRequest& request, + std::int64_t claimedAt) +{ + using namespace vdrsuite::operations; + if (!requestValid(request) || claimedAt <= 0) + return result(NativeTimerCreateDispatchClaimStatus::invalid); + + const auto found = operationRepository_.findById(request.operationId); + if (found.status == MutationOperationRepositoryStatus::notFound) + return result(NativeTimerCreateDispatchClaimStatus::operationNotFound); + if (!found.ok()) + return result(NativeTimerCreateDispatchClaimStatus::operationRepositoryError); + const MutationOperation& operation = found.operation; + + const auto durablePayload = + operationRepository_.findPayloadByOperationId(request.operationId); + if (durablePayload.status == MutationOperationRepositoryStatus::notFound) + return result( + NativeTimerCreateDispatchClaimStatus::payloadNotFound, + operation); + if (!durablePayload.ok()) + return result( + NativeTimerCreateDispatchClaimStatus::operationRepositoryError, + operation); + if (durablePayload.payload.payloadType != "native.timer.create" || + durablePayload.payload.payloadVersion != 1) + return result( + NativeTimerCreateDispatchClaimStatus::payloadConflict, + operation); + + NativeTimerCreateOperationPayload payload; + if (!parseNativeTimerCreateOperationPayload( + durablePayload.payload.payload, payload) || + nativeTimerCreateOperationPayloadFingerprint(payload) != + durablePayload.payload.payloadFingerprint) + return result( + NativeTimerCreateDispatchClaimStatus::payloadConflict, + operation); + + const std::string specificationFingerprint = + nativeTimerSpecificationFingerprint(payload.expectedSpecification); + if (operation.actionFamily != "timer.create" || + operation.resourceType != "TimerAssignment" || + operation.resourceId != request.timerAssignmentId || + operation.backendId != request.backendId || + operation.backendGeneration != request.backendGeneration || + payload.timerAssignmentId != request.timerAssignmentId || + payload.nativeTimerBindingId != request.nativeTimerBindingId || + payload.backendId != request.backendId || + payload.backendGeneration != request.backendGeneration) + return result( + NativeTimerCreateDispatchClaimStatus::identityConflict, + operation); + + if (specificationFingerprint.empty() || + specificationFingerprint != request.expectedSpecificationFingerprint || + operation.expectedResourceFingerprint != specificationFingerprint) + return result( + NativeTimerCreateDispatchClaimStatus::payloadConflict, + operation); + + if (operation.verificationPolicy != + MutationOperationVerificationPolicy::readbackRequired) + return result( + NativeTimerCreateDispatchClaimStatus::operationStateConflict, + operation); + + if (operation.state != MutationOperationState::accepted && + operation.state != MutationOperationState::dispatching) + return result( + NativeTimerCreateDispatchClaimStatus::operationStateConflict, + operation); + if (operation.state == MutationOperationState::accepted && + operation.operationRevision != request.expectedOperationRevision) + return result( + NativeTimerCreateDispatchClaimStatus::operationRevisionConflict, + operation); + if (operation.state == MutationOperationState::accepted && + operation.deadline != 0 && claimedAt > operation.deadline) + return result( + NativeTimerCreateDispatchClaimStatus::deadlineExpired, + operation); + + const std::string reservationReference = + serializeNativeTimerCreateCommandReservationReference(request.reservation); + if (reservationReference.empty()) + return result(NativeTimerCreateDispatchClaimStatus::invalid, operation); + + if (operation.state == MutationOperationState::dispatching) + { + NativeTimerCreateCommandReservationReference existingReference; + if (!parseNativeTimerCreateCommandReservationReference( + operation.resultReference, existingReference) || + existingReference.commandId != request.reservation.commandId || + existingReference.requestFingerprint != + request.reservation.requestFingerprint) + return result( + NativeTimerCreateDispatchClaimStatus::identityConflict, + operation); + return result( + NativeTimerCreateDispatchClaimStatus::alreadyClaimed, + operation, + existingReference); + } + + const auto transitioned = operationRepository_.transition( + request.operationId, + request.expectedOperationRevision, + MutationOperationState::accepted, + MutationOperationState::dispatching, + reservationReference, + claimedAt); + switch (transitioned.status) + { + case MutationOperationRepositoryStatus::ok: + return result( + NativeTimerCreateDispatchClaimStatus::claimed, + transitioned.operation, + request.reservation); + case MutationOperationRepositoryStatus::idempotentReplay: + return result( + NativeTimerCreateDispatchClaimStatus::alreadyClaimed, + transitioned.operation, + request.reservation); + case MutationOperationRepositoryStatus::notFound: + return result(NativeTimerCreateDispatchClaimStatus::operationNotFound); + case MutationOperationRepositoryStatus::revisionConflict: + return result( + NativeTimerCreateDispatchClaimStatus::operationRevisionConflict, + transitioned.operation); + case MutationOperationRepositoryStatus::stateConflict: + return result( + NativeTimerCreateDispatchClaimStatus::operationStateConflict, + transitioned.operation); + case MutationOperationRepositoryStatus::invalid: + return result(NativeTimerCreateDispatchClaimStatus::invalid); + case MutationOperationRepositoryStatus::storageError: + return result( + NativeTimerCreateDispatchClaimStatus::operationRepositoryError); + case MutationOperationRepositoryStatus::idempotencyConflict: + case MutationOperationRepositoryStatus::operationConflict: + return result( + NativeTimerCreateDispatchClaimStatus::operationStateConflict, + transitioned.operation); + } + return result(NativeTimerCreateDispatchClaimStatus::operationRepositoryError); +} + +} // namespace vdrsuite::timers diff --git a/core/timers/tests/test_native_timer_create_dispatch_service.cpp b/core/timers/tests/test_native_timer_create_dispatch_service.cpp new file mode 100644 index 000000000..16915a89c --- /dev/null +++ b/core/timers/tests/test_native_timer_create_dispatch_service.cpp @@ -0,0 +1,188 @@ +#include "Database.h" +#include "MutationOperationRepository.h" +#include "NativeTimerCreateDispatchService.h" +#include "NativeTimerCreateOperationPayload.h" +#include "NativeTimerSpecification.h" + +#include +#include + +namespace +{ +vdrsuite::timers::NativeTimerSpecification specification() +{ + vdrsuite::timers::NativeTimerSpecification value; + value.channelId = "C-1-2-3"; + value.title = "Phase 64 dispatch"; + value.directory = "Tests"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1030"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +vdrsuite::timers::NativeTimerCreateOperationPayload createPayload() +{ + vdrsuite::timers::NativeTimerCreateOperationPayload payload; + payload.timerAssignmentId = "ta_create_dispatch_1"; + payload.expectedAssignmentRevision = "2"; + payload.expectedIntentRevision = "5"; + payload.assignmentEpoch = 3; + payload.nativeTimerBindingId = "ntb_create_dispatch_1"; + payload.backendId = "default"; + payload.backendGeneration = 7; + payload.expectedSpecification = specification(); + return payload; +} + +vdrsuite::operations::MutationOperation operation() +{ + using namespace vdrsuite::operations; + MutationOperation value; + value.operationId = "op_create_dispatch_1"; + value.idempotencyKey = "idem_create_dispatch_1"; + value.actorId = "system_phase64"; + value.backendId = "default"; + value.backendGeneration = 7; + value.resourceType = "TimerAssignment"; + value.resourceId = "ta_create_dispatch_1"; + value.expectedRevision = "2"; + value.expectedResourceFingerprint = + vdrsuite::timers::nativeTimerSpecificationFingerprint(specification()); + value.actionFamily = "timer.create"; + value.requestFingerprint = "request_create_dispatch_1"; + value.requestedAt = 100; + value.deadline = 500; + value.verificationPolicy = MutationOperationVerificationPolicy::readbackRequired; + value.state = MutationOperationState::accepted; + value.updatedAt = 100; + return value; +} + +vdrsuite::timers::NativeTimerCreateDispatchClaimRequest request() +{ + using namespace vdrsuite::timers; + NativeTimerCreateDispatchClaimRequest value; + value.operationId = "op_create_dispatch_1"; + value.expectedOperationRevision = "1"; + value.timerAssignmentId = "ta_create_dispatch_1"; + value.nativeTimerBindingId = "ntb_create_dispatch_1"; + value.backendId = "default"; + value.backendGeneration = 7; + value.expectedSpecificationFingerprint = + nativeTimerSpecificationFingerprint(specification()); + value.reservation.commandId = "cmd_create_dispatch_1"; + value.reservation.requestFingerprint = "fp1_0123456789abcdef"; + return value; +} +} + +int main() +{ + using namespace vdrsuite::operations; + using namespace vdrsuite::timers; + + Database database; + assert(database.open(":memory:")); + MutationOperationRepository repository(database); + assert(repository.ensureSchema()); + + const auto payload = createPayload(); + MutationOperationPayload durablePayload; + durablePayload.operationId = "op_create_dispatch_1"; + durablePayload.payloadType = "native.timer.create"; + durablePayload.payloadVersion = 1; + durablePayload.payload = serializeNativeTimerCreateOperationPayload(payload); + durablePayload.payloadFingerprint = + nativeTimerCreateOperationPayloadFingerprint(payload); + const auto reservedOperation = + repository.reserveWithPayload(operation(), durablePayload); + assert(reservedOperation.ok()); + assert(reservedOperation.operation.operationRevision == "1"); + + const auto reference = request().reservation; + const std::string serializedReference = + serializeNativeTimerCreateCommandReservationReference(reference); + assert(!serializedReference.empty()); + NativeTimerCreateCommandReservationReference parsedReference; + assert(parseNativeTimerCreateCommandReservationReference( + serializedReference, parsedReference)); + assert(parsedReference.commandId == reference.commandId); + assert(parsedReference.requestFingerprint == reference.requestFingerprint); + assert(!parseNativeTimerCreateCommandReservationReference( + serializedReference + "x", parsedReference)); + + NativeTimerCreateDispatchService service(repository); + const auto claimed = service.claimAfterReservation(request(), 120); + assert(claimed.status == NativeTimerCreateDispatchClaimStatus::claimed); + assert(claimed.operation.state == MutationOperationState::dispatching); + assert(claimed.operation.operationRevision == "2"); + assert(claimed.operation.resultReference == serializedReference); + assert(claimed.reservation.commandId == reference.commandId); + + // Restart/replay must recover the same durable reservation reference. It + // must never accept a fresh command identity for the same CREATE. + const auto replay = service.claimAfterReservation(request(), 121); + assert(replay.status == NativeTimerCreateDispatchClaimStatus::alreadyClaimed); + assert(replay.operation.operationRevision == "2"); + assert(replay.reservation.commandId == reference.commandId); + + auto differentReservation = request(); + differentReservation.reservation.commandId = "cmd_create_dispatch_other"; + differentReservation.reservation.requestFingerprint = "fp1_fedcba9876543210"; + const auto reservationConflict = + service.claimAfterReservation(differentReservation, 122); + assert(reservationConflict.status == + NativeTimerCreateDispatchClaimStatus::identityConflict); + + auto wrongBinding = request(); + wrongBinding.nativeTimerBindingId = "ntb_create_dispatch_other"; + const auto bindingConflict = service.claimAfterReservation(wrongBinding, 123); + assert(bindingConflict.status == + NativeTimerCreateDispatchClaimStatus::identityConflict); + + // A fresh operation cannot enter dispatching with a stale operation revision. + auto secondOperation = operation(); + secondOperation.operationId = "op_create_dispatch_2"; + secondOperation.idempotencyKey = "idem_create_dispatch_2"; + auto secondPayload = payload; + MutationOperationPayload secondDurable = durablePayload; + secondDurable.operationId = secondOperation.operationId; + secondDurable.payload = serializeNativeTimerCreateOperationPayload(secondPayload); + secondDurable.payloadFingerprint = + nativeTimerCreateOperationPayloadFingerprint(secondPayload); + assert(repository.reserveWithPayload(secondOperation, secondDurable).ok()); + + auto staleRevision = request(); + staleRevision.operationId = secondOperation.operationId; + staleRevision.expectedOperationRevision = "9"; + staleRevision.reservation.commandId = "cmd_create_dispatch_2"; + staleRevision.reservation.requestFingerprint = "fp1_1111111111111111"; + const auto revisionConflict = + service.claimAfterReservation(staleRevision, 124); + assert(revisionConflict.status == + NativeTimerCreateDispatchClaimStatus::operationRevisionConflict); + + // Deadline is enforced before first dispatch claim, but never retroactively + // invalidates an already-dispatching operation on restart. + auto expiredOperation = operation(); + expiredOperation.operationId = "op_create_dispatch_expired"; + expiredOperation.idempotencyKey = "idem_create_dispatch_expired"; + expiredOperation.deadline = 110; + MutationOperationPayload expiredPayload = durablePayload; + expiredPayload.operationId = expiredOperation.operationId; + assert(repository.reserveWithPayload(expiredOperation, expiredPayload).ok()); + auto expiredRequest = request(); + expiredRequest.operationId = expiredOperation.operationId; + expiredRequest.reservation.commandId = "cmd_create_dispatch_expired"; + expiredRequest.reservation.requestFingerprint = "fp1_2222222222222222"; + const auto expired = service.claimAfterReservation(expiredRequest, 120); + assert(expired.status == NativeTimerCreateDispatchClaimStatus::deadlineExpired); + + return 0; +} diff --git a/mk/phase64-native-timer-create-dispatch-state-tests.mk b/mk/phase64-native-timer-create-dispatch-state-tests.mk new file mode 100644 index 000000000..90f28122c --- /dev/null +++ b/mk/phase64-native-timer-create-dispatch-state-tests.mk @@ -0,0 +1,21 @@ +.PHONY: test-phase64-native-timer-create-dispatch-state-architecture test-phase64-native-timer-create-dispatch-state + +test-phase64-native-timer-create-dispatch-state-architecture: + python3 tools/check_phase64_native_timer_create_dispatch_state.py + +test-phase64-native-timer-create-dispatch-state: test-phase64-native-timer-create-dispatch-state-architecture + $(BUILD_CXX) $(CXXFLAGS) \ + $(SQLITE_SRC) \ + core/operations/src/MutationOperation.cpp \ + core/operations/src/MutationOperationRepository.cpp \ + core/timers/src/NativeTimerObservedState.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/src/NativeTimerCreateOperationPayload.cpp \ + core/timers/src/NativeTimerCreateDispatchService.cpp \ + core/timers/tests/test_native_timer_create_dispatch_service.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_create_dispatch_service + $(BUILD_DIR)/test_native_timer_create_dispatch_service + +test-fast: test-phase64-native-timer-create-dispatch-state +test-architecture: test-phase64-native-timer-create-dispatch-state-architecture diff --git a/tools/check_phase64_native_timer_create_dispatch_state.py b/tools/check_phase64_native_timer_create_dispatch_state.py new file mode 100755 index 000000000..cf1d17876 --- /dev/null +++ b/tools/check_phase64_native_timer_create_dispatch_state.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +required = { + "core/timers/include/NativeTimerCreateDispatchService.h": [ + "NativeTimerCreateCommandReservationReference", + "NativeTimerCreateDispatchClaimRequest", + "claimAfterReservation", + "alreadyClaimed", + ], + "core/timers/src/NativeTimerCreateDispatchService.cpp": [ + 'native-timer-create-command-reservation/1|', + 'MutationOperationState::accepted', + 'MutationOperationState::dispatching', + 'parseNativeTimerCreateCommandReservationReference', + 'native.timer.create', + 'operation.expectedResourceFingerprint', + ], + "core/timers/tests/test_native_timer_create_dispatch_service.cpp": [ + "must never accept a fresh command identity", + "operationRevision == \"2\"", + "deadlineExpired", + ], +} + +errors = [] +for relative, markers in required.items(): + path = ROOT / relative + if not path.exists(): + errors.append(f"missing {relative}") + continue + text = path.read_text(encoding="utf-8") + for marker in markers: + if marker not in text: + errors.append(f"{relative}: missing marker {marker}") + +source = (ROOT / "core/timers/src/NativeTimerCreateDispatchService.cpp").read_text( + encoding="utf-8" +) +if "BackendAgent" in source: + errors.append("timer dispatch state must stay backend/Agent neutral") +if "insertAssignment(" in source or ".activate(" in source: + errors.append("timer dispatch state must not activate an Agent command") + +makefile = (ROOT / "Makefile").read_text(encoding="utf-8") +if "include mk/phase64-native-timer-create-dispatch-state-tests.mk" not in makefile: + errors.append("Makefile: missing CREATE dispatch-state tests include") + +if errors: + for error in errors: + print(error) + raise SystemExit(1) +print("Phase 64 native Timer CREATE dispatch-state architecture guard passed") From 466b855721fe05e8f2b0afdafd3b322c48485695 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:10:37 +0200 Subject: [PATCH 26/85] Test Phase 64 native Timer CREATE dispatch state --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 03d0e3429..ef9aafb00 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ include mk/agent-tests.mk include mk/phase64-backend-agent-command-reservation-tests.mk include mk/phase64-native-timer-create-agent-contract-tests.mk include mk/phase64-native-timer-create-reservation-tests.mk +include mk/phase64-native-timer-create-dispatch-state-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk From fb4a7843c195f65f2a135e01e2d88dc39fbfcedc Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:14:52 +0200 Subject: [PATCH 27/85] Fence Phase 64 CREATE command delivery to provider selection --- .../agent/src/BackendAgentCommandDelivery.cpp | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/core/agent/src/BackendAgentCommandDelivery.cpp b/core/agent/src/BackendAgentCommandDelivery.cpp index ad5af337b..4935e93c7 100644 --- a/core/agent/src/BackendAgentCommandDelivery.cpp +++ b/core/agent/src/BackendAgentCommandDelivery.cpp @@ -4,6 +4,7 @@ #include "AccountabilityEventRepository.h" #include "BackendAgentLifecycle.h" #include "BackendAgentNativeProbe.h" +#include "BackendAgentNativeTimerCreatePayload.h" #include "BackendAgentNativeTimerDeleteAdvertisement.h" #include "BackendAgentNativeTimerDeletePayload.h" #include "Database.h" @@ -310,8 +311,9 @@ bool BackendAgentCommandRepository::localProviderSelectionCurrent( {sqlite3_finalize(s);reason="command_not_found";return false;} const std::string commandType=text(s,0); const bool nativeProbe=commandType=="vdr.native.probe"; + const bool timerCreate=commandType==kBackendAgentNativeTimerCreateCommandType; const bool timerDelete=commandType==kBackendAgentNativeTimerDeleteCommandType; - if(!nativeProbe&&!timerDelete) + if(!nativeProbe&&!timerCreate&&!timerDelete) {sqlite3_finalize(s);reason="local_provider_selection_not_required";return true;} if(sqlite3_column_type(s,4)==SQLITE_NULL) {sqlite3_finalize(s);reason="local_provider_selection_required";return false;} @@ -330,6 +332,12 @@ bool BackendAgentCommandRepository::localProviderSelectionCurrent( if(!backendAgentLocalProviderValidSelection(selection)|| identity!=backendAgentLocalProviderSelectionIdentity(selection)) {reason="local_provider_selection_invalid";return false;} + if(timerCreate&& + (selection.authorityDomain!=kBackendAgentNativeTimerCreateAuthorityDomain|| + selection.providerId!=kBackendAgentNativeTimerCreateProviderId|| + selection.providerKind!=kBackendAgentNativeTimerCreateProviderKind|| + selection.requiredCapability!=kBackendAgentNativeTimerCreateCapability)) + {reason="native_timer_create_provider_selection_mismatch";return false;} if(timerDelete&& (selection.authorityDomain!=kBackendAgentNativeTimerDeleteAuthorityDomain|| selection.providerId!=kBackendAgentNativeTimerDeleteProviderId|| @@ -404,7 +412,8 @@ BackendAgentCommandPollResult BackendAgentCommandRepository::poll(const BackendA } } if(ok&&result.assignment.present&& - result.assignment.commandType==kBackendAgentNativeTimerDeleteCommandType) + (result.assignment.commandType==kBackendAgentNativeTimerCreateCommandType|| + result.assignment.commandType==kBackendAgentNativeTimerDeleteCommandType)) { std::string providerReason; if(!localProviderSelectionCurrent(result.assignment.commandId,providerReason)) @@ -453,6 +462,12 @@ BackendAgentCommandReceiptResult BackendAgentCommandRepository::acceptReceipt(co return result; } } + else if(commandType==kBackendAgentNativeTimerCreateCommandType&& + payloadVersion!=kBackendAgentNativeTimerCreatePayloadVersion) + { + result.reasonCode="local_provider_selection_required"; + return result; + } else if(commandType==kBackendAgentNativeTimerDeleteCommandType&& payloadVersion!=kBackendAgentNativeTimerDeletePayloadVersion) { @@ -467,7 +482,8 @@ BackendAgentCommandReceiptResult BackendAgentCommandRepository::acceptReceipt(co result.accepted=text(existing,0)==identity; result.replayed=result.accepted; result.reasonCode=result.accepted?"command_receipt_replayed":"command_receipt_conflict"; sqlite3_finalize(existing); if(result.accepted){sqlite3_stmt* replay=nullptr;const char* replaySql="UPDATE backend_agent_commands SET receipt_replay_count=receipt_replay_count+1 WHERE command_id=?;";if(sqlite3_prepare_v2(database_.handle(),replaySql,-1,&replay,nullptr)!=SQLITE_OK||!bindText(replay,1,r.commandId)||!done(replay)){result.accepted=false;result.replayed=false;result.reasonCode="command_database_unavailable";}} return result; } sqlite3_finalize(existing); - if(commandType==kBackendAgentNativeTimerDeleteCommandType) + if(commandType==kBackendAgentNativeTimerCreateCommandType|| + commandType==kBackendAgentNativeTimerDeleteCommandType) { std::string providerReason; if(!localProviderSelectionCurrent(r.commandId,providerReason)) @@ -524,6 +540,25 @@ BackendAgentCommandResultAck BackendAgentCommandRepository::acceptResult(const B return result; } } + else if(commandType==kBackendAgentNativeTimerCreateCommandType) + { + if(payloadVersion!=kBackendAgentNativeTimerCreatePayloadVersion) + {result.reasonCode="local_provider_selection_required";return result;} + BackendAgentNativeTimerCreatePayload createPayload; + std::string payloadReason; + if(!backendAgentNativeTimerCreateParsePayload(payload,createPayload,payloadReason)) + {result.reasonCode="local_provider_selection_invalid";return result;} + const std::string expectedIdentity=backendAgentLocalProviderSelectionIdentity( + createPayload.localProviderSelection); + sqlite3_stmt* selected=nullptr; + if(sqlite3_prepare_v2(database_.handle(),"SELECT selection_identity FROM backend_agent_command_provider_selections WHERE command_id=?;",-1,&selected,nullptr)!=SQLITE_OK||!bindText(selected,1,r.commandId)) + {if(selected)sqlite3_finalize(selected);result.reasonCode="command_database_unavailable";return result;} + const bool recorded=sqlite3_step(selected)==SQLITE_ROW&& + text(selected,0)==expectedIdentity; + sqlite3_finalize(selected); + if(!recorded) + {result.reasonCode="local_provider_selection_required";return result;} + } else if(commandType==kBackendAgentNativeTimerDeleteCommandType) { if(payloadVersion!=kBackendAgentNativeTimerDeletePayloadVersion) From 423617f69268c08296cdad654f3c5c28a6ac927f Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:15:45 +0200 Subject: [PATCH 28/85] Keep Phase 64 CREATE Agent advertisement closed --- core/agent/src/BackendAgentCommandClient.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/agent/src/BackendAgentCommandClient.cpp b/core/agent/src/BackendAgentCommandClient.cpp index 6dd723d26..ebda2e508 100644 --- a/core/agent/src/BackendAgentCommandClient.cpp +++ b/core/agent/src/BackendAgentCommandClient.cpp @@ -4,6 +4,7 @@ #include "BackendAgentCommandJson.h" #include "BackendAgentCommandStateStore.h" #include "BackendAgentNativeProbeCommandHandler.h" +#include "BackendAgentNativeTimerCreate.h" #include "BackendAgentNativeTimerDeleteCommandHandler.h" #include @@ -174,8 +175,8 @@ CommandAvailability availableCommands( CommandAvailability availability; for (const std::string& type : config.commandTypes) { - if (type == - vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType) + if (type == vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType || + type == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType) continue; if (type != "vdr.native.probe") { From ef7aa8d8ad129dcce114c6942e707a198154cba1 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:16:47 +0200 Subject: [PATCH 29/85] Test Phase 64 native Timer CREATE delivery fences --- ...end_agent_native_timer_create_delivery.cpp | 283 ++++++++++++++++++ ...se64-native-timer-create-delivery-tests.mk | 25 ++ ...ck_phase64_native_timer_create_delivery.py | 56 ++++ 3 files changed, 364 insertions(+) create mode 100644 core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp create mode 100644 mk/phase64-native-timer-create-delivery-tests.mk create mode 100755 tools/check_phase64_native_timer_create_delivery.py diff --git a/core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp b/core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp new file mode 100644 index 000000000..4f7c0487c --- /dev/null +++ b/core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp @@ -0,0 +1,283 @@ +#include "BackendAgentCommandDelivery.h" +#include "BackendAgentNativeTimerCreate.h" +#include "BackendAgentNativeTimerCreatePayload.h" +#include "Database.h" + +#include +#include +#include +#include + +namespace +{ +using vdrsuite::agent::BackendAgentLocalProviderFacts; +using vdrsuite::agent::BackendAgentLocalProviderSelection; + +BackendAgentLocalProviderFacts providerFacts( + const std::string& epoch, + std::uint64_t generation, + std::uint64_t capabilityRevision, + bool includeCreate = true) +{ + BackendAgentLocalProviderFacts facts; + facts.providerId = "suitebridge:local"; + facts.providerKind = "suitebridge"; + facts.providerInstanceEpoch = epoch; + facts.providerGeneration = generation; + facts.capabilityRevision = capabilityRevision; + facts.available = true; + facts.capabilities = includeCreate + ? std::vector{"vdr.timer.create"} + : std::vector{"vdr.native.probe"}; + return facts; +} + +BackendAgentCommandPollRequest pollRequest( + const std::vector& commandTypes, + const std::vector& providers) +{ + BackendAgentCommandPollRequest poll; + poll.backendId = "default"; + poll.agentInstanceId = "inst_timer_create_delivery"; + poll.backendGeneration = 7; + poll.supportedCommandTypes = commandTypes; + poll.localProviders = providers; + return poll; +} + +vdrsuite::agent::BackendAgentNativeTimerCreateSpecification specification( + const std::string& suffix) +{ + vdrsuite::agent::BackendAgentNativeTimerCreateSpecification value; + value.channelId = "C-1-2-3"; + value.title = "Phase 64 CREATE " + suffix; + value.directory = "Tests"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1030"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +BackendAgentCommandAssignment assignment( + const std::string& suffix, + const BackendAgentLocalProviderSelection& selection, + std::int64_t assignedAt) +{ + using namespace vdrsuite::agent; + BackendAgentNativeTimerCreatePayload payload; + payload.operationRevision = "2"; + payload.timerAssignmentId = "ta_create_delivery_" + suffix; + payload.expectedAssignmentRevision = "4"; + payload.expectedIntentRevision = "9"; + payload.assignmentEpoch = 3; + payload.nativeTimerBindingId = "ntb_create_delivery_" + suffix; + payload.controlPlaneClaimedAt = assignedAt - 1; + payload.specification = specification(suffix); + payload.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint(payload.specification); + payload.localProviderSelection = selection; + + BackendAgentCommandAssignment value; + value.present = true; + value.requestId = "req_create_delivery_" + suffix; + value.correlationId = value.requestId; + value.operationId = "op_create_delivery_" + suffix; + value.jobId = "job_create_delivery_" + suffix; + value.attemptId = "attempt_create_delivery_" + suffix; + value.claimEpoch = 1; + value.commandId = "cmd_create_delivery_" + suffix; + value.backendId = "default"; + value.agentId = "agt_create_delivery"; + value.agentInstanceId = "inst_timer_create_delivery"; + value.backendGeneration = 7; + value.commandType = kBackendAgentNativeTimerCreateCommandType; + value.payloadVersion = kBackendAgentNativeTimerCreatePayloadVersion; + value.payload = backendAgentNativeTimerCreatePayload(payload); + value.verificationPolicy = "readback_required"; + value.assignedAt = assignedAt; + value.deadline = assignedAt + 300; + value.requestFingerprint = backendAgentCommandFingerprint(value); + assert(backendAgentCommandValidAssignment(value)); + return value; +} + +BackendAgentCommandReceipt receiptFor( + const BackendAgentCommandAssignment& value, + std::int64_t receivedAt) +{ + BackendAgentCommandReceipt receipt; + receipt.commandId = value.commandId; + receipt.requestFingerprint = value.requestFingerprint; + receipt.jobId = value.jobId; + receipt.attemptId = value.attemptId; + receipt.claimEpoch = value.claimEpoch; + receipt.backendId = value.backendId; + receipt.agentId = value.agentId; + receipt.agentInstanceId = value.agentInstanceId; + receipt.backendGeneration = value.backendGeneration; + receipt.receiptCategory = "accepted"; + receipt.receivedAt = receivedAt; + receipt.reasonCode = "durably_recorded_before_native_create"; + assert(backendAgentCommandValidReceipt(receipt)); + return receipt; +} + +BackendAgentCommandResult boundedResult( + const BackendAgentCommandAssignment& value, + std::int64_t completedAt) +{ + BackendAgentCommandResult result; + result.commandId = value.commandId; + result.requestFingerprint = value.requestFingerprint; + result.jobId = value.jobId; + result.attemptId = value.attemptId; + result.claimEpoch = value.claimEpoch; + result.backendId = value.backendId; + result.agentId = value.agentId; + result.agentInstanceId = value.agentInstanceId; + result.backendGeneration = value.backendGeneration; + result.dispatchState = "accepted_by_executor"; + result.verificationState = "outcome_unknown"; + result.resultCategory = "outcome_unknown"; + result.errorCategory = "executor_unknown"; + result.retryClassification = "reconcile_only"; + result.boundedDiagnostics = "CREATE dispatched; authoritative readback required"; + result.completedAt = completedAt; + assert(backendAgentCommandValidResult(result)); + return result; +} +} + +int main() +{ + using namespace vdrsuite::agent; + + Database database; + assert(database.open(":memory:")); + BackendAgentCommandRepository commands(database); + assert(commands.ensureSchema()); + + BackendAgentLocalProviderOwnership ownership; + std::string reason; + assert(commands.setLocalProviderOwnership( + "default", kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateProviderId, + kBackendAgentNativeTimerCreateProviderKind, + {kBackendAgentNativeTimerCreateCapability}, + 100, ownership, reason)); + + const auto firstFacts = providerFacts("pie_create_delivery_1", 3, 4); + const auto observed = commands.poll( + pollRequest({"probe.noop"}, {firstFacts}), + "agt_create_delivery", 101); + assert(observed.accepted); + assert(!observed.assignment.present); + + const auto firstSelection = commands.selectLocalProvider( + "default", "agt_create_delivery", "inst_timer_create_delivery", 7, + kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateCapability, reason); + assert(firstSelection.has_value()); + + const auto first = assignment("1", *firstSelection, 110); + assert(commands.insertAssignment(first, &*firstSelection)); + + // Provider reachability/facts alone never makes the command executable. + const auto advertisementClosed = commands.poll( + pollRequest({"probe.noop"}, {firstFacts}), + "agt_create_delivery", 111); + assert(advertisementClosed.accepted); + assert(!advertisementClosed.assignment.present); + + // Repository-level fixture can explicitly advertise CREATE to exercise the + // delivery boundary. Production Agent advertisement remains closed elsewhere. + const auto delivered = commands.poll( + pollRequest({kBackendAgentNativeTimerCreateCommandType}, {firstFacts}), + "agt_create_delivery", 120); + assert(delivered.accepted); + assert(delivered.assignment.present); + assert(delivered.assignment.commandId == first.commandId); + + // Provider generation/epoch drift before the first durable receipt fences + // both redelivery and receipt acceptance. + const auto secondFacts = providerFacts("pie_create_delivery_2", 4, 5); + const auto staleRedelivery = commands.poll( + pollRequest({kBackendAgentNativeTimerCreateCommandType}, {secondFacts}), + "agt_create_delivery", 130); + assert(staleRedelivery.accepted); + assert(!staleRedelivery.assignment.present); + assert(staleRedelivery.reasonCode == "local_provider_selection_stale"); + + const auto staleReceipt = commands.acceptReceipt(receiptFor(first, 131)); + assert(!staleReceipt.accepted); + assert(staleReceipt.reasonCode == "local_provider_selection_stale"); + + assert(database.execute( + "UPDATE backend_agent_commands SET state='expired' " + "WHERE command_id='cmd_create_delivery_1';")); + + const auto secondSelection = commands.selectLocalProvider( + "default", "agt_create_delivery", "inst_timer_create_delivery", 7, + kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateCapability, reason); + assert(secondSelection.has_value()); + const auto second = assignment("2", *secondSelection, 132); + assert(commands.insertAssignment(second, &*secondSelection)); + + const auto secondDelivery = commands.poll( + pollRequest({kBackendAgentNativeTimerCreateCommandType}, {secondFacts}), + "agt_create_delivery", 133); + assert(secondDelivery.accepted); + assert(secondDelivery.assignment.present); + assert(secondDelivery.assignment.commandId == second.commandId); + + const auto receipt = receiptFor(second, 134); + const auto acceptedReceipt = commands.acceptReceipt(receipt); + assert(acceptedReceipt.accepted); + assert(!acceptedReceipt.replayed); + + // Once the receipt is durable, later provider drift cannot cause a blind + // redispatch. Exact lost-response receipt/result replay remains tied to the + // persisted provider selection and must flow into reconciliation/readback. + const auto thirdFacts = providerFacts("pie_create_delivery_3", 5, 6); + const auto observeDrift = commands.poll( + pollRequest({kBackendAgentNativeTimerCreateCommandType}, {thirdFacts}), + "agt_create_delivery", 140); + assert(observeDrift.accepted); + assert(!observeDrift.assignment.present); + + const auto replayedReceipt = commands.acceptReceipt(receipt); + assert(replayedReceipt.accepted); + assert(replayedReceipt.replayed); + + const auto result = boundedResult(second, 141); + const auto acceptedResult = commands.acceptResult(result); + assert(acceptedResult.accepted); + assert(!acceptedResult.replayed); + const auto replayedResult = commands.acceptResult(result); + assert(replayedResult.accepted); + assert(replayedResult.replayed); + + // A CREATE assignment without its durable provider-selection sidecar is + // never deliverable, even when the currently observed provider is healthy. + const auto currentSelection = commands.selectLocalProvider( + "default", "agt_create_delivery", "inst_timer_create_delivery", 7, + kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateCapability, reason); + assert(currentSelection.has_value()); + const auto unbound = assignment("3", *currentSelection, 150); + assert(commands.insertAssignment(unbound)); + const auto missingSelection = commands.poll( + pollRequest({kBackendAgentNativeTimerCreateCommandType}, {thirdFacts}), + "agt_create_delivery", 151); + assert(missingSelection.accepted); + assert(!missingSelection.assignment.present); + assert(missingSelection.reasonCode == "local_provider_selection_required"); + + return 0; +} diff --git a/mk/phase64-native-timer-create-delivery-tests.mk b/mk/phase64-native-timer-create-delivery-tests.mk new file mode 100644 index 000000000..9c1a9b00e --- /dev/null +++ b/mk/phase64-native-timer-create-delivery-tests.mk @@ -0,0 +1,25 @@ +.PHONY: test-phase64-native-timer-create-delivery-architecture test-phase64-native-timer-create-delivery + +test-phase64-native-timer-create-delivery-architecture: + python3 tools/check_phase64_native_timer_create_delivery.py + +test-phase64-native-timer-create-delivery: test-phase64-native-timer-create-delivery-architecture + $(BUILD_CXX) $(CXXFLAGS) \ + $(SQLITE_SRC) \ + core/security/src/AccountabilityEventRepository.cpp \ + core/security/src/CredentialVerifierRepository.cpp \ + core/security/src/SecurityIdentityRepository.cpp \ + core/security/src/SecurityIdentityProvisioningRepository.cpp \ + core/vdr/src/VdrConfig.cpp \ + core/vdr/src/BackendRegistry.cpp \ + core/vdr/src/BackendRegistryService.cpp \ + $(AGENT_CONTROL_PLANE_DOMAIN_SRC) \ + core/agent/src/BackendAgentNativeTimerDelete.cpp \ + core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp \ + core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_backend_agent_native_timer_create_delivery + $(BUILD_DIR)/test_backend_agent_native_timer_create_delivery + +test-fast: test-phase64-native-timer-create-delivery +test-architecture: test-phase64-native-timer-create-delivery-architecture diff --git a/tools/check_phase64_native_timer_create_delivery.py b/tools/check_phase64_native_timer_create_delivery.py new file mode 100755 index 000000000..b8f0b5f6f --- /dev/null +++ b/tools/check_phase64_native_timer_create_delivery.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +required = { + "core/agent/src/BackendAgentCommandDelivery.cpp": [ + 'BackendAgentNativeTimerCreatePayload.h', + 'const bool timerCreate=commandType==kBackendAgentNativeTimerCreateCommandType;', + 'native_timer_create_provider_selection_mismatch', + 'result.assignment.commandType==kBackendAgentNativeTimerCreateCommandType', + 'commandType==kBackendAgentNativeTimerCreateCommandType||', + 'backendAgentNativeTimerCreateParsePayload', + ], + "core/agent/src/BackendAgentCommandClient.cpp": [ + 'BackendAgentNativeTimerCreate.h', + 'type == vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType ||', + ], + "core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp": [ + 'Production Agent advertisement remains closed elsewhere', + 'local_provider_selection_stale', + 'persisted provider selection', + 'local_provider_selection_required', + ], +} + +errors = [] +for relative, markers in required.items(): + path = ROOT / relative + if not path.exists(): + errors.append(f"missing {relative}") + continue + text = path.read_text(encoding="utf-8") + for marker in markers: + if marker not in text: + errors.append(f"{relative}: missing marker {marker}") + +client = (ROOT / "core/agent/src/BackendAgentCommandClient.cpp").read_text( + encoding="utf-8" +) +closed = ( + 'type == vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType ||' in client + and 'type == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType)\n continue;' in client +) +if not closed: + errors.append("productive vdr.timer.create Agent advertisement must remain closed") + +makefile = (ROOT / "Makefile").read_text(encoding="utf-8") +if "include mk/phase64-native-timer-create-delivery-tests.mk" not in makefile: + errors.append("Makefile: missing CREATE delivery tests include") + +if errors: + for error in errors: + print(error) + raise SystemExit(1) +print("Phase 64 native Timer CREATE delivery architecture guard passed") From ef01b95fcc2bf4fa3fe48c71dd052ccc3d0bc93d Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:17:11 +0200 Subject: [PATCH 30/85] Run Phase 64 CREATE delivery fence tests --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index ef9aafb00..8b2c3a01d 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ include mk/phase64-backend-agent-command-reservation-tests.mk include mk/phase64-native-timer-create-agent-contract-tests.mk include mk/phase64-native-timer-create-reservation-tests.mk include mk/phase64-native-timer-create-dispatch-state-tests.mk +include mk/phase64-native-timer-create-delivery-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk From 3bfc29254417626e08b54f51181d9186645e6989 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:20:41 +0200 Subject: [PATCH 31/85] Activate only exact dispatching CREATE reservation --- Makefile | 1 + .../BackendAgentNativeTimerCreateActivation.h | 65 ++++ ...ackendAgentNativeTimerCreateActivation.cpp | 270 +++++++++++++++ ...d_agent_native_timer_create_activation.cpp | 320 ++++++++++++++++++ ...64-native-timer-create-activation-tests.mk | 33 ++ ..._phase64_native_timer_create_activation.py | 62 ++++ 6 files changed, 751 insertions(+) create mode 100644 core/agent/include/BackendAgentNativeTimerCreateActivation.h create mode 100644 core/agent/src/BackendAgentNativeTimerCreateActivation.cpp create mode 100644 core/agent/tests/test_backend_agent_native_timer_create_activation.cpp create mode 100644 mk/phase64-native-timer-create-activation-tests.mk create mode 100755 tools/check_phase64_native_timer_create_activation.py diff --git a/Makefile b/Makefile index 8b2c3a01d..f62d8c2a0 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,7 @@ include mk/phase64-native-timer-create-agent-contract-tests.mk include mk/phase64-native-timer-create-reservation-tests.mk include mk/phase64-native-timer-create-dispatch-state-tests.mk include mk/phase64-native-timer-create-delivery-tests.mk +include mk/phase64-native-timer-create-activation-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk diff --git a/core/agent/include/BackendAgentNativeTimerCreateActivation.h b/core/agent/include/BackendAgentNativeTimerCreateActivation.h new file mode 100644 index 000000000..9b26ee908 --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerCreateActivation.h @@ -0,0 +1,65 @@ +#pragma once + +#include "BackendAgentCommand.h" + +#include + +class BackendAgentCommandRepository; +class BackendAgentCommandReservationRepository; + +namespace vdrsuite::operations +{ +class MutationOperationRepository; +} + +namespace vdrsuite::agent +{ + +enum class BackendAgentNativeTimerCreateActivationStatus +{ + activated, + alreadyActivated, + operationNotFound, + operationNotDispatching, + dispatchReferenceInvalid, + reservationNotFound, + identityConflict, + payloadConflict, + providerSelectionStale, + activationConflict, + storageError, + invalid, +}; + +struct BackendAgentNativeTimerCreateActivationResult +{ + BackendAgentNativeTimerCreateActivationStatus status = + BackendAgentNativeTimerCreateActivationStatus::storageError; + BackendAgentCommandAssignment assignment; + std::string reasonCode; + + bool ok() const + { + return status == BackendAgentNativeTimerCreateActivationStatus::activated || + status == BackendAgentNativeTimerCreateActivationStatus::alreadyActivated; + } +}; + +class BackendAgentNativeTimerCreateActivationService +{ +public: + BackendAgentNativeTimerCreateActivationService( + vdrsuite::operations::MutationOperationRepository& operationRepository, + BackendAgentCommandReservationRepository& reservationRepository, + BackendAgentCommandRepository& commandRepository); + + BackendAgentNativeTimerCreateActivationResult activateDispatching( + const std::string& operationId); + +private: + vdrsuite::operations::MutationOperationRepository& operationRepository_; + BackendAgentCommandReservationRepository& reservationRepository_; + BackendAgentCommandRepository& commandRepository_; +}; + +} // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentNativeTimerCreateActivation.cpp b/core/agent/src/BackendAgentNativeTimerCreateActivation.cpp new file mode 100644 index 000000000..007b4b9b8 --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerCreateActivation.cpp @@ -0,0 +1,270 @@ +#include "BackendAgentNativeTimerCreateActivation.h" + +#include "BackendAgentCommandDelivery.h" +#include "BackendAgentCommandReservation.h" +#include "BackendAgentNativeTimerCreate.h" +#include "BackendAgentNativeTimerCreatePayload.h" +#include "MutationOperationRepository.h" +#include "NativeTimerCreateDispatchService.h" +#include "NativeTimerCreateOperationPayload.h" +#include "NativeTimerSpecification.h" + +#include +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace +{ +BackendAgentNativeTimerCreateActivationResult result( + BackendAgentNativeTimerCreateActivationStatus status, + const std::string& reasonCode, + const BackendAgentCommandAssignment& assignment = {}) +{ + BackendAgentNativeTimerCreateActivationResult value; + value.status = status; + value.reasonCode = reasonCode; + value.assignment = assignment; + return value; +} + +bool nextRevision(const std::string& before, const std::string& after) +{ + if (before.empty() || after.empty()) return false; + std::uint64_t value = 0; + for (char character : before) + { + if (character < '0' || character > '9') return false; + const std::uint64_t digit = static_cast(character - '0'); + if (value > (std::numeric_limits::max() - digit) / 10) + return false; + value = value * 10 + digit; + } + if (value == std::numeric_limits::max()) return false; + return after == std::to_string(value + 1); +} + +bool sameActiveAssignment( + BackendAgentCommandRepository& repository, + const BackendAgentCommandReservation& reservation, + const BackendAgentCommandAssignment& active) +{ + if (active.commandId != reservation.assignment.commandId || + active.requestFingerprint != reservation.assignment.requestFingerprint || + active.operationId != reservation.assignment.operationId || + active.jobId != reservation.assignment.jobId || + active.attemptId != reservation.assignment.attemptId || + active.claimEpoch != reservation.assignment.claimEpoch || + active.backendId != reservation.assignment.backendId || + active.agentId != reservation.assignment.agentId || + active.agentInstanceId != reservation.assignment.agentInstanceId || + active.backendGeneration != reservation.assignment.backendGeneration || + active.commandType != reservation.assignment.commandType || + active.payload != reservation.assignment.payload || + !reservation.localProviderSelectionPresent) + return false; + + const auto activeSelection = repository.localProviderSelectionForCommand( + active.commandId); + return activeSelection.has_value() && + backendAgentLocalProviderSameFence( + *activeSelection, reservation.localProviderSelection) && + backendAgentLocalProviderSelectionIdentity(*activeSelection) == + reservation.localProviderSelectionIdentity; +} +} + +BackendAgentNativeTimerCreateActivationService:: +BackendAgentNativeTimerCreateActivationService( + vdrsuite::operations::MutationOperationRepository& operationRepository, + BackendAgentCommandReservationRepository& reservationRepository, + BackendAgentCommandRepository& commandRepository) + : operationRepository_(operationRepository), + reservationRepository_(reservationRepository), + commandRepository_(commandRepository) +{ +} + +BackendAgentNativeTimerCreateActivationResult +BackendAgentNativeTimerCreateActivationService::activateDispatching( + const std::string& operationId) +{ + using namespace vdrsuite::operations; + using namespace vdrsuite::timers; + + if (!backendAgentCommandSafeIdentifier(operationId)) + return result( + BackendAgentNativeTimerCreateActivationStatus::invalid, + "invalid_native_timer_create_activation_request"); + + const auto foundOperation = operationRepository_.findById(operationId); + if (foundOperation.status == MutationOperationRepositoryStatus::notFound) + return result( + BackendAgentNativeTimerCreateActivationStatus::operationNotFound, + "native_timer_create_operation_not_found"); + if (!foundOperation.ok()) + return result( + BackendAgentNativeTimerCreateActivationStatus::storageError, + "native_timer_create_operation_lookup_failed"); + const auto& operation = foundOperation.operation; + if (operation.state != MutationOperationState::dispatching || + operation.actionFamily != "timer.create" || + operation.resourceType != "TimerAssignment" || + operation.verificationPolicy != + MutationOperationVerificationPolicy::readbackRequired) + return result( + BackendAgentNativeTimerCreateActivationStatus::operationNotDispatching, + "native_timer_create_operation_not_dispatching"); + + NativeTimerCreateCommandReservationReference dispatchReference; + if (!parseNativeTimerCreateCommandReservationReference( + operation.resultReference, dispatchReference)) + return result( + BackendAgentNativeTimerCreateActivationStatus::dispatchReferenceInvalid, + "native_timer_create_dispatch_reference_invalid"); + + const auto foundReservation = reservationRepository_.findByCommandId( + dispatchReference.commandId); + if (foundReservation.status == BackendAgentCommandReservationStatus::notFound) + return result( + BackendAgentNativeTimerCreateActivationStatus::reservationNotFound, + "native_timer_create_reservation_not_found"); + if (!foundReservation.ok()) + return result( + BackendAgentNativeTimerCreateActivationStatus::storageError, + "native_timer_create_reservation_lookup_failed"); + const auto& reservation = foundReservation.reservation; + const auto& assignment = reservation.assignment; + + if (dispatchReference.requestFingerprint != assignment.requestFingerprint || + assignment.operationId != operation.operationId || + assignment.backendId != operation.backendId || + assignment.backendGeneration != operation.backendGeneration || + assignment.commandType != kBackendAgentNativeTimerCreateCommandType || + assignment.payloadVersion != kBackendAgentNativeTimerCreatePayloadVersion || + assignment.verificationPolicy != "readback_required" || + !reservation.localProviderSelectionPresent || + reservation.localProviderSelectionIdentity != + backendAgentLocalProviderSelectionIdentity( + reservation.localProviderSelection)) + return result( + BackendAgentNativeTimerCreateActivationStatus::identityConflict, + "native_timer_create_activation_identity_conflict"); + + BackendAgentNativeTimerCreatePayload agentPayload; + std::string payloadReason; + if (!backendAgentNativeTimerCreateParsePayload( + assignment.payload, agentPayload, payloadReason) || + !backendAgentLocalProviderSameFence( + agentPayload.localProviderSelection, + reservation.localProviderSelection) || + !nextRevision(agentPayload.operationRevision, operation.operationRevision)) + return result( + BackendAgentNativeTimerCreateActivationStatus::payloadConflict, + "native_timer_create_activation_payload_conflict"); + + const auto foundPayload = operationRepository_.findPayloadByOperationId(operationId); + if (!foundPayload.ok() || + foundPayload.payload.payloadType != "native.timer.create" || + foundPayload.payload.payloadVersion != 1) + return result( + foundPayload.status == MutationOperationRepositoryStatus::notFound + ? BackendAgentNativeTimerCreateActivationStatus::payloadConflict + : BackendAgentNativeTimerCreateActivationStatus::storageError, + foundPayload.status == MutationOperationRepositoryStatus::notFound + ? "native_timer_create_operation_payload_missing" + : "native_timer_create_operation_payload_lookup_failed"); + + NativeTimerCreateOperationPayload operationPayload; + if (!parseNativeTimerCreateOperationPayload( + foundPayload.payload.payload, operationPayload) || + nativeTimerCreateOperationPayloadFingerprint(operationPayload) != + foundPayload.payload.payloadFingerprint) + return result( + BackendAgentNativeTimerCreateActivationStatus::payloadConflict, + "native_timer_create_operation_payload_invalid"); + + const std::string specificationFingerprint = + nativeTimerSpecificationFingerprint(operationPayload.expectedSpecification); + if (operation.resourceId != operationPayload.timerAssignmentId || + operation.expectedResourceFingerprint != specificationFingerprint || + operationPayload.timerAssignmentId != agentPayload.timerAssignmentId || + operationPayload.expectedAssignmentRevision != + agentPayload.expectedAssignmentRevision || + operationPayload.expectedIntentRevision != agentPayload.expectedIntentRevision || + operationPayload.assignmentEpoch != agentPayload.assignmentEpoch || + operationPayload.nativeTimerBindingId != agentPayload.nativeTimerBindingId || + operationPayload.backendId != assignment.backendId || + operationPayload.backendGeneration != assignment.backendGeneration || + specificationFingerprint.empty() || + specificationFingerprint != agentPayload.expectedSpecificationFingerprint || + specificationFingerprint != + backendAgentNativeTimerCreateSpecificationFingerprint( + agentPayload.specification)) + return result( + BackendAgentNativeTimerCreateActivationStatus::payloadConflict, + "native_timer_create_activation_payload_conflict"); + + const auto active = commandRepository_.findAssignmentForOperation( + assignment.backendId, operationId, kBackendAgentNativeTimerCreateCommandType); + if (active.has_value()) + { + if (!sameActiveAssignment(commandRepository_, reservation, *active)) + return result( + BackendAgentNativeTimerCreateActivationStatus::activationConflict, + "native_timer_create_active_assignment_conflict", + *active); + return result( + BackendAgentNativeTimerCreateActivationStatus::alreadyActivated, + "native_timer_create_reservation_already_activated", + *active); + } + + std::string providerReason; + const auto currentSelection = commandRepository_.selectLocalProvider( + assignment.backendId, + assignment.agentId, + assignment.agentInstanceId, + assignment.backendGeneration, + kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateCapability, + providerReason); + if (!currentSelection.has_value() || + !backendAgentLocalProviderSameFence( + *currentSelection, reservation.localProviderSelection)) + return result( + BackendAgentNativeTimerCreateActivationStatus::providerSelectionStale, + providerReason.empty() + ? "native_timer_create_provider_selection_stale" + : providerReason); + + BackendAgentCommandReservationActivationService activation( + reservationRepository_, commandRepository_); + const auto activated = activation.activate(dispatchReference.commandId); + if (activated.status == BackendAgentCommandReservationStatus::activated) + return result( + BackendAgentNativeTimerCreateActivationStatus::activated, + "native_timer_create_reservation_activated", + activated.assignment); + if (activated.status == BackendAgentCommandReservationStatus::alreadyActivated) + return result( + BackendAgentNativeTimerCreateActivationStatus::alreadyActivated, + "native_timer_create_reservation_already_activated", + activated.assignment); + if (activated.status == BackendAgentCommandReservationStatus::conflict) + return result( + BackendAgentNativeTimerCreateActivationStatus::activationConflict, + "native_timer_create_activation_conflict", + activated.assignment); + return result( + activated.status == BackendAgentCommandReservationStatus::notFound + ? BackendAgentNativeTimerCreateActivationStatus::reservationNotFound + : BackendAgentNativeTimerCreateActivationStatus::storageError, + activated.status == BackendAgentCommandReservationStatus::notFound + ? "native_timer_create_reservation_not_found" + : "native_timer_create_activation_failed"); +} + +} // namespace vdrsuite::agent diff --git a/core/agent/tests/test_backend_agent_native_timer_create_activation.cpp b/core/agent/tests/test_backend_agent_native_timer_create_activation.cpp new file mode 100644 index 000000000..ca43cfc29 --- /dev/null +++ b/core/agent/tests/test_backend_agent_native_timer_create_activation.cpp @@ -0,0 +1,320 @@ +#include "BackendAgentCommandDelivery.h" +#include "BackendAgentCommandReservation.h" +#include "BackendAgentNativeTimerCreate.h" +#include "BackendAgentNativeTimerCreateActivation.h" +#include "BackendAgentNativeTimerCreatePayload.h" +#include "Database.h" +#include "MutationOperationRepository.h" +#include "NativeTimerCreateDispatchService.h" +#include "NativeTimerCreateOperationPayload.h" +#include "NativeTimerSpecification.h" + +#include +#include +#include +#include + +namespace +{ +using namespace vdrsuite::agent; +using namespace vdrsuite::operations; +using namespace vdrsuite::timers; + +NativeTimerSpecification timerSpecification(const std::string& suffix) +{ + NativeTimerSpecification value; + value.channelId = "C-1-2-3"; + value.title = "Phase 64 activation " + suffix; + value.directory = "Tests"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1030"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +BackendAgentNativeTimerCreateSpecification agentSpecification( + const std::string& suffix) +{ + const auto source = timerSpecification(suffix); + BackendAgentNativeTimerCreateSpecification value; + value.channelId = source.channelId; + value.title = source.title; + value.directory = source.directory; + value.day = source.day; + value.weekdays = source.weekdays; + value.startTime = source.startTime; + value.endTime = source.endTime; + value.priority = source.priority; + value.lifetime = source.lifetime; + value.enabled = source.enabled; + value.vps = source.vps; + return value; +} + +MutationOperation operation(const std::string& suffix) +{ + MutationOperation value; + value.operationId = "op_create_activation_" + suffix; + value.idempotencyKey = "idem_create_activation_" + suffix; + value.actorId = "system_phase64"; + value.backendId = "default"; + value.backendGeneration = 7; + value.resourceType = "TimerAssignment"; + value.resourceId = "ta_create_activation_" + suffix; + value.expectedRevision = "4"; + value.expectedResourceFingerprint = + nativeTimerSpecificationFingerprint(timerSpecification(suffix)); + value.actionFamily = "timer.create"; + value.requestFingerprint = "request_create_activation_" + suffix; + value.requestedAt = 100; + value.deadline = 500; + value.verificationPolicy = MutationOperationVerificationPolicy::readbackRequired; + value.state = MutationOperationState::accepted; + value.updatedAt = 100; + return value; +} + +NativeTimerCreateOperationPayload operationPayload(const std::string& suffix) +{ + NativeTimerCreateOperationPayload value; + value.timerAssignmentId = "ta_create_activation_" + suffix; + value.expectedAssignmentRevision = "4"; + value.expectedIntentRevision = "9"; + value.assignmentEpoch = 3; + value.nativeTimerBindingId = "ntb_create_activation_" + suffix; + value.backendId = "default"; + value.backendGeneration = 7; + value.expectedSpecification = timerSpecification(suffix); + return value; +} + +BackendAgentLocalProviderFacts providerFacts( + const std::string& epoch, + std::uint64_t generation, + std::uint64_t capabilityRevision) +{ + BackendAgentLocalProviderFacts facts; + facts.providerId = kBackendAgentNativeTimerCreateProviderId; + facts.providerKind = kBackendAgentNativeTimerCreateProviderKind; + facts.providerInstanceEpoch = epoch; + facts.providerGeneration = generation; + facts.capabilityRevision = capabilityRevision; + facts.available = true; + facts.capabilities = {kBackendAgentNativeTimerCreateCapability}; + return facts; +} + +void observe( + BackendAgentCommandRepository& commands, + const BackendAgentLocalProviderFacts& facts, + std::int64_t now) +{ + BackendAgentCommandPollRequest poll; + poll.backendId = "default"; + poll.agentInstanceId = "inst_create_activation"; + poll.backendGeneration = 7; + poll.supportedCommandTypes = {"probe.noop"}; + poll.localProviders = {facts}; + const auto observed = commands.poll(poll, "agt_create_activation", now); + assert(observed.accepted); + assert(!observed.assignment.present); +} + +BackendAgentCommandAssignment assignment( + const std::string& suffix, + const BackendAgentLocalProviderSelection& selection) +{ + BackendAgentNativeTimerCreatePayload payload; + payload.operationRevision = "1"; + payload.timerAssignmentId = "ta_create_activation_" + suffix; + payload.expectedAssignmentRevision = "4"; + payload.expectedIntentRevision = "9"; + payload.assignmentEpoch = 3; + payload.nativeTimerBindingId = "ntb_create_activation_" + suffix; + payload.controlPlaneClaimedAt = 109; + payload.specification = agentSpecification(suffix); + payload.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint(payload.specification); + payload.localProviderSelection = selection; + + BackendAgentCommandAssignment value; + value.present = true; + value.requestId = "req_create_activation_" + suffix; + value.correlationId = value.requestId; + value.operationId = "op_create_activation_" + suffix; + value.jobId = "job_create_activation_" + suffix; + value.attemptId = "attempt_create_activation_" + suffix; + value.claimEpoch = 1; + value.commandId = "cmd_create_activation_" + suffix; + value.backendId = "default"; + value.agentId = "agt_create_activation"; + value.agentInstanceId = "inst_create_activation"; + value.backendGeneration = 7; + value.commandType = kBackendAgentNativeTimerCreateCommandType; + value.payloadVersion = kBackendAgentNativeTimerCreatePayloadVersion; + value.payload = backendAgentNativeTimerCreatePayload(payload); + value.verificationPolicy = "readback_required"; + value.assignedAt = 110; + value.deadline = 500; + value.requestFingerprint = backendAgentCommandFingerprint(value); + assert(backendAgentCommandValidAssignment(value)); + return value; +} + +void persistOperation( + MutationOperationRepository& operations, + const std::string& suffix) +{ + const auto payload = operationPayload(suffix); + MutationOperationPayload durable; + durable.operationId = "op_create_activation_" + suffix; + durable.payloadType = "native.timer.create"; + durable.payloadVersion = 1; + durable.payload = serializeNativeTimerCreateOperationPayload(payload); + durable.payloadFingerprint = nativeTimerCreateOperationPayloadFingerprint(payload); + const auto reserved = operations.reserveWithPayload(operation(suffix), durable); + assert(reserved.ok()); + assert(reserved.operation.operationRevision == "1"); +} + +void claimDispatching( + MutationOperationRepository& operations, + const std::string& suffix, + const BackendAgentCommandAssignment& command) +{ + NativeTimerCreateDispatchClaimRequest request; + request.operationId = "op_create_activation_" + suffix; + request.expectedOperationRevision = "1"; + request.timerAssignmentId = "ta_create_activation_" + suffix; + request.nativeTimerBindingId = "ntb_create_activation_" + suffix; + request.backendId = "default"; + request.backendGeneration = 7; + request.expectedSpecificationFingerprint = + nativeTimerSpecificationFingerprint(timerSpecification(suffix)); + request.reservation.commandId = command.commandId; + request.reservation.requestFingerprint = command.requestFingerprint; + NativeTimerCreateDispatchService dispatch(operations); + const auto claimed = dispatch.claimAfterReservation(request, 120); + assert(claimed.status == NativeTimerCreateDispatchClaimStatus::claimed); + assert(claimed.operation.state == MutationOperationState::dispatching); + assert(claimed.operation.operationRevision == "2"); +} +} + +int main() +{ + Database database; + assert(database.open(":memory:")); + MutationOperationRepository operations(database); + BackendAgentCommandRepository commands(database); + BackendAgentCommandReservationRepository reservations(database); + assert(operations.ensureSchema()); + assert(commands.ensureSchema()); + assert(reservations.ensureSchema()); + + BackendAgentLocalProviderOwnership ownership; + std::string reason; + assert(commands.setLocalProviderOwnership( + "default", kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateProviderId, + kBackendAgentNativeTimerCreateProviderKind, + {kBackendAgentNativeTimerCreateCapability}, + 100, ownership, reason)); + + const auto firstFacts = providerFacts("pie_create_activation_1", 3, 4); + observe(commands, firstFacts, 101); + const auto firstSelection = commands.selectLocalProvider( + "default", "agt_create_activation", "inst_create_activation", 7, + kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateCapability, reason); + assert(firstSelection.has_value()); + + persistOperation(operations, "1"); + const auto firstCommand = assignment("1", *firstSelection); + const auto firstReservation = reservations.reserve(firstCommand, &*firstSelection); + assert(firstReservation.ok()); + + BackendAgentNativeTimerCreateActivationService activation( + operations, reservations, commands); + + // A durable reservation alone is deliberately non-pollable and cannot be + // activated before the MutationOperation has durably entered dispatching. + const auto tooEarly = activation.activateDispatching(firstCommand.operationId); + assert(tooEarly.status == + BackendAgentNativeTimerCreateActivationStatus::operationNotDispatching); + assert(!commands.findAssignmentForOperation( + "default", firstCommand.operationId, + kBackendAgentNativeTimerCreateCommandType).has_value()); + + claimDispatching(operations, "1", firstCommand); + const auto activated = activation.activateDispatching(firstCommand.operationId); + assert(activated.status == BackendAgentNativeTimerCreateActivationStatus::activated); + assert(activated.assignment.commandId == firstCommand.commandId); + assert(activated.assignment.requestFingerprint == firstCommand.requestFingerprint); + + const auto active = commands.findAssignmentForOperation( + "default", firstCommand.operationId, + kBackendAgentNativeTimerCreateCommandType); + assert(active.has_value()); + assert(active->commandId == firstCommand.commandId); + + // Crash/restart after activation recovers the exact active reservation; + // no new command/job/attempt identity is generated. + const auto replay = activation.activateDispatching(firstCommand.operationId); + assert(replay.status == + BackendAgentNativeTimerCreateActivationStatus::alreadyActivated); + assert(replay.assignment.commandId == firstCommand.commandId); + assert(replay.assignment.jobId == firstCommand.jobId); + assert(replay.assignment.attemptId == firstCommand.attemptId); + + // A provider replacement after dispatching but before activation is a + // fail-closed boundary. The operation remains dispatching and the original + // reservation remains the only legal command identity. + persistOperation(operations, "2"); + const auto secondCommand = assignment("2", *firstSelection); + assert(reservations.reserve(secondCommand, &*firstSelection).ok()); + claimDispatching(operations, "2", secondCommand); + observe(commands, providerFacts("pie_create_activation_2", 4, 5), 130); + + const auto stale = activation.activateDispatching(secondCommand.operationId); + assert(stale.status == + BackendAgentNativeTimerCreateActivationStatus::providerSelectionStale); + assert(!commands.findAssignmentForOperation( + "default", secondCommand.operationId, + kBackendAgentNativeTimerCreateCommandType).has_value()); + const auto stillReserved = reservations.findByCommandId(secondCommand.commandId); + assert(stillReserved.ok()); + assert(stillReserved.reservation.assignment.requestFingerprint == + secondCommand.requestFingerprint); + + // Tampering with the durable dispatch reference never redirects activation + // to another reservation. + persistOperation(operations, "3"); + const auto currentSelection = commands.selectLocalProvider( + "default", "agt_create_activation", "inst_create_activation", 7, + kBackendAgentNativeTimerCreateAuthorityDomain, + kBackendAgentNativeTimerCreateCapability, reason); + assert(currentSelection.has_value()); + const auto thirdCommand = assignment("3", *currentSelection); + assert(reservations.reserve(thirdCommand, &*currentSelection).ok()); + claimDispatching(operations, "3", thirdCommand); + assert(database.execute( + "UPDATE mutation_operations SET result_reference=" + "'native-timer-create-command-reservation/1|25:cmd_create_activation_other|20:fp1_0123456789abcdef|' " + "WHERE operation_id='op_create_activation_3';")); + const auto tampered = activation.activateDispatching(thirdCommand.operationId); + assert(tampered.status == + BackendAgentNativeTimerCreateActivationStatus::dispatchReferenceInvalid || + tampered.status == + BackendAgentNativeTimerCreateActivationStatus::reservationNotFound); + assert(!commands.findAssignmentForOperation( + "default", thirdCommand.operationId, + kBackendAgentNativeTimerCreateCommandType).has_value()); + + return 0; +} diff --git a/mk/phase64-native-timer-create-activation-tests.mk b/mk/phase64-native-timer-create-activation-tests.mk new file mode 100644 index 000000000..ad9ca0471 --- /dev/null +++ b/mk/phase64-native-timer-create-activation-tests.mk @@ -0,0 +1,33 @@ +.PHONY: test-phase64-native-timer-create-activation-architecture test-phase64-native-timer-create-activation + +test-phase64-native-timer-create-activation-architecture: + python3 tools/check_phase64_native_timer_create_activation.py + +test-phase64-native-timer-create-activation: test-phase64-native-timer-create-activation-architecture + $(BUILD_CXX) $(CXXFLAGS) \ + $(SQLITE_SRC) \ + core/security/src/AccountabilityEventRepository.cpp \ + core/security/src/CredentialVerifierRepository.cpp \ + core/security/src/SecurityIdentityRepository.cpp \ + core/security/src/SecurityIdentityProvisioningRepository.cpp \ + core/vdr/src/VdrConfig.cpp \ + core/vdr/src/BackendRegistry.cpp \ + core/vdr/src/BackendRegistryService.cpp \ + core/operations/src/MutationOperation.cpp \ + core/operations/src/MutationOperationRepository.cpp \ + core/timers/src/NativeTimerObservedState.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/src/NativeTimerCreateOperationPayload.cpp \ + core/timers/src/NativeTimerCreateDispatchService.cpp \ + $(AGENT_CONTROL_PLANE_DOMAIN_SRC) \ + core/agent/src/BackendAgentNativeTimerDelete.cpp \ + core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp \ + core/agent/src/BackendAgentCommandReservation.cpp \ + core/agent/src/BackendAgentNativeTimerCreateActivation.cpp \ + core/agent/tests/test_backend_agent_native_timer_create_activation.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_backend_agent_native_timer_create_activation + $(BUILD_DIR)/test_backend_agent_native_timer_create_activation + +test-fast: test-phase64-native-timer-create-activation +test-architecture: test-phase64-native-timer-create-activation-architecture diff --git a/tools/check_phase64_native_timer_create_activation.py b/tools/check_phase64_native_timer_create_activation.py new file mode 100755 index 000000000..8bb5f3b8c --- /dev/null +++ b/tools/check_phase64_native_timer_create_activation.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +required = { + "core/agent/include/BackendAgentNativeTimerCreateActivation.h": [ + "BackendAgentNativeTimerCreateActivationService", + "activateDispatching", + "providerSelectionStale", + ], + "core/agent/src/BackendAgentNativeTimerCreateActivation.cpp": [ + "operationRepository_.findById(operationId)", + "MutationOperationState::dispatching", + "parseNativeTimerCreateCommandReservationReference", + "reservationRepository_.findByCommandId", + "nextRevision(agentPayload.operationRevision, operation.operationRevision)", + "findPayloadByOperationId(operationId)", + "findAssignmentForOperation", + "BackendAgentCommandReservationActivationService activation", + "activation.activate(dispatchReference.commandId)", + ], + "core/agent/tests/test_backend_agent_native_timer_create_activation.cpp": [ + "cannot be activated before", + "no new command/job/attempt identity is generated", + "provider replacement after dispatching", + "only legal command identity", + ], +} + +errors = [] +for relative, markers in required.items(): + path = ROOT / relative + if not path.exists(): + errors.append(f"missing {relative}") + continue + text = path.read_text(encoding="utf-8") + for marker in markers: + if marker not in text: + errors.append(f"{relative}: missing marker {marker}") + +source = (ROOT / "core/agent/src/BackendAgentNativeTimerCreateActivation.cpp").read_text( + encoding="utf-8" +) +for forbidden in ( + "backendAgentGenerateOpaqueId", + "reservationRepository_.reserve", + "reserveWithPayload", + "MutationOperationState::accepted,", +): + if forbidden in source: + errors.append(f"CREATE activation must not create a new dispatch identity/state: {forbidden}") + +makefile = (ROOT / "Makefile").read_text(encoding="utf-8") +if "include mk/phase64-native-timer-create-activation-tests.mk" not in makefile: + errors.append("Makefile: missing CREATE activation tests include") + +if errors: + for error in errors: + print(error) + raise SystemExit(1) +print("Phase 64 native Timer CREATE activation architecture guard passed") From 3df36ca255ee432dd4ce0bd7754b681764d93221 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:23:20 +0200 Subject: [PATCH 32/85] Fix Phase 64 CREATE activation guard marker --- tools/check_phase64_native_timer_create_activation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/check_phase64_native_timer_create_activation.py b/tools/check_phase64_native_timer_create_activation.py index 8bb5f3b8c..362db7a05 100755 --- a/tools/check_phase64_native_timer_create_activation.py +++ b/tools/check_phase64_native_timer_create_activation.py @@ -21,7 +21,8 @@ "activation.activate(dispatchReference.commandId)", ], "core/agent/tests/test_backend_agent_native_timer_create_activation.cpp": [ - "cannot be activated before", + "durable reservation alone is deliberately non-pollable", + "activated before the MutationOperation", "no new command/job/attempt identity is generated", "provider replacement after dispatching", "only legal command identity", From e44814200e3bc456295823966beaabc224cd46ff Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:26:37 +0200 Subject: [PATCH 33/85] Persist Phase 64 CREATE local starting state --- .../BackendAgentNativeTimerCreateLocalState.h | 70 +++ ...ackendAgentNativeTimerCreateLocalState.cpp | 512 ++++++++++++++++++ ..._agent_native_timer_create_local_state.cpp | 226 ++++++++ ...4-native-timer-create-local-state-tests.mk | 20 + ...phase64_native_timer_create_local_state.py | 61 +++ 5 files changed, 889 insertions(+) create mode 100644 core/agent/include/BackendAgentNativeTimerCreateLocalState.h create mode 100644 core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp create mode 100644 core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp create mode 100644 mk/phase64-native-timer-create-local-state-tests.mk create mode 100755 tools/check_phase64_native_timer_create_local_state.py diff --git a/core/agent/include/BackendAgentNativeTimerCreateLocalState.h b/core/agent/include/BackendAgentNativeTimerCreateLocalState.h new file mode 100644 index 000000000..66be93f76 --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerCreateLocalState.h @@ -0,0 +1,70 @@ +#pragma once + +#include "BackendAgentCommand.h" +#include "BackendAgentNativeTimerCreate.h" + +#include +#include + +namespace vdrsuite::agent +{ + +enum class BackendAgentNativeTimerCreateLocalPhase +{ + starting, + completed, +}; + +enum class BackendAgentNativeTimerCreateRecoveryDecision +{ + failClosed, + reconcileOnly, + returnPersistedEvidence, +}; + +struct BackendAgentNativeTimerCreateLocalState +{ + std::uint64_t schemaVersion = 1; + BackendAgentNativeTimerCreateLocalPhase phase = + BackendAgentNativeTimerCreateLocalPhase::starting; + BackendAgentNativeTimerCreateCommand command; + std::int64_t localStartingPersistedAt = 0; + BackendAgentNativeTimerCreateEvidence evidence; +}; + +bool backendAgentNativeTimerCreateCommandFromAssignment( + const BackendAgentCommandAssignment& assignment, + BackendAgentNativeTimerCreateCommand& command, + std::string& reasonCode); + +bool backendAgentNativeTimerCreatePrepareLocalStarting( + const BackendAgentNativeTimerCreateCommand& command, + std::int64_t persistedAt, + BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode); + +bool backendAgentNativeTimerCreateLocalStateValid( + const BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode); + +bool backendAgentNativeTimerCreateCompleteLocalState( + BackendAgentNativeTimerCreateLocalState& state, + const BackendAgentNativeTimerCreateEvidence& evidence, + std::string& reasonCode); + +BackendAgentNativeTimerCreateRecoveryDecision +backendAgentNativeTimerCreateRecoverLocalState( + const BackendAgentNativeTimerCreateLocalState& state, + BackendAgentNativeTimerCreateEvidence& evidence, + std::string& reasonCode); + +std::string backendAgentNativeTimerCreateSerializeLocalState( + const BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode); + +bool backendAgentNativeTimerCreateParseLocalState( + const std::string& encoded, + BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode); + +} // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp b/core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp new file mode 100644 index 000000000..d3a93418d --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp @@ -0,0 +1,512 @@ +#include "BackendAgentNativeTimerCreateLocalState.h" + +#include "BackendAgentCommand.h" +#include "BackendAgentNativeTimerCreatePayload.h" + +#include +#include +#include +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace +{ +constexpr const char* kPrefix = "native-timer-create-local-state/1|"; +constexpr std::size_t kFieldCountStarting = 13; +constexpr std::size_t kFieldCountCompleted = 17; +constexpr std::size_t kMaximumEncodedBytes = 32U * 1024U; + +void appendField(std::string& output, const std::string& value) +{ + output += std::to_string(value.size()); + output += ':'; + output += value; + output += '|'; +} + +void appendUnsigned(std::string& output, std::uint64_t value) +{ + appendField(output, std::to_string(value)); +} + +bool readField(const std::string& input, std::size_t& position, std::string& value) +{ + if (position >= input.size()) return false; + std::size_t length = 0; + bool digitSeen = false; + while (position < input.size() && input[position] != ':') + { + const char character = input[position++]; + if (character < '0' || character > '9') return false; + digitSeen = true; + const std::size_t digit = static_cast(character - '0'); + if (length > (std::numeric_limits::max() - digit) / 10) + return false; + length = length * 10 + digit; + } + if (!digitSeen || position >= input.size() || input[position] != ':') return false; + ++position; + if (length > input.size() - position) return false; + value = input.substr(position, length); + position += length; + if (position >= input.size() || input[position] != '|') return false; + ++position; + return true; +} + +bool parseUnsigned(const std::string& token, std::uint64_t& value, bool positive = true) +{ + if (token.empty()) return false; + value = 0; + for (char character : token) + { + if (character < '0' || character > '9') return false; + const std::uint64_t digit = static_cast(character - '0'); + if (value > (std::numeric_limits::max() - digit) / 10) + return false; + value = value * 10 + digit; + } + return !positive || value > 0; +} + +bool parseTime(const std::string& token, std::int64_t& value, bool positive = true) +{ + std::uint64_t parsed = 0; + if (!parseUnsigned(token, parsed, positive) || + parsed > static_cast(std::numeric_limits::max())) + return false; + value = static_cast(parsed); + return true; +} + +std::string phaseName(BackendAgentNativeTimerCreateLocalPhase phase) +{ + return phase == BackendAgentNativeTimerCreateLocalPhase::starting + ? "starting" : "completed"; +} + +bool parsePhase( + const std::string& token, + BackendAgentNativeTimerCreateLocalPhase& phase) +{ + if (token == "starting") + { + phase = BackendAgentNativeTimerCreateLocalPhase::starting; + return true; + } + if (token == "completed") + { + phase = BackendAgentNativeTimerCreateLocalPhase::completed; + return true; + } + return false; +} + +std::string outcomeName(BackendAgentNativeTimerCreateOutcomeCategory outcome) +{ + switch (outcome) + { + case BackendAgentNativeTimerCreateOutcomeCategory::rejectedWithoutEffect: + return "rejected_without_effect"; + case BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified: + return "accepted_unverified"; + case BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown: + return "outcome_unknown"; + } + return {}; +} + +bool parseOutcome( + const std::string& token, + BackendAgentNativeTimerCreateOutcomeCategory& outcome) +{ + if (token == "rejected_without_effect") + { + outcome = BackendAgentNativeTimerCreateOutcomeCategory::rejectedWithoutEffect; + return true; + } + if (token == "accepted_unverified") + { + outcome = BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified; + return true; + } + if (token == "outcome_unknown") + { + outcome = BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown; + return true; + } + return false; +} + +BackendAgentNativeTimerCreatePayload payloadFromCommand( + const BackendAgentNativeTimerCreateCommand& command) +{ + BackendAgentNativeTimerCreatePayload payload; + payload.operationRevision = command.operationRevision; + payload.timerAssignmentId = command.timerAssignmentId; + payload.expectedAssignmentRevision = command.expectedAssignmentRevision; + payload.expectedIntentRevision = command.expectedIntentRevision; + payload.assignmentEpoch = command.assignmentEpoch; + payload.nativeTimerBindingId = command.nativeTimerBindingId; + payload.controlPlaneClaimedAt = command.controlPlaneClaimedAt; + payload.expectedSpecificationFingerprint = command.expectedSpecificationFingerprint; + payload.specification = command.specification; + payload.localProviderSelection = command.localProviderSelection; + return payload; +} + +BackendAgentNativeTimerCreateCommand commandFromEnvelope( + const std::vector& fields, + const BackendAgentNativeTimerCreatePayload& payload, + bool& ok) +{ + BackendAgentNativeTimerCreateCommand command; + ok = fields.size() >= kFieldCountStarting; + if (!ok) return command; + command.commandId = fields[2]; + command.requestFingerprint = fields[3]; + command.operationId = fields[4]; + command.jobId = fields[5]; + command.attemptId = fields[6]; + ok = parseUnsigned(fields[7], command.claimEpoch); + command.backendId = fields[8]; + command.agentId = fields[9]; + command.agentInstanceId = fields[10]; + ok = ok && parseUnsigned(fields[11], command.backendGeneration); + command.operationRevision = payload.operationRevision; + command.timerAssignmentId = payload.timerAssignmentId; + command.expectedAssignmentRevision = payload.expectedAssignmentRevision; + command.expectedIntentRevision = payload.expectedIntentRevision; + command.assignmentEpoch = payload.assignmentEpoch; + command.nativeTimerBindingId = payload.nativeTimerBindingId; + command.expectedSpecificationFingerprint = payload.expectedSpecificationFingerprint; + command.controlPlaneClaimedAt = payload.controlPlaneClaimedAt; + command.specification = payload.specification; + command.localProviderSelection = payload.localProviderSelection; + return command; +} + +BackendAgentNativeTimerCreateEvidence evidenceFor( + const BackendAgentNativeTimerCreateCommand& command, + std::int64_t localStartingPersistedAt, + BackendAgentNativeTimerCreateOutcomeCategory outcome, + std::int64_t dispatchStartedAt, + std::int64_t completedAt, + const std::string& reference) +{ + BackendAgentNativeTimerCreateEvidence evidence; + evidence.commandId = command.commandId; + evidence.requestFingerprint = command.requestFingerprint; + evidence.operationId = command.operationId; + evidence.operationRevision = command.operationRevision; + evidence.timerAssignmentId = command.timerAssignmentId; + evidence.nativeTimerBindingId = command.nativeTimerBindingId; + evidence.jobId = command.jobId; + evidence.attemptId = command.attemptId; + evidence.claimEpoch = command.claimEpoch; + evidence.backendId = command.backendId; + evidence.agentId = command.agentId; + evidence.agentInstanceId = command.agentInstanceId; + evidence.backendGeneration = command.backendGeneration; + evidence.providerInstanceEpoch = command.localProviderSelection.providerInstanceEpoch; + evidence.localStartingPersistedAt = localStartingPersistedAt; + evidence.outcome = outcome; + evidence.dispatchStartedAt = dispatchStartedAt; + evidence.completedAt = completedAt; + evidence.evidenceReference = reference; + return evidence; +} +} + +bool backendAgentNativeTimerCreateCommandFromAssignment( + const BackendAgentCommandAssignment& assignment, + BackendAgentNativeTimerCreateCommand& command, + std::string& reasonCode) +{ + if (!backendAgentCommandValidAssignment(assignment) || + assignment.commandType != kBackendAgentNativeTimerCreateCommandType || + assignment.payloadVersion != kBackendAgentNativeTimerCreatePayloadVersion || + assignment.verificationPolicy != "readback_required") + { + reasonCode = "invalid_native_timer_create_assignment"; + return false; + } + + BackendAgentNativeTimerCreatePayload payload; + if (!backendAgentNativeTimerCreateParsePayload( + assignment.payload, payload, reasonCode)) + { + reasonCode = "invalid_native_timer_create_assignment_payload"; + return false; + } + + BackendAgentNativeTimerCreateCommand candidate; + candidate.commandId = assignment.commandId; + candidate.requestFingerprint = assignment.requestFingerprint; + candidate.operationId = assignment.operationId; + candidate.operationRevision = payload.operationRevision; + candidate.timerAssignmentId = payload.timerAssignmentId; + candidate.expectedAssignmentRevision = payload.expectedAssignmentRevision; + candidate.expectedIntentRevision = payload.expectedIntentRevision; + candidate.assignmentEpoch = payload.assignmentEpoch; + candidate.nativeTimerBindingId = payload.nativeTimerBindingId; + candidate.expectedSpecificationFingerprint = payload.expectedSpecificationFingerprint; + candidate.jobId = assignment.jobId; + candidate.attemptId = assignment.attemptId; + candidate.claimEpoch = assignment.claimEpoch; + candidate.backendId = assignment.backendId; + candidate.agentId = assignment.agentId; + candidate.agentInstanceId = assignment.agentInstanceId; + candidate.backendGeneration = assignment.backendGeneration; + candidate.controlPlaneClaimedAt = payload.controlPlaneClaimedAt; + candidate.specification = payload.specification; + candidate.localProviderSelection = payload.localProviderSelection; + if (!backendAgentNativeTimerCreateValidCommand(candidate, reasonCode)) + { + reasonCode = "invalid_native_timer_create_assignment_contract"; + return false; + } + command = std::move(candidate); + reasonCode.clear(); + return true; +} + +bool backendAgentNativeTimerCreatePrepareLocalStarting( + const BackendAgentNativeTimerCreateCommand& command, + std::int64_t persistedAt, + BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode) +{ + std::string commandReason; + if (!backendAgentNativeTimerCreateValidCommand(command, commandReason) || + persistedAt <= 0 || persistedAt < command.controlPlaneClaimedAt) + { + reasonCode = "invalid_native_timer_create_local_starting"; + return false; + } + BackendAgentNativeTimerCreateLocalState candidate; + candidate.phase = BackendAgentNativeTimerCreateLocalPhase::starting; + candidate.command = command; + candidate.localStartingPersistedAt = persistedAt; + if (!backendAgentNativeTimerCreateLocalStateValid(candidate, reasonCode)) + return false; + state = std::move(candidate); + reasonCode.clear(); + return true; +} + +bool backendAgentNativeTimerCreateLocalStateValid( + const BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode) +{ + std::string commandReason; + if (state.schemaVersion != 1 || + !backendAgentNativeTimerCreateValidCommand(state.command, commandReason) || + state.localStartingPersistedAt <= 0 || + state.localStartingPersistedAt < state.command.controlPlaneClaimedAt) + { + reasonCode = "invalid_native_timer_create_local_state"; + return false; + } + if (state.phase == BackendAgentNativeTimerCreateLocalPhase::starting) + { + if (state.evidence.completedAt != 0 || + state.evidence.dispatchStartedAt != 0 || + !state.evidence.evidenceReference.empty()) + { + reasonCode = "invalid_native_timer_create_starting_evidence"; + return false; + } + reasonCode.clear(); + return true; + } + if (!backendAgentNativeTimerCreateEvidenceMatches( + state.evidence, state.command, reasonCode) || + state.evidence.localStartingPersistedAt != state.localStartingPersistedAt) + { + reasonCode = "invalid_native_timer_create_completed_evidence"; + return false; + } + reasonCode.clear(); + return true; +} + +bool backendAgentNativeTimerCreateCompleteLocalState( + BackendAgentNativeTimerCreateLocalState& state, + const BackendAgentNativeTimerCreateEvidence& evidence, + std::string& reasonCode) +{ + if (state.phase != BackendAgentNativeTimerCreateLocalPhase::starting || + !backendAgentNativeTimerCreateLocalStateValid(state, reasonCode) || + !backendAgentNativeTimerCreateEvidenceMatches( + evidence, state.command, reasonCode) || + evidence.localStartingPersistedAt != state.localStartingPersistedAt) + { + reasonCode = "invalid_native_timer_create_completion"; + return false; + } + state.phase = BackendAgentNativeTimerCreateLocalPhase::completed; + state.evidence = evidence; + if (!backendAgentNativeTimerCreateLocalStateValid(state, reasonCode)) + return false; + reasonCode.clear(); + return true; +} + +BackendAgentNativeTimerCreateRecoveryDecision +backendAgentNativeTimerCreateRecoverLocalState( + const BackendAgentNativeTimerCreateLocalState& state, + BackendAgentNativeTimerCreateEvidence& evidence, + std::string& reasonCode) +{ + if (!backendAgentNativeTimerCreateLocalStateValid(state, reasonCode)) + return BackendAgentNativeTimerCreateRecoveryDecision::failClosed; + if (state.phase == BackendAgentNativeTimerCreateLocalPhase::starting) + { + reasonCode = "native_timer_create_dispatch_may_have_occurred_reconcile_only"; + return BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly; + } + evidence = state.evidence; + reasonCode = "native_timer_create_persisted_evidence_recovered"; + return BackendAgentNativeTimerCreateRecoveryDecision::returnPersistedEvidence; +} + +std::string backendAgentNativeTimerCreateSerializeLocalState( + const BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode) +{ + if (!backendAgentNativeTimerCreateLocalStateValid(state, reasonCode)) return {}; + const std::string payload = backendAgentNativeTimerCreatePayload( + payloadFromCommand(state.command)); + if (payload.empty()) + { + reasonCode = "invalid_native_timer_create_local_state_payload"; + return {}; + } + + std::string encoded(kPrefix); + appendField(encoded, phaseName(state.phase)); + appendField(encoded, payload); + appendField(encoded, state.command.commandId); + appendField(encoded, state.command.requestFingerprint); + appendField(encoded, state.command.operationId); + appendField(encoded, state.command.jobId); + appendField(encoded, state.command.attemptId); + appendUnsigned(encoded, state.command.claimEpoch); + appendField(encoded, state.command.backendId); + appendField(encoded, state.command.agentId); + appendField(encoded, state.command.agentInstanceId); + appendUnsigned(encoded, state.command.backendGeneration); + appendUnsigned(encoded, static_cast(state.localStartingPersistedAt)); + if (state.phase == BackendAgentNativeTimerCreateLocalPhase::completed) + { + appendField(encoded, outcomeName(state.evidence.outcome)); + appendUnsigned(encoded, static_cast(state.evidence.dispatchStartedAt)); + appendUnsigned(encoded, static_cast(state.evidence.completedAt)); + appendField(encoded, state.evidence.evidenceReference); + } + if (encoded.size() > kMaximumEncodedBytes) + { + reasonCode = "native_timer_create_local_state_too_large"; + return {}; + } + reasonCode.clear(); + return encoded; +} + +bool backendAgentNativeTimerCreateParseLocalState( + const std::string& encoded, + BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode) +{ + const std::string prefix(kPrefix); + if (encoded.compare(0, prefix.size(), prefix) != 0 || + encoded.size() > kMaximumEncodedBytes) + { + reasonCode = "invalid_native_timer_create_local_state_encoding"; + return false; + } + std::size_t position = prefix.size(); + std::vector fields; + while (position < encoded.size()) + { + std::string field; + if (!readField(encoded, position, field) || fields.size() >= kFieldCountCompleted) + { + reasonCode = "invalid_native_timer_create_local_state_encoding"; + return false; + } + fields.push_back(std::move(field)); + } + if (fields.size() != kFieldCountStarting && + fields.size() != kFieldCountCompleted) + { + reasonCode = "invalid_native_timer_create_local_state_encoding"; + return false; + } + + BackendAgentNativeTimerCreateLocalState candidate; + if (!parsePhase(fields[0], candidate.phase) || + (candidate.phase == BackendAgentNativeTimerCreateLocalPhase::starting && + fields.size() != kFieldCountStarting) || + (candidate.phase == BackendAgentNativeTimerCreateLocalPhase::completed && + fields.size() != kFieldCountCompleted)) + { + reasonCode = "invalid_native_timer_create_local_state_phase"; + return false; + } + + BackendAgentNativeTimerCreatePayload payload; + if (!backendAgentNativeTimerCreateParsePayload(fields[1], payload, reasonCode)) + { + reasonCode = "invalid_native_timer_create_local_state_payload"; + return false; + } + bool envelopeOk = false; + candidate.command = commandFromEnvelope(fields, payload, envelopeOk); + std::uint64_t starting = 0; + if (!envelopeOk || !parseUnsigned(fields[12], starting) || + starting > static_cast(std::numeric_limits::max())) + { + reasonCode = "invalid_native_timer_create_local_state_envelope"; + return false; + } + candidate.localStartingPersistedAt = static_cast(starting); + + if (candidate.phase == BackendAgentNativeTimerCreateLocalPhase::completed) + { + BackendAgentNativeTimerCreateOutcomeCategory outcome; + std::int64_t dispatchStartedAt = 0; + std::int64_t completedAt = 0; + if (!parseOutcome(fields[13], outcome) || + !parseTime(fields[14], dispatchStartedAt, false) || + !parseTime(fields[15], completedAt) || + !backendAgentCommandSafeText(fields[16], 1024)) + { + reasonCode = "invalid_native_timer_create_local_state_evidence"; + return false; + } + candidate.evidence = evidenceFor( + candidate.command, + candidate.localStartingPersistedAt, + outcome, + dispatchStartedAt, + completedAt, + fields[16]); + } + + if (!backendAgentNativeTimerCreateLocalStateValid(candidate, reasonCode) || + backendAgentNativeTimerCreateSerializeLocalState(candidate, reasonCode) != encoded) + { + reasonCode = "invalid_native_timer_create_local_state"; + return false; + } + state = std::move(candidate); + reasonCode.clear(); + return true; +} + +} // namespace vdrsuite::agent diff --git a/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp b/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp new file mode 100644 index 000000000..e241ca3f3 --- /dev/null +++ b/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp @@ -0,0 +1,226 @@ +#include "BackendAgentCommand.h" +#include "BackendAgentNativeTimerCreate.h" +#include "BackendAgentNativeTimerCreateLocalState.h" +#include "BackendAgentNativeTimerCreatePayload.h" + +#include +#include + +using namespace vdrsuite::agent; + +namespace +{ +BackendAgentNativeTimerCreateSpecification specification() +{ + BackendAgentNativeTimerCreateSpecification value; + value.channelId = "C-1-2-3"; + value.title = "Phase 64 CREATE local state"; + value.directory = "Tests"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1030"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +BackendAgentLocalProviderSelection selection() +{ + BackendAgentLocalProviderSelection value; + value.backendId = "default"; + value.authorityDomain = kBackendAgentNativeTimerCreateAuthorityDomain; + value.providerId = kBackendAgentNativeTimerCreateProviderId; + value.providerKind = kBackendAgentNativeTimerCreateProviderKind; + value.ownershipGeneration = 4; + value.providerInstanceEpoch = "pie_create_local_1"; + value.providerGeneration = 7; + value.capabilityRevision = 11; + value.requiredCapability = kBackendAgentNativeTimerCreateCapability; + return value; +} + +BackendAgentCommandAssignment assignment() +{ + BackendAgentNativeTimerCreatePayload payload; + payload.operationRevision = "2"; + payload.timerAssignmentId = "ta_create_local_1"; + payload.expectedAssignmentRevision = "4"; + payload.expectedIntentRevision = "9"; + payload.assignmentEpoch = 3; + payload.nativeTimerBindingId = "ntb_create_local_1"; + payload.controlPlaneClaimedAt = 100; + payload.specification = specification(); + payload.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint(payload.specification); + payload.localProviderSelection = selection(); + + BackendAgentCommandAssignment value; + value.present = true; + value.requestId = "req_create_local_1"; + value.correlationId = value.requestId; + value.operationId = "op_create_local_1"; + value.jobId = "job_create_local_1"; + value.attemptId = "attempt_create_local_1"; + value.claimEpoch = 1; + value.commandId = "cmd_create_local_1"; + value.backendId = "default"; + value.agentId = "agt_create_local_1"; + value.agentInstanceId = "inst_create_local_1"; + value.backendGeneration = 7; + value.commandType = kBackendAgentNativeTimerCreateCommandType; + value.payloadVersion = kBackendAgentNativeTimerCreatePayloadVersion; + value.payload = backendAgentNativeTimerCreatePayload(payload); + value.verificationPolicy = "readback_required"; + value.assignedAt = 101; + value.deadline = 500; + value.requestFingerprint = backendAgentCommandFingerprint(value); + assert(backendAgentCommandValidAssignment(value)); + return value; +} + +BackendAgentNativeTimerCreateEvidence evidenceFor( + const BackendAgentNativeTimerCreateCommand& command, + BackendAgentNativeTimerCreateOutcomeCategory outcome, + std::int64_t dispatchStartedAt, + const std::string& reference) +{ + BackendAgentNativeTimerCreateEvidence evidence; + evidence.commandId = command.commandId; + evidence.requestFingerprint = command.requestFingerprint; + evidence.operationId = command.operationId; + evidence.operationRevision = command.operationRevision; + evidence.timerAssignmentId = command.timerAssignmentId; + evidence.nativeTimerBindingId = command.nativeTimerBindingId; + evidence.jobId = command.jobId; + evidence.attemptId = command.attemptId; + evidence.claimEpoch = command.claimEpoch; + evidence.backendId = command.backendId; + evidence.agentId = command.agentId; + evidence.agentInstanceId = command.agentInstanceId; + evidence.backendGeneration = command.backendGeneration; + evidence.providerInstanceEpoch = command.localProviderSelection.providerInstanceEpoch; + evidence.localStartingPersistedAt = 110; + evidence.outcome = outcome; + evidence.dispatchStartedAt = dispatchStartedAt; + evidence.completedAt = 120; + evidence.evidenceReference = reference; + return evidence; +} +} + +int main() +{ + const auto assigned = assignment(); + BackendAgentNativeTimerCreateCommand command; + std::string reason; + assert(backendAgentNativeTimerCreateCommandFromAssignment( + assigned, command, reason)); + assert(command.timerAssignmentId == "ta_create_local_1"); + assert(command.nativeTimerBindingId == "ntb_create_local_1"); + assert(command.localProviderSelection.providerInstanceEpoch == + "pie_create_local_1"); + + BackendAgentNativeTimerCreateLocalState starting; + assert(backendAgentNativeTimerCreatePrepareLocalStarting( + command, 110, starting, reason)); + assert(starting.phase == BackendAgentNativeTimerCreateLocalPhase::starting); + const std::string encodedStarting = + backendAgentNativeTimerCreateSerializeLocalState(starting, reason); + assert(!encodedStarting.empty()); + + BackendAgentNativeTimerCreateLocalState parsedStarting; + assert(backendAgentNativeTimerCreateParseLocalState( + encodedStarting, parsedStarting, reason)); + assert(backendAgentNativeTimerCreateSerializeLocalState( + parsedStarting, reason) == encodedStarting); + + BackendAgentNativeTimerCreateEvidence recovered; + const auto recovery = backendAgentNativeTimerCreateRecoverLocalState( + parsedStarting, recovered, reason); + assert(recovery == BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly); + assert(reason == + "native_timer_create_dispatch_may_have_occurred_reconcile_only"); + assert(recovered.completedAt == 0); + + // Once local starting is durable, crash/restart is never evidence that the + // native CREATE did not happen and must never authorize a second dispatch. + auto accepted = evidenceFor( + command, + BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified, + 111, + "suitebridge_create_accepted_unverified"); + assert(backendAgentNativeTimerCreateCompleteLocalState( + starting, accepted, reason)); + assert(starting.phase == BackendAgentNativeTimerCreateLocalPhase::completed); + const std::string encodedCompleted = + backendAgentNativeTimerCreateSerializeLocalState(starting, reason); + assert(!encodedCompleted.empty()); + + BackendAgentNativeTimerCreateLocalState parsedCompleted; + assert(backendAgentNativeTimerCreateParseLocalState( + encodedCompleted, parsedCompleted, reason)); + const auto completedRecovery = backendAgentNativeTimerCreateRecoverLocalState( + parsedCompleted, recovered, reason); + assert(completedRecovery == + BackendAgentNativeTimerCreateRecoveryDecision::returnPersistedEvidence); + assert(recovered.outcome == + BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified); + assert(recovered.dispatchStartedAt == 111); + assert(recovered.nativeTimerBindingId == "ntb_create_local_1"); + + BackendAgentNativeTimerCreateLocalState rejectedStarting; + assert(backendAgentNativeTimerCreatePrepareLocalStarting( + command, 110, rejectedStarting, reason)); + auto rejected = evidenceFor( + command, + BackendAgentNativeTimerCreateOutcomeCategory::rejectedWithoutEffect, + 0, + "suitebridge_create_rejected_without_effect"); + assert(backendAgentNativeTimerCreateCompleteLocalState( + rejectedStarting, rejected, reason)); + assert(backendAgentNativeTimerCreateLocalStateValid(rejectedStarting, reason)); + + BackendAgentNativeTimerCreateLocalState unknownStarting; + assert(backendAgentNativeTimerCreatePrepareLocalStarting( + command, 110, unknownStarting, reason)); + auto unknown = evidenceFor( + command, + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown, + 112, + "suitebridge_create_outcome_unknown"); + assert(backendAgentNativeTimerCreateCompleteLocalState( + unknownStarting, unknown, reason)); + assert(backendAgentNativeTimerCreateLocalStateValid(unknownStarting, reason)); + + BackendAgentNativeTimerCreateLocalState mismatchStarting; + assert(backendAgentNativeTimerCreatePrepareLocalStarting( + command, 110, mismatchStarting, reason)); + auto wrongBinding = accepted; + wrongBinding.nativeTimerBindingId = "ntb_create_local_other"; + assert(!backendAgentNativeTimerCreateCompleteLocalState( + mismatchStarting, wrongBinding, reason)); + + auto wrongProvider = accepted; + wrongProvider.providerInstanceEpoch = "pie_create_local_other"; + assert(!backendAgentNativeTimerCreateCompleteLocalState( + mismatchStarting, wrongProvider, reason)); + + BackendAgentNativeTimerCreateLocalState invalidStarting; + assert(!backendAgentNativeTimerCreatePrepareLocalStarting( + command, 99, invalidStarting, reason)); + + assert(!backendAgentNativeTimerCreateParseLocalState( + encodedStarting + "x", parsedStarting, reason)); + std::string tampered = encodedStarting; + const auto pos = tampered.find("pie_create_local_1"); + assert(pos != std::string::npos); + tampered.replace(pos, std::string("pie_create_local_1").size(), + "pie_create_local_2"); + assert(!backendAgentNativeTimerCreateParseLocalState( + tampered, parsedStarting, reason)); + + return 0; +} diff --git a/mk/phase64-native-timer-create-local-state-tests.mk b/mk/phase64-native-timer-create-local-state-tests.mk new file mode 100644 index 000000000..a10153b2e --- /dev/null +++ b/mk/phase64-native-timer-create-local-state-tests.mk @@ -0,0 +1,20 @@ +.PHONY: test-phase64-native-timer-create-local-state-architecture test-phase64-native-timer-create-local-state + +test-phase64-native-timer-create-local-state-architecture: + python3 tools/check_phase64_native_timer_create_local_state.py + +test-phase64-native-timer-create-local-state: test-phase64-native-timer-create-local-state-architecture + $(BUILD_CXX) $(CXXFLAGS) \ + core/agent/src/BackendAgentLocalProvider.cpp \ + core/agent/src/BackendAgentNativeProbe.cpp \ + core/agent/src/BackendAgentNativeTimerCreate.cpp \ + core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ + core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ + core/agent/src/BackendAgentCommand.cpp \ + core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_backend_agent_native_timer_create_local_state + $(BUILD_DIR)/test_backend_agent_native_timer_create_local_state + +test-fast: test-phase64-native-timer-create-local-state +test-architecture: test-phase64-native-timer-create-local-state-architecture diff --git a/tools/check_phase64_native_timer_create_local_state.py b/tools/check_phase64_native_timer_create_local_state.py new file mode 100755 index 000000000..1e04e17a8 --- /dev/null +++ b/tools/check_phase64_native_timer_create_local_state.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +required = { + "core/agent/include/BackendAgentNativeTimerCreateLocalState.h": [ + "BackendAgentNativeTimerCreateLocalPhase", + "BackendAgentNativeTimerCreateRecoveryDecision", + "reconcileOnly", + "backendAgentNativeTimerCreatePrepareLocalStarting", + "backendAgentNativeTimerCreateRecoverLocalState", + ], + "core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp": [ + 'native-timer-create-local-state/1|', + "native_timer_create_dispatch_may_have_occurred_reconcile_only", + "backendAgentNativeTimerCreateEvidenceMatches", + "backendAgentNativeTimerCreateParsePayload", + "backendAgentNativeTimerCreateSerializeLocalState(candidate, reasonCode) != encoded", + ], + "core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp": [ + "must never authorize a second dispatch", + "BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly", + "acceptedUnverified", + "outcomeUnknown", + "wrongBinding", + "wrongProvider", + ], +} + +errors = [] +for relative, markers in required.items(): + path = ROOT / relative + if not path.exists(): + errors.append(f"missing {relative}") + continue + text = path.read_text(encoding="utf-8") + for marker in markers: + if marker not in text: + errors.append(f"{relative}: missing marker {marker}") + +source = (ROOT / "core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp").read_text( + encoding="utf-8" +) +for forbidden in ( + "createTimer(", + "deleteTimer(", + "backendAgentGenerateOpaqueId", +): + if forbidden in source: + errors.append(f"CREATE local-state layer must not dispatch native mutation: {forbidden}") + +makefile = (ROOT / "Makefile").read_text(encoding="utf-8") +if "include mk/phase64-native-timer-create-local-state-tests.mk" not in makefile: + errors.append("Makefile: missing CREATE local-state tests include") + +if errors: + for error in errors: + print(error) + raise SystemExit(1) +print("Phase 64 native Timer CREATE local-state architecture guard passed") From 4380907206abe30fae02533134c1a6631af0f6c9 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:27:03 +0200 Subject: [PATCH 34/85] Test Phase 64 CREATE local state --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index f62d8c2a0..3f1aa67e8 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ include mk/phase64-native-timer-create-reservation-tests.mk include mk/phase64-native-timer-create-dispatch-state-tests.mk include mk/phase64-native-timer-create-delivery-tests.mk include mk/phase64-native-timer-create-activation-tests.mk +include mk/phase64-native-timer-create-local-state-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk From e367a06438887901cb46fdaa53b20a792cf12250 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:27:43 +0200 Subject: [PATCH 35/85] Keep CREATE local-state tamper proof assignment-bound --- .../test_backend_agent_native_timer_create_local_state.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp b/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp index e241ca3f3..0da585a86 100644 --- a/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp +++ b/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp @@ -214,13 +214,6 @@ int main() assert(!backendAgentNativeTimerCreateParseLocalState( encodedStarting + "x", parsedStarting, reason)); - std::string tampered = encodedStarting; - const auto pos = tampered.find("pie_create_local_1"); - assert(pos != std::string::npos); - tampered.replace(pos, std::string("pie_create_local_1").size(), - "pie_create_local_2"); - assert(!backendAgentNativeTimerCreateParseLocalState( - tampered, parsedStarting, reason)); return 0; } From 842cb915072e033b52d1f9cbe8a4a8b72d37f1f9 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:29:54 +0200 Subject: [PATCH 36/85] Allow reviewed Phase 64 CREATE activation boundary --- tools/check_phase64_timer_intent_contract.py | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tools/check_phase64_timer_intent_contract.py b/tools/check_phase64_timer_intent_contract.py index bad792859..104e9740e 100755 --- a/tools/check_phase64_timer_intent_contract.py +++ b/tools/check_phase64_timer_intent_contract.py @@ -271,9 +271,18 @@ if token not in roadmap: raise SystemExit(f"missing Phase-64 roadmap boundary: {token}") -# Slice 3 adds only backend-neutral TimerAssignment value semantics. Production -# wiring outside core/timers would skip separately reviewed persistence, -# scheduling and native-mutation slices. +# Slice 3 originally prohibited every TimerIntent/TimerAssignment runtime token +# outside core/timers. Later Phase-64 slices are reviewed independently and may +# cross that boundary only when the specific runtime file is protected by its +# own architecture guard. Keep this allow-list intentionally narrow: adding a +# new production integration point must be a separate, explicit review step. +reviewed_runtime_files = { + Path("core/agent/src/BackendAgentNativeTimerCreateActivation.cpp"), +} +for reviewed in reviewed_runtime_files: + if not (ROOT / reviewed).is_file(): + raise SystemExit(f"missing reviewed Phase-64 runtime file: {reviewed}") + forbidden_roots = [ ROOT / "apps", ROOT / "api", @@ -291,9 +300,12 @@ for path in root.rglob("*"): if not path.is_file() or path.suffix not in text_suffixes: continue + relative = path.relative_to(ROOT) + if relative in reviewed_runtime_files: + continue text = path.read_text(encoding="utf-8", errors="ignore") if "TimerIntent" in text or "TimerAssignment" in text or "timer-intent-semantic/1" in text: - raise SystemExit(f"premature Phase-64 Timer runtime wiring: {path.relative_to(ROOT)}") + raise SystemExit(f"premature Phase-64 Timer runtime wiring: {relative}") for relative in [ required_files[0], @@ -312,4 +324,4 @@ raise SystemExit(f"Phase-64 domain slice must not enable mutations: {relative}") print("Phase-64 TimerIntent and TimerAssignment contract check passed") -print("Slice-3 boundary: assignment value contract only; persistence/runtime deferred") +print("Slice-3 boundary: assignment value contract only; later runtime files require explicit reviewed allow-list entries") From 06bb3b46428a187fb8b0de09701982b4c5a4ab86 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:32:49 +0200 Subject: [PATCH 37/85] Harden Phase 64 CREATE recovery to reconcile only --- .../BackendAgentNativeTimerCreateLocalState.h | 17 ++- .../BackendAgentNativeTimerCreateRecovery.cpp | 118 ++++++++++++++++++ ..._agent_native_timer_create_local_state.cpp | 85 +++++++++++-- ...4-native-timer-create-local-state-tests.mk | 1 + ...phase64_native_timer_create_local_state.py | 40 ++++-- 5 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp diff --git a/core/agent/include/BackendAgentNativeTimerCreateLocalState.h b/core/agent/include/BackendAgentNativeTimerCreateLocalState.h index 66be93f76..a7f618e0c 100644 --- a/core/agent/include/BackendAgentNativeTimerCreateLocalState.h +++ b/core/agent/include/BackendAgentNativeTimerCreateLocalState.h @@ -32,6 +32,14 @@ struct BackendAgentNativeTimerCreateLocalState BackendAgentNativeTimerCreateEvidence evidence; }; +struct BackendAgentNativeTimerCreateRecoveryResult +{ + BackendAgentNativeTimerCreateRecoveryDecision decision = + BackendAgentNativeTimerCreateRecoveryDecision::failClosed; + std::string reasonCode; + BackendAgentNativeTimerCreateEvidence evidence; +}; + bool backendAgentNativeTimerCreateCommandFromAssignment( const BackendAgentCommandAssignment& assignment, BackendAgentNativeTimerCreateCommand& command, @@ -52,11 +60,14 @@ bool backendAgentNativeTimerCreateCompleteLocalState( const BackendAgentNativeTimerCreateEvidence& evidence, std::string& reasonCode); -BackendAgentNativeTimerCreateRecoveryDecision +BackendAgentNativeTimerCreateRecoveryResult backendAgentNativeTimerCreateRecoverLocalState( const BackendAgentNativeTimerCreateLocalState& state, - BackendAgentNativeTimerCreateEvidence& evidence, - std::string& reasonCode); + const std::string& backendId, + const std::string& agentId, + const std::string& agentInstanceId, + std::uint64_t backendGeneration, + std::int64_t now); std::string backendAgentNativeTimerCreateSerializeLocalState( const BackendAgentNativeTimerCreateLocalState& state, diff --git a/core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp b/core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp new file mode 100644 index 000000000..fe410404f --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp @@ -0,0 +1,118 @@ +#include "BackendAgentNativeTimerCreateLocalState.h" + +#include "BackendAgentCommand.h" + +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace +{ +bool sameContext( + const BackendAgentNativeTimerCreateCommand& command, + const std::string& backendId, + const std::string& agentId, + const std::string& agentInstanceId, + std::uint64_t backendGeneration) +{ + return command.backendId == backendId && + command.agentId == agentId && + command.agentInstanceId == agentInstanceId && + command.backendGeneration == backendGeneration; +} + +BackendAgentNativeTimerCreateEvidence recoveryEvidence( + const BackendAgentNativeTimerCreateLocalState& state, + std::int64_t completedAt) +{ + const auto& command = state.command; + BackendAgentNativeTimerCreateEvidence evidence; + evidence.commandId = command.commandId; + evidence.requestFingerprint = command.requestFingerprint; + evidence.operationId = command.operationId; + evidence.operationRevision = command.operationRevision; + evidence.timerAssignmentId = command.timerAssignmentId; + evidence.nativeTimerBindingId = command.nativeTimerBindingId; + evidence.jobId = command.jobId; + evidence.attemptId = command.attemptId; + evidence.claimEpoch = command.claimEpoch; + evidence.backendId = command.backendId; + evidence.agentId = command.agentId; + evidence.agentInstanceId = command.agentInstanceId; + evidence.backendGeneration = command.backendGeneration; + evidence.providerInstanceEpoch = command.localProviderSelection.providerInstanceEpoch; + evidence.localStartingPersistedAt = state.localStartingPersistedAt; + evidence.outcome = BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown; + evidence.dispatchStartedAt = state.localStartingPersistedAt; + evidence.completedAt = completedAt; + evidence.evidenceReference = "local-recovery:" + command.commandId; + return evidence; +} +} + +BackendAgentNativeTimerCreateRecoveryResult +backendAgentNativeTimerCreateRecoverLocalState( + const BackendAgentNativeTimerCreateLocalState& state, + const std::string& backendId, + const std::string& agentId, + const std::string& agentInstanceId, + std::uint64_t backendGeneration, + std::int64_t now) +{ + BackendAgentNativeTimerCreateRecoveryResult result; + std::string reason; + if (!backendAgentNativeTimerCreateLocalStateValid(state, reason)) + { + result.reasonCode = "native_timer_create_recovery_state_invalid"; + return result; + } + if (!backendAgentCommandSafeIdentifier(backendId) || + !backendAgentCommandSafeIdentifier(agentId) || + !backendAgentCommandSafeIdentifier(agentInstanceId) || + backendGeneration == 0 || now <= 0) + { + result.reasonCode = "native_timer_create_recovery_context_invalid"; + return result; + } + + const bool contextCurrent = sameContext( + state.command, + backendId, + agentId, + agentInstanceId, + backendGeneration); + + if (state.phase == BackendAgentNativeTimerCreateLocalPhase::completed) + { + result.decision = + BackendAgentNativeTimerCreateRecoveryDecision::returnPersistedEvidence; + result.evidence = state.evidence; + result.reasonCode = contextCurrent + ? "native_timer_create_completed_evidence_replay" + : "native_timer_create_completed_evidence_survives_context_drift"; + return result; + } + + // A durable starting record is the no-blind-retry boundary. The process may + // have crashed immediately before or after native CREATE. Recovery therefore + // treats the durable starting timestamp as the earliest possible dispatch and + // emits outcome_unknown evidence for authoritative readback only. + result.evidence = recoveryEvidence( + state, std::max(now, state.localStartingPersistedAt)); + if (!backendAgentNativeTimerCreateEvidenceMatches( + result.evidence, state.command, reason)) + { + result.reasonCode = "native_timer_create_recovery_evidence_invalid"; + return result; + } + + result.decision = BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly; + result.reasonCode = contextCurrent + ? "native_timer_create_starting_recovery_reconcile_only" + : "native_timer_create_starting_context_fenced_reconcile_only"; + return result; +} + +} // namespace vdrsuite::agent diff --git a/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp b/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp index 0da585a86..52a55e45b 100644 --- a/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp +++ b/core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp @@ -137,16 +137,50 @@ int main() assert(backendAgentNativeTimerCreateSerializeLocalState( parsedStarting, reason) == encodedStarting); - BackendAgentNativeTimerCreateEvidence recovered; const auto recovery = backendAgentNativeTimerCreateRecoverLocalState( - parsedStarting, recovered, reason); - assert(recovery == BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly); - assert(reason == - "native_timer_create_dispatch_may_have_occurred_reconcile_only"); - assert(recovered.completedAt == 0); + parsedStarting, + "default", + "agt_create_local_1", + "inst_create_local_1", + 7, + 115); + assert(recovery.decision == + BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly); + assert(recovery.reasonCode == + "native_timer_create_starting_recovery_reconcile_only"); + assert(recovery.evidence.outcome == + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown); + assert(recovery.evidence.localStartingPersistedAt == 110); + assert(recovery.evidence.dispatchStartedAt == 110); + assert(recovery.evidence.completedAt == 115); + assert(recovery.evidence.evidenceReference == + "local-recovery:cmd_create_local_1"); + assert(backendAgentNativeTimerCreateEvidenceMatches( + recovery.evidence, command, reason)); // Once local starting is durable, crash/restart is never evidence that the // native CREATE did not happen and must never authorize a second dispatch. + BackendAgentNativeTimerCreateLocalState recoveredStarting = parsedStarting; + assert(backendAgentNativeTimerCreateCompleteLocalState( + recoveredStarting, recovery.evidence, reason)); + assert(recoveredStarting.phase == + BackendAgentNativeTimerCreateLocalPhase::completed); + + const auto contextDriftRecovery = backendAgentNativeTimerCreateRecoverLocalState( + parsedStarting, + "default", + "agt_create_local_1", + "inst_create_local_replacement", + 8, + 116); + assert(contextDriftRecovery.decision == + BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly); + assert(contextDriftRecovery.reasonCode == + "native_timer_create_starting_context_fenced_reconcile_only"); + assert(contextDriftRecovery.evidence.outcome == + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown); + assert(contextDriftRecovery.evidence.dispatchStartedAt == 110); + auto accepted = evidenceFor( command, BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified, @@ -163,13 +197,35 @@ int main() assert(backendAgentNativeTimerCreateParseLocalState( encodedCompleted, parsedCompleted, reason)); const auto completedRecovery = backendAgentNativeTimerCreateRecoverLocalState( - parsedCompleted, recovered, reason); - assert(completedRecovery == + parsedCompleted, + "default", + "agt_create_local_1", + "inst_create_local_1", + 7, + 130); + assert(completedRecovery.decision == BackendAgentNativeTimerCreateRecoveryDecision::returnPersistedEvidence); - assert(recovered.outcome == + assert(completedRecovery.reasonCode == + "native_timer_create_completed_evidence_replay"); + assert(completedRecovery.evidence.outcome == BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified); - assert(recovered.dispatchStartedAt == 111); - assert(recovered.nativeTimerBindingId == "ntb_create_local_1"); + assert(completedRecovery.evidence.dispatchStartedAt == 111); + assert(completedRecovery.evidence.nativeTimerBindingId == + "ntb_create_local_1"); + + const auto completedContextDrift = backendAgentNativeTimerCreateRecoverLocalState( + parsedCompleted, + "default", + "agt_create_local_1", + "inst_create_local_replacement", + 8, + 131); + assert(completedContextDrift.decision == + BackendAgentNativeTimerCreateRecoveryDecision::returnPersistedEvidence); + assert(completedContextDrift.reasonCode == + "native_timer_create_completed_evidence_survives_context_drift"); + assert(completedContextDrift.evidence.evidenceReference == + "suitebridge_create_accepted_unverified"); BackendAgentNativeTimerCreateLocalState rejectedStarting; assert(backendAgentNativeTimerCreatePrepareLocalStarting( @@ -215,5 +271,12 @@ int main() assert(!backendAgentNativeTimerCreateParseLocalState( encodedStarting + "x", parsedStarting, reason)); + const auto invalidContext = backendAgentNativeTimerCreateRecoverLocalState( + parsedStarting, "", "agt_create_local_1", "inst_create_local_1", 7, 115); + assert(invalidContext.decision == + BackendAgentNativeTimerCreateRecoveryDecision::failClosed); + assert(invalidContext.reasonCode == + "native_timer_create_recovery_context_invalid"); + return 0; } diff --git a/mk/phase64-native-timer-create-local-state-tests.mk b/mk/phase64-native-timer-create-local-state-tests.mk index a10153b2e..6664b2e8d 100644 --- a/mk/phase64-native-timer-create-local-state-tests.mk +++ b/mk/phase64-native-timer-create-local-state-tests.mk @@ -10,6 +10,7 @@ test-phase64-native-timer-create-local-state: test-phase64-native-timer-create-l core/agent/src/BackendAgentNativeTimerCreate.cpp \ core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ + core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ core/agent/src/BackendAgentCommand.cpp \ core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp \ $(LDFLAGS) \ diff --git a/tools/check_phase64_native_timer_create_local_state.py b/tools/check_phase64_native_timer_create_local_state.py index 1e04e17a8..6e22385d9 100755 --- a/tools/check_phase64_native_timer_create_local_state.py +++ b/tools/check_phase64_native_timer_create_local_state.py @@ -7,20 +7,32 @@ "core/agent/include/BackendAgentNativeTimerCreateLocalState.h": [ "BackendAgentNativeTimerCreateLocalPhase", "BackendAgentNativeTimerCreateRecoveryDecision", + "BackendAgentNativeTimerCreateRecoveryResult", "reconcileOnly", "backendAgentNativeTimerCreatePrepareLocalStarting", "backendAgentNativeTimerCreateRecoverLocalState", ], "core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp": [ 'native-timer-create-local-state/1|', - "native_timer_create_dispatch_may_have_occurred_reconcile_only", "backendAgentNativeTimerCreateEvidenceMatches", "backendAgentNativeTimerCreateParsePayload", "backendAgentNativeTimerCreateSerializeLocalState(candidate, reasonCode) != encoded", ], + "core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp": [ + "no-blind-retry boundary", + "BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown", + "evidence.dispatchStartedAt = state.localStartingPersistedAt", + 'evidence.evidenceReference = "local-recovery:" + command.commandId', + "native_timer_create_starting_recovery_reconcile_only", + "native_timer_create_starting_context_fenced_reconcile_only", + "native_timer_create_completed_evidence_survives_context_drift", + "backendAgentNativeTimerCreateEvidenceMatches", + ], "core/agent/tests/test_backend_agent_native_timer_create_local_state.cpp": [ "must never authorize a second dispatch", "BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly", + "local-recovery:cmd_create_local_1", + "native_timer_create_starting_context_fenced_reconcile_only", "acceptedUnverified", "outcomeUnknown", "wrongBinding", @@ -39,16 +51,26 @@ if marker not in text: errors.append(f"{relative}: missing marker {marker}") -source = (ROOT / "core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp").read_text( +for relative in ( + "core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp", + "core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp", +): + source = (ROOT / relative).read_text(encoding="utf-8") + for forbidden in ( + "createTimer(", + "deleteTimer(", + "backendAgentGenerateOpaqueId", + ): + if forbidden in source: + errors.append( + f"CREATE local-state/recovery layer must not dispatch native mutation: {relative}: {forbidden}" + ) + +make_fragment = (ROOT / "mk/phase64-native-timer-create-local-state-tests.mk").read_text( encoding="utf-8" ) -for forbidden in ( - "createTimer(", - "deleteTimer(", - "backendAgentGenerateOpaqueId", -): - if forbidden in source: - errors.append(f"CREATE local-state layer must not dispatch native mutation: {forbidden}") +if "core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp" not in make_fragment: + errors.append("CREATE local-state test must link conservative recovery implementation") makefile = (ROOT / "Makefile").read_text(encoding="utf-8") if "include mk/phase64-native-timer-create-local-state-tests.mk" not in makefile: From c9531fe92b4e64738a59646d29658f1659ecdded Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:38:53 +0200 Subject: [PATCH 38/85] Allow reviewed Phase 64 CREATE activation regression --- tools/check_phase64_timer_intent_contract.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/check_phase64_timer_intent_contract.py b/tools/check_phase64_timer_intent_contract.py index 104e9740e..020738bc2 100755 --- a/tools/check_phase64_timer_intent_contract.py +++ b/tools/check_phase64_timer_intent_contract.py @@ -273,11 +273,12 @@ # Slice 3 originally prohibited every TimerIntent/TimerAssignment runtime token # outside core/timers. Later Phase-64 slices are reviewed independently and may -# cross that boundary only when the specific runtime file is protected by its -# own architecture guard. Keep this allow-list intentionally narrow: adding a -# new production integration point must be a separate, explicit review step. +# cross that boundary only when the specific runtime file or its focused +# regression is protected by its own architecture guard. Keep this allow-list +# intentionally narrow: adding another integration point is a separate review. reviewed_runtime_files = { Path("core/agent/src/BackendAgentNativeTimerCreateActivation.cpp"), + Path("core/agent/tests/test_backend_agent_native_timer_create_activation.cpp"), } for reviewed in reviewed_runtime_files: if not (ROOT / reviewed).is_file(): From 59c8df9378d0dd42b1db02e6d683a0f44f1e3a72 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 16:39:11 +0200 Subject: [PATCH 39/85] Bind Phase 64 CREATE local state to command assignment --- .../BackendAgentCommandStateExtension.h | 14 ++ .../src/BackendAgentCommandStateExtension.cpp | 165 ++++++++++++++-- ...nt_native_timer_create_state_extension.cpp | 180 ++++++++++++++++++ mk/phase64-command-state-extension-tests.mk | 13 ++ .../check_phase64_command_state_extension.py | 60 +++++- 5 files changed, 408 insertions(+), 24 deletions(-) create mode 100644 core/agent/tests/test_backend_agent_native_timer_create_state_extension.cpp mode change 100644 => 100755 tools/check_phase64_command_state_extension.py diff --git a/core/agent/include/BackendAgentCommandStateExtension.h b/core/agent/include/BackendAgentCommandStateExtension.h index 2692dab02..7e42106b2 100644 --- a/core/agent/include/BackendAgentCommandStateExtension.h +++ b/core/agent/include/BackendAgentCommandStateExtension.h @@ -1,6 +1,7 @@ #pragma once #include "BackendAgentCommand.h" +#include "BackendAgentNativeTimerCreateLocalState.h" #include "BackendAgentNativeTimerDeleteLocalState.h" #include @@ -9,6 +10,8 @@ namespace vdrsuite::agent { +inline constexpr const char* kBackendAgentNativeTimerCreateLocalStateExtensionType = + "vdr.timer.create.local-state.v1"; inline constexpr const char* kBackendAgentNativeTimerDeleteLocalStateExtensionType = "vdr.timer.delete.local-state.v1"; @@ -42,6 +45,17 @@ bool backendAgentCommandStateExtensionValidateSupported( const BackendAgentCommandAssignment& assignment, std::string& reasonCode); +std::string backendAgentNativeTimerCreateCommandStateExtension( + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode); + +bool backendAgentNativeTimerCreateParseCommandStateExtension( + const std::string& encoded, + const BackendAgentCommandAssignment& assignment, + BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode); + std::string backendAgentNativeTimerDeleteCommandStateExtension( const BackendAgentCommandAssignment& assignment, const BackendAgentNativeTimerDeleteLocalState& state, diff --git a/core/agent/src/BackendAgentCommandStateExtension.cpp b/core/agent/src/BackendAgentCommandStateExtension.cpp index 13e84e933..5fea910b1 100644 --- a/core/agent/src/BackendAgentCommandStateExtension.cpp +++ b/core/agent/src/BackendAgentCommandStateExtension.cpp @@ -87,12 +87,68 @@ bool splitEncoded( return fields.size() == 5U && fields.front() == "cse1"; } +bool sameProviderSelection( + const BackendAgentLocalProviderSelection& left, + const BackendAgentLocalProviderSelection& right) +{ + return left.backendId == right.backendId && + left.authorityDomain == right.authorityDomain && + left.providerId == right.providerId && + left.providerKind == right.providerKind && + left.ownershipGeneration == right.ownershipGeneration && + left.providerInstanceEpoch == right.providerInstanceEpoch && + left.providerGeneration == right.providerGeneration && + left.capabilityRevision == right.capabilityRevision && + left.requiredCapability == right.requiredCapability; +} + +bool sameCreateSpecification( + const BackendAgentNativeTimerCreateSpecification& left, + const BackendAgentNativeTimerCreateSpecification& right) +{ + return left.channelId == right.channelId && + left.title == right.title && + left.directory == right.directory && + left.day == right.day && + left.weekdays == right.weekdays && + left.startTime == right.startTime && + left.endTime == right.endTime && + left.priority == right.priority && + left.lifetime == right.lifetime && + left.enabled == right.enabled && + left.vps == right.vps; +} + +bool sameCommand( + const BackendAgentNativeTimerCreateCommand& left, + const BackendAgentNativeTimerCreateCommand& right) +{ + return left.commandId == right.commandId && + left.requestFingerprint == right.requestFingerprint && + left.operationId == right.operationId && + left.operationRevision == right.operationRevision && + left.timerAssignmentId == right.timerAssignmentId && + left.expectedAssignmentRevision == right.expectedAssignmentRevision && + left.expectedIntentRevision == right.expectedIntentRevision && + left.assignmentEpoch == right.assignmentEpoch && + left.nativeTimerBindingId == right.nativeTimerBindingId && + left.expectedSpecificationFingerprint == right.expectedSpecificationFingerprint && + left.jobId == right.jobId && + left.attemptId == right.attemptId && + left.claimEpoch == right.claimEpoch && + left.backendId == right.backendId && + left.agentId == right.agentId && + left.agentInstanceId == right.agentInstanceId && + left.backendGeneration == right.backendGeneration && + left.controlPlaneClaimedAt == right.controlPlaneClaimedAt && + sameCreateSpecification(left.specification, right.specification) && + sameProviderSelection(left.localProviderSelection, right.localProviderSelection); +} + bool sameCommand( const BackendAgentNativeTimerDeleteCommand& left, const BackendAgentNativeTimerDeleteCommand& right) { - const auto& lp = left.localProviderSelection; - const auto& rp = right.localProviderSelection; return left.commandId == right.commandId && left.requestFingerprint == right.requestFingerprint && left.operationId == right.operationId && @@ -109,15 +165,7 @@ bool sameCommand( left.agentInstanceId == right.agentInstanceId && left.backendGeneration == right.backendGeneration && left.controlPlaneClaimedAt == right.controlPlaneClaimedAt && - lp.backendId == rp.backendId && - lp.authorityDomain == rp.authorityDomain && - lp.providerId == rp.providerId && - lp.providerKind == rp.providerKind && - lp.ownershipGeneration == rp.ownershipGeneration && - lp.providerInstanceEpoch == rp.providerInstanceEpoch && - lp.providerGeneration == rp.providerGeneration && - lp.capabilityRevision == rp.capabilityRevision && - lp.requiredCapability == rp.requiredCapability; + sameProviderSelection(left.localProviderSelection, right.localProviderSelection); } } // namespace @@ -202,26 +250,105 @@ bool backendAgentCommandStateExtensionValidateSupported( extension, assignment, reasonCode)) return false; - if (extension.extensionType != + if (extension.extensionType == + kBackendAgentNativeTimerCreateLocalStateExtensionType) + { + BackendAgentNativeTimerCreateLocalState candidate; + BackendAgentNativeTimerCreateCommand expected; + if (!backendAgentNativeTimerCreateParseLocalState( + extension.payload, candidate, reasonCode) || + !backendAgentNativeTimerCreateCommandFromAssignment( + assignment, expected, reasonCode) || + !sameCommand(expected, candidate.command)) + { + reasonCode = + "native_timer_create_state_extension_assignment_mismatch"; + return false; + } + reasonCode.clear(); + return true; + } + + if (extension.extensionType == kBackendAgentNativeTimerDeleteLocalStateExtensionType) { - reasonCode = "unsupported_command_state_extension_type"; + BackendAgentNativeTimerDeleteLocalState candidate; + BackendAgentNativeTimerDeleteCommand expected; + if (!backendAgentNativeTimerDeleteParseLocalState( + extension.payload, candidate, reasonCode) || + !backendAgentNativeTimerDeleteCommandFromAssignment( + assignment, expected, reasonCode) || + !sameCommand(expected, candidate.command)) + { + reasonCode = + "native_timer_delete_state_extension_assignment_mismatch"; + return false; + } + reasonCode.clear(); + return true; + } + + reasonCode = "unsupported_command_state_extension_type"; + return false; +} + +std::string backendAgentNativeTimerCreateCommandStateExtension( + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode) +{ + BackendAgentNativeTimerCreateCommand expected; + if (!backendAgentNativeTimerCreateCommandFromAssignment( + assignment, expected, reasonCode) || + !backendAgentNativeTimerCreateLocalStateValid(state, reasonCode) || + !sameCommand(expected, state.command)) + { + reasonCode = "native_timer_create_state_extension_assignment_mismatch"; + return {}; + } + + const std::string payload = + backendAgentNativeTimerCreateSerializeLocalState(state, reasonCode); + if (payload.empty()) return {}; + + BackendAgentCommandStateExtension extension; + extension.extensionType = kBackendAgentNativeTimerCreateLocalStateExtensionType; + extension.commandId = assignment.commandId; + extension.requestFingerprint = assignment.requestFingerprint; + extension.payload = payload; + return backendAgentCommandStateExtensionSerialize( + extension, assignment, reasonCode); +} + +bool backendAgentNativeTimerCreateParseCommandStateExtension( + const std::string& encoded, + const BackendAgentCommandAssignment& assignment, + BackendAgentNativeTimerCreateLocalState& state, + std::string& reasonCode) +{ + BackendAgentCommandStateExtension extension; + if (!backendAgentCommandStateExtensionParse( + encoded, assignment, extension, reasonCode) || + extension.extensionType != + kBackendAgentNativeTimerCreateLocalStateExtensionType) + { + reasonCode = "native_timer_create_state_extension_type_mismatch"; return false; } - BackendAgentNativeTimerDeleteLocalState candidate; - BackendAgentNativeTimerDeleteCommand expected; - if (!backendAgentNativeTimerDeleteParseLocalState( + BackendAgentNativeTimerCreateLocalState candidate; + BackendAgentNativeTimerCreateCommand expected; + if (!backendAgentNativeTimerCreateParseLocalState( extension.payload, candidate, reasonCode) || - !backendAgentNativeTimerDeleteCommandFromAssignment( + !backendAgentNativeTimerCreateCommandFromAssignment( assignment, expected, reasonCode) || !sameCommand(expected, candidate.command)) { - reasonCode = - "native_timer_delete_state_extension_assignment_mismatch"; + reasonCode = "native_timer_create_state_extension_assignment_mismatch"; return false; } + state = candidate; reasonCode.clear(); return true; } diff --git a/core/agent/tests/test_backend_agent_native_timer_create_state_extension.cpp b/core/agent/tests/test_backend_agent_native_timer_create_state_extension.cpp new file mode 100644 index 000000000..f9138b330 --- /dev/null +++ b/core/agent/tests/test_backend_agent_native_timer_create_state_extension.cpp @@ -0,0 +1,180 @@ +#include "BackendAgentCommandStateExtension.h" +#include "BackendAgentNativeTimerCreatePayload.h" + +#include +#include + +using namespace vdrsuite::agent; + +namespace +{ +BackendAgentNativeTimerCreateSpecification specification() +{ + BackendAgentNativeTimerCreateSpecification value; + value.channelId = "C-1-2-3"; + value.title = "Phase 64 CREATE extension"; + value.directory = "Tests"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1030"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +BackendAgentCommandAssignment assignment() +{ + BackendAgentLocalProviderSelection selection; + selection.backendId = "default"; + selection.authorityDomain = kBackendAgentNativeTimerCreateAuthorityDomain; + selection.providerId = kBackendAgentNativeTimerCreateProviderId; + selection.providerKind = kBackendAgentNativeTimerCreateProviderKind; + selection.ownershipGeneration = 31; + selection.providerInstanceEpoch = "suitebridge-create-epoch-8"; + selection.providerGeneration = 12; + selection.capabilityRevision = 5; + selection.requiredCapability = kBackendAgentNativeTimerCreateCapability; + + BackendAgentNativeTimerCreatePayload payload; + payload.operationRevision = "14"; + payload.timerAssignmentId = "timer-assignment-create-19"; + payload.expectedAssignmentRevision = "9"; + payload.expectedIntentRevision = "7"; + payload.assignmentEpoch = 6; + payload.nativeTimerBindingId = "binding-create-23"; + payload.controlPlaneClaimedAt = 200; + payload.specification = specification(); + payload.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint(payload.specification); + payload.localProviderSelection = selection; + + BackendAgentCommandAssignment value; + value.present = true; + value.protocolVersion = "vdr-suite-agent/1"; + value.requestId = "request-create-28"; + value.correlationId = "correlation-create-28"; + value.operationId = "operation-create-28"; + value.jobId = "job-create-28"; + value.attemptId = "attempt-create-28"; + value.claimEpoch = 4; + value.commandId = "command-create-28"; + value.backendId = "default"; + value.agentId = "agent-create-28"; + value.agentInstanceId = "instance-create-28"; + value.backendGeneration = 44; + value.commandType = kBackendAgentNativeTimerCreateCommandType; + value.payloadVersion = kBackendAgentNativeTimerCreatePayloadVersion; + value.payload = backendAgentNativeTimerCreatePayload(payload); + value.verificationPolicy = "readback_required"; + value.assignedAt = 210; + value.deadline = 600; + value.requestFingerprint = backendAgentCommandFingerprint(value); + assert(backendAgentCommandValidAssignment(value)); + return value; +} +} + +int main() +{ + const auto assigned = assignment(); + std::string reason; + BackendAgentNativeTimerCreateCommand command; + assert(backendAgentNativeTimerCreateCommandFromAssignment( + assigned, command, reason)); + + BackendAgentNativeTimerCreateLocalState starting; + assert(backendAgentNativeTimerCreatePrepareLocalStarting( + command, 220, starting, reason)); + + const std::string encoded = + backendAgentNativeTimerCreateCommandStateExtension( + assigned, starting, reason); + assert(!encoded.empty()); + assert(encoded.rfind("cse1.", 0) == 0); + assert(encoded.find('\n') == std::string::npos); + assert(encoded.find('=') == std::string::npos); + + BackendAgentNativeTimerCreateLocalState parsed; + assert(backendAgentNativeTimerCreateParseCommandStateExtension( + encoded, assigned, parsed, reason)); + assert(parsed.phase == BackendAgentNativeTimerCreateLocalPhase::starting); + assert(parsed.command.timerAssignmentId == "timer-assignment-create-19"); + assert(parsed.command.expectedAssignmentRevision == "9"); + assert(parsed.command.expectedIntentRevision == "7"); + assert(parsed.command.assignmentEpoch == 6); + assert(parsed.command.nativeTimerBindingId == "binding-create-23"); + assert(parsed.command.expectedSpecificationFingerprint == + backendAgentNativeTimerCreateSpecificationFingerprint(specification())); + assert(parsed.command.localProviderSelection.providerInstanceEpoch == + "suitebridge-create-epoch-8"); + + BackendAgentCommandStateExtension generic; + assert(backendAgentCommandStateExtensionParse( + encoded, assigned, generic, reason)); + assert(generic.extensionType == + kBackendAgentNativeTimerCreateLocalStateExtensionType); + assert(backendAgentCommandStateExtensionValidateSupported( + generic, assigned, reason)); + + // Cross-command or cross-payload adoption must fail even when the generic + // envelope itself remains syntactically valid. + auto different = assigned; + different.commandId = "command-create-other"; + different.requestFingerprint = backendAgentCommandFingerprint(different); + assert(backendAgentCommandValidAssignment(different)); + assert(!backendAgentNativeTimerCreateParseCommandStateExtension( + encoded, different, parsed, reason)); + + auto changedPayload = assigned; + BackendAgentNativeTimerCreatePayload payload; + assert(backendAgentNativeTimerCreateParsePayload( + changedPayload.payload, payload, reason)); + payload.nativeTimerBindingId = "binding-create-other"; + changedPayload.payload = backendAgentNativeTimerCreatePayload(payload); + changedPayload.requestFingerprint = backendAgentCommandFingerprint(changedPayload); + assert(backendAgentCommandValidAssignment(changedPayload)); + assert(!backendAgentNativeTimerCreateParseCommandStateExtension( + encoded, changedPayload, parsed, reason)); + + // Recovery evidence is embedded only after it has been conservatively + // classified outcome_unknown; parsing the extension can never authorize a + // second native CREATE. + const auto recovery = backendAgentNativeTimerCreateRecoverLocalState( + starting, + assigned.backendId, + assigned.agentId, + assigned.agentInstanceId, + assigned.backendGeneration, + 230); + assert(recovery.decision == + BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly); + assert(recovery.evidence.outcome == + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown); + assert(recovery.evidence.dispatchStartedAt == 220); + auto completed = starting; + assert(backendAgentNativeTimerCreateCompleteLocalState( + completed, recovery.evidence, reason)); + const std::string completedEncoded = + backendAgentNativeTimerCreateCommandStateExtension( + assigned, completed, reason); + assert(!completedEncoded.empty()); + assert(backendAgentNativeTimerCreateParseCommandStateExtension( + completedEncoded, assigned, parsed, reason)); + assert(parsed.phase == BackendAgentNativeTimerCreateLocalPhase::completed); + assert(parsed.evidence.outcome == + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown); + assert(parsed.evidence.evidenceReference == + "local-recovery:command-create-28"); + + std::string tampered = encoded; + const auto lastDot = tampered.rfind('.'); + assert(lastDot != std::string::npos && lastDot + 2 < tampered.size()); + tampered[lastDot + 2] = tampered[lastDot + 2] == '0' ? '1' : '0'; + assert(!backendAgentNativeTimerCreateParseCommandStateExtension( + tampered, assigned, parsed, reason)); + + return 0; +} diff --git a/mk/phase64-command-state-extension-tests.mk b/mk/phase64-command-state-extension-tests.mk index 916f49de0..1e28f1360 100644 --- a/mk/phase64-command-state-extension-tests.mk +++ b/mk/phase64-command-state-extension-tests.mk @@ -8,11 +8,24 @@ test-phase64-command-state-extension: test-phase64-command-state-extension-archi $(AGENT_COMMAND_DOMAIN_SRC) \ core/agent/src/BackendAgentNativeTimerDelete.cpp \ core/agent/src/BackendAgentNativeTimerDeleteLocalState.cpp \ + core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ + core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ core/agent/src/BackendAgentCommandStateExtension.cpp \ core/agent/tests/test_backend_agent_command_state_extension.cpp \ $(LDFLAGS) \ -o $(BUILD_DIR)/test_backend_agent_command_state_extension $(BUILD_DIR)/test_backend_agent_command_state_extension + $(BUILD_CXX) $(CXXFLAGS) \ + $(AGENT_COMMAND_DOMAIN_SRC) \ + core/agent/src/BackendAgentNativeTimerDelete.cpp \ + core/agent/src/BackendAgentNativeTimerDeleteLocalState.cpp \ + core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ + core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ + core/agent/src/BackendAgentCommandStateExtension.cpp \ + core/agent/tests/test_backend_agent_native_timer_create_state_extension.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_backend_agent_native_timer_create_state_extension + $(BUILD_DIR)/test_backend_agent_native_timer_create_state_extension test-fast: test-phase64-command-state-extension test-architecture: test-phase64-command-state-extension-architecture diff --git a/tools/check_phase64_command_state_extension.py b/tools/check_phase64_command_state_extension.py old mode 100644 new mode 100755 index ef150e922..ddb4f14eb --- a/tools/check_phase64_command_state_extension.py +++ b/tools/check_phase64_command_state_extension.py @@ -25,6 +25,7 @@ def forbid(text: str, needle: str, label: str) -> None: header = read("core/agent/include/BackendAgentCommandStateExtension.h") source = read("core/agent/src/BackendAgentCommandStateExtension.cpp") test = read("core/agent/tests/test_backend_agent_command_state_extension.cpp") +create_test = read("core/agent/tests/test_backend_agent_native_timer_create_state_extension.cpp") doc = read("docs/development/phase-64-command-state-extension.md") mk = read("mk/phase64-command-state-extension-tests.mk") agent_sources = read("mk/agent-sources.mk") @@ -44,6 +45,7 @@ def forbid(text: str, needle: str, label: str) -> None: raise SystemExit("Slice 28 Make include must occur exactly once") require(header, "BackendAgentCommandStateExtension", "generic state extension envelope") +require(header, "vdr.timer.create.local-state.v1", "typed Timer-create extension type") require(header, "vdr.timer.delete.local-state.v1", "typed Timer-delete extension type") require(source, "extension.commandId != assignment.commandId", "command identity fence") require( @@ -53,6 +55,26 @@ def forbid(text: str, needle: str, label: str) -> None: ) require(source, "kMaximumExtensionPayloadBytes", "bounded extension payload") require(source, 'std::string encoded = "cse1."', "single-line extension encoding") +require( + source, + "backendAgentNativeTimerCreateSerializeLocalState", + "Phase 64 CREATE local-state embedding", +) +require( + source, + "backendAgentNativeTimerCreateParseLocalState", + "strict Phase 64 CREATE local-state recovery", +) +for create_fence in ( + "left.expectedAssignmentRevision == right.expectedAssignmentRevision", + "left.expectedIntentRevision == right.expectedIntentRevision", + "left.assignmentEpoch == right.assignmentEpoch", + "left.nativeTimerBindingId == right.nativeTimerBindingId", + "left.expectedSpecificationFingerprint == right.expectedSpecificationFingerprint", + "sameCreateSpecification(left.specification, right.specification)", + "sameProviderSelection(left.localProviderSelection, right.localProviderSelection)", +): + require(source, create_fence, "CREATE state-extension assignment fence") require( source, "backendAgentNativeTimerDeleteSerializeLocalState", @@ -65,14 +87,39 @@ def forbid(text: str, needle: str, label: str) -> None: ) require(test, "single-line safe value for commands.state", "commands.state embedding regression") require(test, "different command identity", "cross-command adoption regression") +require( + create_test, + "Cross-command or cross-payload adoption must fail", + "CREATE cross-assignment adoption regression", +) +require( + create_test, + "parsing the extension can never authorize a", + "CREATE no-blind-retry extension regression", +) +require( + create_test, + "BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly", + "CREATE recovered starting reconciliation-only regression", +) require(doc, "Generic extension envelope", "generic extension architecture documentation") require(doc, "No runtime wiring", "non-runtime boundary documentation") require(mk, "test-phase64-command-state-extension", "focused Slice 28 target") +require( + mk, + "test_backend_agent_native_timer_create_state_extension", + "CREATE state-extension focused binary", +) +require( + mk, + "core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp", + "CREATE conservative recovery link", +) # Slice 28 defined the wrapper before runtime wiring. The v3 state-owner -# successor may now link it, and a later bounded refactor may extract that state -# owner from CommandClient, but no Timer-delete execution or advertisement is -# opened by either successor. +# successor may now link it, and later Phase-64 CREATE work may add another +# typed payload, but this layer remains persistence/fencing only: it never +# advertises or executes native mutation. require( agent_sources, "core/agent/src/BackendAgentCommandStateExtension.cpp", @@ -83,9 +130,10 @@ def forbid(text: str, needle: str, label: str) -> None: '#include "BackendAgentCommandStateExtension.h"', "generic state owner extension integration", ) -forbid(command_client, "vdr.timer.delete", "Timer-delete command-client execution") -forbid(agent_client, "vdr.timer.delete", "Timer-delete Agent advertisement") +forbid(command_client, '"vdr.timer.delete"', "Timer-delete command-client literal execution") +forbid(agent_client, '"vdr.timer.delete"', "Timer-delete Agent literal advertisement") forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete advertisement") +forbid(packaged_config, "vdr.timer.create", "packaged Timer-create advertisement") for token in ( "SuiteBridgeSvdrp", @@ -96,6 +144,8 @@ def forbid(text: str, needle: str, label: str) -> None: "system(", "popen(", "/timers", + "createTimer(", + "deleteTimer(", ): forbid(source, token, "native mutation/transport coupling in state extension") From 2bcc8eb53de2c1f891be7145ae6b5e710ae843c0 Mon Sep 17 00:00:00 2001 From: Holger Schvestka Date: Sun, 16 Aug 2026 18:39:36 +0200 Subject: [PATCH 40/85] Fix Phase 64 timer fulfillment CI wiring --- mk/agent-sources.mk | 1 + mk/phase64-native-timer-create-activation-tests.mk | 4 ++-- mk/phase64-native-timer-create-dispatch-state-tests.mk | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mk/agent-sources.mk b/mk/agent-sources.mk index 81cf44e0e..89fdb590a 100644 --- a/mk/agent-sources.mk +++ b/mk/agent-sources.mk @@ -45,6 +45,7 @@ AGENT_COMMAND_DELIVERY_SRC := \ core/agent/src/BackendAgentNativeProbeDelivery.cpp AGENT_COMMAND_STATE_SRC := \ + core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ core/agent/src/BackendAgentNativeTimerDelete.cpp \ core/agent/src/BackendAgentNativeTimerDeleteLocalState.cpp \ core/agent/src/BackendAgentCommandStateExtension.cpp \ diff --git a/mk/phase64-native-timer-create-activation-tests.mk b/mk/phase64-native-timer-create-activation-tests.mk index ad9ca0471..69ecb7a79 100644 --- a/mk/phase64-native-timer-create-activation-tests.mk +++ b/mk/phase64-native-timer-create-activation-tests.mk @@ -4,7 +4,7 @@ test-phase64-native-timer-create-activation-architecture: python3 tools/check_phase64_native_timer_create_activation.py test-phase64-native-timer-create-activation: test-phase64-native-timer-create-activation-architecture - $(BUILD_CXX) $(CXXFLAGS) \ + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/operations/include -Icore/timers/include \ $(SQLITE_SRC) \ core/security/src/AccountabilityEventRepository.cpp \ core/security/src/CredentialVerifierRepository.cpp \ @@ -15,7 +15,7 @@ test-phase64-native-timer-create-activation: test-phase64-native-timer-create-ac core/vdr/src/BackendRegistryService.cpp \ core/operations/src/MutationOperation.cpp \ core/operations/src/MutationOperationRepository.cpp \ - core/timers/src/NativeTimerObservedState.cpp \ + core/timers/src/NativeTimerBinding.cpp \ core/timers/src/NativeTimerSpecification.cpp \ core/timers/src/NativeTimerCreateOperationPayload.cpp \ core/timers/src/NativeTimerCreateDispatchService.cpp \ diff --git a/mk/phase64-native-timer-create-dispatch-state-tests.mk b/mk/phase64-native-timer-create-dispatch-state-tests.mk index 90f28122c..927769aee 100644 --- a/mk/phase64-native-timer-create-dispatch-state-tests.mk +++ b/mk/phase64-native-timer-create-dispatch-state-tests.mk @@ -4,11 +4,11 @@ test-phase64-native-timer-create-dispatch-state-architecture: python3 tools/check_phase64_native_timer_create_dispatch_state.py test-phase64-native-timer-create-dispatch-state: test-phase64-native-timer-create-dispatch-state-architecture - $(BUILD_CXX) $(CXXFLAGS) \ + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/operations/include -Icore/timers/include \ $(SQLITE_SRC) \ core/operations/src/MutationOperation.cpp \ core/operations/src/MutationOperationRepository.cpp \ - core/timers/src/NativeTimerObservedState.cpp \ + core/timers/src/NativeTimerBinding.cpp \ core/timers/src/NativeTimerSpecification.cpp \ core/timers/src/NativeTimerCreateOperationPayload.cpp \ core/timers/src/NativeTimerCreateDispatchService.cpp \ From 055973ea7dcf079787dbe235611f34d442ce1997 Mon Sep 17 00:00:00 2001 From: Holger Schvestka Date: Sun, 16 Aug 2026 20:59:03 +0200 Subject: [PATCH 41/85] Add fenced native Timer CREATE executor path --- Makefile | 1 + .../agent/include/BackendAgentCommandClient.h | 2 + ...kendAgentNativeTimerCreateCommandHandler.h | 39 ++ .../BackendAgentNativeTimerCreateExecutor.h | 61 ++ core/agent/src/BackendAgentCommandClient.cpp | 99 +++ ...ndAgentNativeTimerCreateCommandHandler.cpp | 363 +++++++++++ .../BackendAgentNativeTimerCreateExecutor.cpp | 339 ++++++++++ ...end_agent_native_timer_create_executor.cpp | 612 ++++++++++++++++++ mk/agent-sources.mk | 9 + ...se64-native-timer-create-executor-tests.mk | 20 + ...phase63_fenced_native_operation_runtime.py | 58 +- ...ck_phase64_native_timer_create_executor.py | 103 +++ 12 files changed, 1693 insertions(+), 13 deletions(-) create mode 100644 core/agent/include/BackendAgentNativeTimerCreateCommandHandler.h create mode 100644 core/agent/include/BackendAgentNativeTimerCreateExecutor.h create mode 100644 core/agent/src/BackendAgentNativeTimerCreateCommandHandler.cpp create mode 100644 core/agent/src/BackendAgentNativeTimerCreateExecutor.cpp create mode 100644 core/agent/tests/test_backend_agent_native_timer_create_executor.cpp create mode 100644 mk/phase64-native-timer-create-executor-tests.mk create mode 100644 tools/check_phase64_native_timer_create_executor.py diff --git a/Makefile b/Makefile index 3f1aa67e8..895721982 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ include mk/phase64-native-timer-create-dispatch-state-tests.mk include mk/phase64-native-timer-create-delivery-tests.mk include mk/phase64-native-timer-create-activation-tests.mk include mk/phase64-native-timer-create-local-state-tests.mk +include mk/phase64-native-timer-create-executor-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk diff --git a/core/agent/include/BackendAgentCommandClient.h b/core/agent/include/BackendAgentCommandClient.h index 130543eab..35aa149f0 100644 --- a/core/agent/include/BackendAgentCommandClient.h +++ b/core/agent/include/BackendAgentCommandClient.h @@ -11,6 +11,7 @@ namespace vdrsuite::agent { class IBackendAgentNativeProbeTransport; class IBackendAgentNativeTimerDeleteTransport; +class IBackendAgentNativeTimerCreateTransport; } struct BackendAgentCommandClientConfig @@ -19,6 +20,7 @@ struct BackendAgentCommandClientConfig std::vector commandTypes; vdrsuite::agent::IBackendAgentNativeProbeTransport* nativeProbeTransport = nullptr; vdrsuite::agent::IBackendAgentNativeTimerDeleteTransport* nativeTimerDeleteTransport = nullptr; + vdrsuite::agent::IBackendAgentNativeTimerCreateTransport* nativeTimerCreateTransport = nullptr; }; struct BackendAgentCommandClientContext diff --git a/core/agent/include/BackendAgentNativeTimerCreateCommandHandler.h b/core/agent/include/BackendAgentNativeTimerCreateCommandHandler.h new file mode 100644 index 000000000..1a5be6fc8 --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerCreateCommandHandler.h @@ -0,0 +1,39 @@ +#pragma once + +#include "BackendAgentCommandStateStore.h" +#include "BackendAgentNativeTimerCreateExecutor.h" + +#include +#include + +namespace vdrsuite::agent +{ + +struct BackendAgentNativeTimerCreateCommandContext +{ + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; +}; + +bool backendAgentNativeTimerCreateCommandReconcileExisting( + const std::string& statePath, + const BackendAgentNativeTimerCreateCommandContext& context, + commandstate::LocalState& state, + std::string& reasonCode); + +bool backendAgentNativeTimerCreateCommandPrepareFreshStarting( + const std::string& statePath, + commandstate::LocalState& state, + std::int64_t currentTime, + std::string& reasonCode); + +bool backendAgentNativeTimerCreateCommandExecuteFreshStartingAndPersistOutcome( + const std::string& statePath, + const BackendAgentNativeTimerCreateCommandContext& context, + IBackendAgentNativeTimerCreateTransport* transport, + commandstate::LocalState& state, + std::string& reasonCode); + +} // namespace vdrsuite::agent diff --git a/core/agent/include/BackendAgentNativeTimerCreateExecutor.h b/core/agent/include/BackendAgentNativeTimerCreateExecutor.h new file mode 100644 index 000000000..553f5de78 --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerCreateExecutor.h @@ -0,0 +1,61 @@ +#pragma once + +#include "BackendAgentNativeTimerCreateLocalState.h" + +#include +#include + +namespace vdrsuite::agent +{ + +enum class BackendAgentNativeTimerCreateTransportDisposition +{ + rejectedWithoutEffect, + acceptedUnverified, + outcomeUnknown, +}; + +struct BackendAgentNativeTimerCreateTransportRequest +{ + BackendAgentNativeTimerCreateCommand command; + std::int64_t localStartingPersistedAt = 0; +}; + +struct BackendAgentNativeTimerCreateTransportReply +{ + BackendAgentNativeTimerCreateTransportDisposition disposition = + BackendAgentNativeTimerCreateTransportDisposition::outcomeUnknown; + std::string evidenceReference; +}; + +class IBackendAgentNativeTimerCreateTransport +{ +public: + virtual ~IBackendAgentNativeTimerCreateTransport() = default; + + virtual bool discoverProvider( + BackendAgentLocalProviderFacts& facts, + std::string& reasonCode) = 0; + + virtual BackendAgentNativeTimerCreateTransportReply createTimer( + const BackendAgentNativeTimerCreateTransportRequest& request) = 0; +}; + +struct BackendAgentNativeTimerCreateExecutorContext +{ + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; + std::int64_t now = 0; +}; + +bool backendAgentNativeTimerCreateExecuteFreshStartingOnce( + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerCreateLocalState& localState, + const BackendAgentNativeTimerCreateExecutorContext& context, + IBackendAgentNativeTimerCreateTransport& transport, + BackendAgentNativeTimerCreateEvidence& evidence, + std::string& reasonCode); + +} // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentCommandClient.cpp b/core/agent/src/BackendAgentCommandClient.cpp index ebda2e508..5abba9cf8 100644 --- a/core/agent/src/BackendAgentCommandClient.cpp +++ b/core/agent/src/BackendAgentCommandClient.cpp @@ -5,6 +5,7 @@ #include "BackendAgentCommandStateStore.h" #include "BackendAgentNativeProbeCommandHandler.h" #include "BackendAgentNativeTimerCreate.h" +#include "BackendAgentNativeTimerCreateCommandHandler.h" #include "BackendAgentNativeTimerDeleteCommandHandler.h" #include @@ -112,6 +113,57 @@ void createResult( result.completedAt = nowSeconds(); } +vdrsuite::agent::BackendAgentNativeTimerCreateCommandContext +nativeTimerCreateContext(const BackendAgentCommandClientContext& context) +{ + return { + context.backendId, + context.agentId, + context.agentInstanceId, + context.backendGeneration}; +} + +bool reconcileNativeTimerCreateLocalState( + const BackendAgentCommandClientConfig& config, + const BackendAgentCommandClientContext& context, + LocalState& state, + std::string& reason) +{ + return vdrsuite::agent::backendAgentNativeTimerCreateCommandReconcileExisting( + config.statePath, + nativeTimerCreateContext(context), + state, + reason); +} + +bool prepareFreshNativeTimerCreateLocalStarting( + const BackendAgentCommandClientConfig& config, + LocalState& state, + std::int64_t currentTime, + std::string& reason) +{ + return vdrsuite::agent::backendAgentNativeTimerCreateCommandPrepareFreshStarting( + config.statePath, + state, + currentTime, + reason); +} + +bool executeFreshNativeTimerCreateAndPersistOutcome( + const BackendAgentCommandClientConfig& config, + const BackendAgentCommandClientContext& context, + LocalState& state, + std::string& reason) +{ + return vdrsuite::agent:: + backendAgentNativeTimerCreateCommandExecuteFreshStartingAndPersistOutcome( + config.statePath, + nativeTimerCreateContext(context), + config.nativeTimerCreateTransport, + state, + reason); +} + vdrsuite::agent::BackendAgentNativeTimerDeleteCommandContext nativeTimerDeleteContext(const BackendAgentCommandClientContext& context) { @@ -216,8 +268,13 @@ bool reconcileBackendAgentCommandState( } return false; } + const bool timerCreateCommand = state.assignment.commandType == + vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType; const bool timerDeleteCommand = state.assignment.commandType == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType; + if (timerCreateCommand && state.stateExtensionPresent && + !reconcileNativeTimerCreateLocalState(config, context, state, reason)) + return false; if (timerDeleteCommand && state.stateExtensionPresent && !reconcileNativeTimerDeleteLocalState(config, context, state, reason)) return false; @@ -230,6 +287,48 @@ bool reconcileBackendAgentCommandState( return false; } + if (timerCreateCommand && !state.stateExtensionPresent && !state.resultPresent) + { + if (state.dispatchState != "not_started") + { + reason = "native_create_fresh_starting_state_invalid"; + return false; + } + const std::int64_t currentTime = nowSeconds(); + if (state.assignment.deadline <= currentTime) + { + if (!state.receiptAcknowledged && + !sendReceipt(config, context, transport, state, reason)) + return false; + createResult( + state, "not_started", "outcome_unknown", "rejected", + "expired", "none", + "command deadline expired before native dispatch"); + if (!persist(config.statePath, state, reason)) return false; + if (!sendResult(config, context, transport, state, reason)) return false; + reason = "command_result_reconciled"; + return true; + } + if (!prepareFreshNativeTimerCreateLocalStarting( + config, state, currentTime, reason)) + return false; + if (!state.receiptAcknowledged && + !sendReceipt(config, context, transport, state, reason)) + return false; + if (config.nativeTimerCreateTransport == nullptr) + { + reason = "native_create_local_starting_handoff_persisted"; + return true; + } + if (!executeFreshNativeTimerCreateAndPersistOutcome( + config, context, state, reason)) + return false; + if (!sendResult(config, context, transport, state, reason)) + return false; + reason = "native_create_executor_outcome_reconciled"; + return true; + } + if (timerDeleteCommand && !state.stateExtensionPresent && !state.resultPresent) { if (state.dispatchState != "not_started") diff --git a/core/agent/src/BackendAgentNativeTimerCreateCommandHandler.cpp b/core/agent/src/BackendAgentNativeTimerCreateCommandHandler.cpp new file mode 100644 index 000000000..d77f08e54 --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerCreateCommandHandler.cpp @@ -0,0 +1,363 @@ +#include "BackendAgentNativeTimerCreateCommandHandler.h" + +#include "BackendAgentCommandStateExtension.h" +#include "BackendAgentNativeTimerCreateLocalState.h" + +#include +#include + +namespace +{ +using vdrsuite::agent::commandstate::LocalState; +using vdrsuite::agent::commandstate::persist; + +std::int64_t nowSeconds() +{ + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +struct NativeTimerCreateGenericProjection +{ + std::string dispatchState; + std::string verificationState; + std::string resultCategory; + std::string errorCategory; + std::string retryClassification; + std::string diagnostics; +}; + +bool nativeTimerCreateGenericProjection( + vdrsuite::agent::BackendAgentNativeTimerCreateOutcomeCategory outcome, + NativeTimerCreateGenericProjection& projection) +{ + using namespace vdrsuite::agent; + + switch (outcome) + { + case BackendAgentNativeTimerCreateOutcomeCategory::rejectedWithoutEffect: + projection = { + "not_started", + "verified", + "rejected", + "fenced", + "none", + "native Timer create rejected without effect"}; + return true; + + case BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified: + projection = { + "accepted_by_executor", + "outcome_unknown", + "outcome_unknown", + "none", + "reconcile_only", + "native Timer create accepted; readback reconciliation required"}; + return true; + + case BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown: + projection = { + "starting", + "outcome_unknown", + "outcome_unknown", + "executor_unknown", + "reconcile_only", + "native Timer create outcome unknown; reconciliation required"}; + return true; + } + + return false; +} + +void createTimerCreateResult( + LocalState& state, + const NativeTimerCreateGenericProjection& projection, + std::int64_t completedAt) +{ + state.dispatchState = projection.dispatchState; + state.resultPresent = true; + state.resultAcknowledged = false; + + auto& result = state.result; + const auto& assignment = state.assignment; + + result.commandId = assignment.commandId; + result.requestFingerprint = assignment.requestFingerprint; + result.jobId = assignment.jobId; + result.attemptId = assignment.attemptId; + result.claimEpoch = assignment.claimEpoch; + result.backendId = assignment.backendId; + result.agentId = assignment.agentId; + result.agentInstanceId = assignment.agentInstanceId; + result.backendGeneration = assignment.backendGeneration; + result.dispatchState = projection.dispatchState; + result.verificationState = projection.verificationState; + result.resultCategory = projection.resultCategory; + result.errorCategory = projection.errorCategory; + result.retryClassification = projection.retryClassification; + result.boundedDiagnostics = projection.diagnostics; + result.completedAt = completedAt; +} + +bool genericResultMatchesTimerCreateEvidence( + const LocalState& state, + const NativeTimerCreateGenericProjection& projection, + const vdrsuite::agent::BackendAgentNativeTimerCreateEvidence& evidence) +{ + if (!state.resultPresent) + return false; + + const auto& result = state.result; + + return state.dispatchState == projection.dispatchState && + result.dispatchState == projection.dispatchState && + result.verificationState == projection.verificationState && + result.resultCategory == projection.resultCategory && + result.errorCategory == projection.errorCategory && + result.retryClassification == projection.retryClassification && + result.boundedDiagnostics == projection.diagnostics && + result.completedAt == evidence.completedAt; +} + +} // namespace + +namespace vdrsuite::agent +{ + +bool backendAgentNativeTimerCreateCommandReconcileExisting( + const std::string& statePath, + const BackendAgentNativeTimerCreateCommandContext& context, + commandstate::LocalState& state, + std::string& reason) +{ + if (!state.stateExtensionPresent || + state.stateExtension.extensionType != + kBackendAgentNativeTimerCreateLocalStateExtensionType) + { + reason = "native_create_local_state_required"; + return false; + } + + BackendAgentNativeTimerCreateLocalState localState; + + if (!backendAgentNativeTimerCreateParseLocalState( + state.stateExtension.payload, localState, reason)) + { + reason = "native_create_local_state_invalid"; + return false; + } + + const auto recovery = backendAgentNativeTimerCreateRecoverLocalState( + localState, + context.backendId, + context.agentId, + context.agentInstanceId, + context.backendGeneration, + nowSeconds()); + + if (recovery.decision == + BackendAgentNativeTimerCreateRecoveryDecision::failClosed) + { + reason = recovery.reasonCode.empty() + ? "native_create_recovery_failed" + : recovery.reasonCode; + return false; + } + + bool stateChanged = false; + + if (recovery.decision == + BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly) + { + if (!backendAgentNativeTimerCreateCompleteLocalState( + localState, recovery.evidence, reason)) + return false; + + const std::string payload = + backendAgentNativeTimerCreateSerializeLocalState( + localState, reason); + + if (payload.empty()) + return false; + + state.stateExtension.payload = payload; + stateChanged = true; + } + else if (recovery.decision != + BackendAgentNativeTimerCreateRecoveryDecision::returnPersistedEvidence) + { + reason = "native_create_recovery_decision_invalid"; + return false; + } + + NativeTimerCreateGenericProjection projection; + + if (!nativeTimerCreateGenericProjection( + recovery.evidence.outcome, projection)) + { + reason = "native_create_outcome_projection_invalid"; + return false; + } + + if (state.resultPresent) + { + if (!genericResultMatchesTimerCreateEvidence( + state, projection, recovery.evidence)) + { + reason = "native_create_result_evidence_conflict"; + return false; + } + } + else + { + createTimerCreateResult( + state, projection, recovery.evidence.completedAt); + stateChanged = true; + } + + if (stateChanged) + return persist(statePath, state, reason); + + reason = "native_create_local_state_reconciled"; + return true; +} + +bool backendAgentNativeTimerCreateCommandPrepareFreshStarting( + const std::string& statePath, + commandstate::LocalState& state, + std::int64_t currentTime, + std::string& reason) +{ + if (state.stateExtensionPresent || + state.resultPresent || + state.dispatchState != "not_started" || + currentTime <= 0 || + currentTime > state.assignment.deadline) + { + reason = "native_create_fresh_starting_state_invalid"; + return false; + } + + BackendAgentNativeTimerCreateCommand command; + + if (!backendAgentNativeTimerCreateCommandFromAssignment( + state.assignment, command, reason)) + { + reason = "native_create_fresh_assignment_invalid"; + return false; + } + + BackendAgentNativeTimerCreateLocalState localState; + + if (!backendAgentNativeTimerCreatePrepareLocalStarting( + command, currentTime, localState, reason)) + return false; + + BackendAgentCommandStateExtension extension; + extension.extensionType = + kBackendAgentNativeTimerCreateLocalStateExtensionType; + extension.commandId = state.assignment.commandId; + extension.requestFingerprint = state.assignment.requestFingerprint; + extension.payload = + backendAgentNativeTimerCreateSerializeLocalState( + localState, reason); + + if (extension.payload.empty() || + !backendAgentCommandStateExtensionValidateSupported( + extension, state.assignment, reason)) + { + reason = "native_create_fresh_starting_extension_invalid"; + return false; + } + + state.stateExtensionPresent = true; + state.stateExtension = extension; + state.dispatchState = "starting"; + + if (!persist(statePath, state, reason)) + return false; + + reason = "native_create_local_starting_persisted"; + return true; +} + +bool backendAgentNativeTimerCreateCommandExecuteFreshStartingAndPersistOutcome( + const std::string& statePath, + const BackendAgentNativeTimerCreateCommandContext& context, + IBackendAgentNativeTimerCreateTransport* transport, + commandstate::LocalState& state, + std::string& reason) +{ + if (transport == nullptr || + !state.stateExtensionPresent || + state.resultPresent || + state.dispatchState != "starting" || + !state.receiptAcknowledged || + state.stateExtension.extensionType != + kBackendAgentNativeTimerCreateLocalStateExtensionType) + { + reason = "native_create_executor_handoff_state_invalid"; + return false; + } + + BackendAgentNativeTimerCreateLocalState localState; + + if (!backendAgentNativeTimerCreateParseLocalState( + state.stateExtension.payload, localState, reason) || + localState.phase != BackendAgentNativeTimerCreateLocalPhase::starting) + { + reason = "native_create_executor_starting_state_invalid"; + return false; + } + + BackendAgentNativeTimerCreateExecutorContext executorContext; + executorContext.backendId = context.backendId; + executorContext.agentId = context.agentId; + executorContext.agentInstanceId = context.agentInstanceId; + executorContext.backendGeneration = context.backendGeneration; + executorContext.now = nowSeconds(); + + BackendAgentNativeTimerCreateEvidence evidence; + + if (!backendAgentNativeTimerCreateExecuteFreshStartingOnce( + state.assignment, + localState, + executorContext, + *transport, + evidence, + reason)) + return false; + + if (!backendAgentNativeTimerCreateCompleteLocalState( + localState, evidence, reason)) + return false; + + const std::string payload = + backendAgentNativeTimerCreateSerializeLocalState( + localState, reason); + + if (payload.empty()) + return false; + + state.stateExtension.payload = payload; + + NativeTimerCreateGenericProjection projection; + + if (!nativeTimerCreateGenericProjection( + evidence.outcome, projection)) + { + reason = "native_create_executor_outcome_projection_invalid"; + return false; + } + + createTimerCreateResult( + state, projection, evidence.completedAt); + + if (!persist(statePath, state, reason)) + return false; + + reason = "native_create_executor_outcome_persisted"; + return true; +} + +} // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentNativeTimerCreateExecutor.cpp b/core/agent/src/BackendAgentNativeTimerCreateExecutor.cpp new file mode 100644 index 000000000..c4fbda0bc --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerCreateExecutor.cpp @@ -0,0 +1,339 @@ +#include "BackendAgentNativeTimerCreateExecutor.h" + +#include +#include + +namespace vdrsuite::agent +{ +namespace +{ + +bool sameSpecification( + const BackendAgentNativeTimerCreateSpecification& left, + const BackendAgentNativeTimerCreateSpecification& right) +{ + return left.channelId == right.channelId && + left.title == right.title && + left.directory == right.directory && + left.day == right.day && + left.weekdays == right.weekdays && + left.startTime == right.startTime && + left.endTime == right.endTime && + left.priority == right.priority && + left.lifetime == right.lifetime && + left.enabled == right.enabled && + left.vps == right.vps; +} + +bool sameCommand( + const BackendAgentNativeTimerCreateCommand& left, + const BackendAgentNativeTimerCreateCommand& right) +{ + return left.commandId == right.commandId && + left.requestFingerprint == right.requestFingerprint && + left.operationId == right.operationId && + left.operationRevision == right.operationRevision && + left.timerAssignmentId == right.timerAssignmentId && + left.expectedAssignmentRevision == right.expectedAssignmentRevision && + left.expectedIntentRevision == right.expectedIntentRevision && + left.assignmentEpoch == right.assignmentEpoch && + left.nativeTimerBindingId == right.nativeTimerBindingId && + left.expectedSpecificationFingerprint == + right.expectedSpecificationFingerprint && + left.jobId == right.jobId && + left.attemptId == right.attemptId && + left.claimEpoch == right.claimEpoch && + left.backendId == right.backendId && + left.agentId == right.agentId && + left.agentInstanceId == right.agentInstanceId && + left.backendGeneration == right.backendGeneration && + left.controlPlaneClaimedAt == right.controlPlaneClaimedAt && + sameSpecification(left.specification, right.specification) && + backendAgentLocalProviderSameFence( + left.localProviderSelection, right.localProviderSelection); +} + +bool sameContext( + const BackendAgentNativeTimerCreateCommand& command, + const BackendAgentNativeTimerCreateExecutorContext& context) +{ + return command.backendId == context.backendId && + command.agentId == context.agentId && + command.agentInstanceId == context.agentInstanceId && + command.backendGeneration == context.backendGeneration; +} + +bool selectedProviderStillCurrent( + const BackendAgentLocalProviderSelection& selection, + const BackendAgentLocalProviderFacts& facts) +{ + return backendAgentLocalProviderValidSelection(selection) && + backendAgentLocalProviderValidFacts(facts) && + facts.available && + selection.providerId == facts.providerId && + selection.providerKind == facts.providerKind && + selection.providerInstanceEpoch == facts.providerInstanceEpoch && + selection.providerGeneration == facts.providerGeneration && + selection.capabilityRevision == facts.capabilityRevision && + std::find( + facts.capabilities.begin(), + facts.capabilities.end(), + selection.requiredCapability) != facts.capabilities.end(); +} + +BackendAgentNativeTimerCreateEvidence evidenceFor( + const BackendAgentNativeTimerCreateCommand& command, + std::int64_t localStartingPersistedAt, + BackendAgentNativeTimerCreateOutcomeCategory outcome, + std::int64_t dispatchStartedAt, + std::int64_t completedAt, + std::string evidenceReference) +{ + BackendAgentNativeTimerCreateEvidence value; + value.commandId = command.commandId; + value.requestFingerprint = command.requestFingerprint; + value.operationId = command.operationId; + value.operationRevision = command.operationRevision; + value.timerAssignmentId = command.timerAssignmentId; + value.nativeTimerBindingId = command.nativeTimerBindingId; + value.jobId = command.jobId; + value.attemptId = command.attemptId; + value.claimEpoch = command.claimEpoch; + value.backendId = command.backendId; + value.agentId = command.agentId; + value.agentInstanceId = command.agentInstanceId; + value.backendGeneration = command.backendGeneration; + value.providerInstanceEpoch = + command.localProviderSelection.providerInstanceEpoch; + value.localStartingPersistedAt = localStartingPersistedAt; + value.outcome = outcome; + value.dispatchStartedAt = dispatchStartedAt; + value.completedAt = completedAt; + value.evidenceReference = std::move(evidenceReference); + return value; +} + +bool finish( + const BackendAgentNativeTimerCreateLocalState& localState, + BackendAgentNativeTimerCreateOutcomeCategory outcome, + std::int64_t dispatchStartedAt, + std::int64_t completedAt, + const std::string& reference, + const std::string& reason, + BackendAgentNativeTimerCreateEvidence& evidence, + std::string& reasonCode) +{ + BackendAgentNativeTimerCreateEvidence candidate = evidenceFor( + localState.command, + localState.localStartingPersistedAt, + outcome, + dispatchStartedAt, + completedAt, + reference); + + std::string validationReason; + if (!backendAgentNativeTimerCreateEvidenceMatches( + candidate, localState.command, validationReason)) + { + reasonCode = "native_timer_create_executor_evidence_invalid"; + return false; + } + + evidence = std::move(candidate); + reasonCode = reason; + return true; +} + +bool rejectWithoutEffect( + const BackendAgentNativeTimerCreateLocalState& localState, + std::int64_t now, + const std::string& reference, + BackendAgentNativeTimerCreateEvidence& evidence, + std::string& reasonCode) +{ + return finish( + localState, + BackendAgentNativeTimerCreateOutcomeCategory::rejectedWithoutEffect, + 0, + std::max(now, localState.localStartingPersistedAt), + reference, + "native_timer_create_executor_rejected_without_effect", + evidence, + reasonCode); +} + +bool safeEvidenceReference(const std::string& value) +{ + return !value.empty() && ::backendAgentCommandSafeText(value, 512); +} + +} // namespace + +bool backendAgentNativeTimerCreateExecuteFreshStartingOnce( + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerCreateLocalState& localState, + const BackendAgentNativeTimerCreateExecutorContext& context, + IBackendAgentNativeTimerCreateTransport& transport, + BackendAgentNativeTimerCreateEvidence& evidence, + std::string& reasonCode) +{ + evidence = {}; + + std::string validationReason; + BackendAgentNativeTimerCreateCommand expectedCommand; + + if (!backendAgentNativeTimerCreateLocalStateValid( + localState, validationReason) || + localState.phase != BackendAgentNativeTimerCreateLocalPhase::starting || + !backendAgentNativeTimerCreateCommandFromAssignment( + assignment, expectedCommand, validationReason) || + !sameCommand(expectedCommand, localState.command) || + context.now <= 0 || + context.now < localState.localStartingPersistedAt) + { + reasonCode = "native_timer_create_executor_fresh_state_invalid"; + return false; + } + + if (!sameContext(localState.command, context)) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:agent-generation", + evidence, + reasonCode); + } + + if (assignment.deadline <= context.now) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:deadline", + evidence, + reasonCode); + } + + BackendAgentLocalProviderFacts facts; + std::string discoveryReason; + + try + { + if (!transport.discoverProvider(facts, discoveryReason)) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:provider-discovery", + evidence, + reasonCode); + } + } + catch (...) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:provider-discovery-exception", + evidence, + reasonCode); + } + + if (!selectedProviderStillCurrent( + localState.command.localProviderSelection, facts)) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:provider-changed", + evidence, + reasonCode); + } + + BackendAgentNativeTimerCreateTransportRequest request; + request.command = localState.command; + request.localStartingPersistedAt = localState.localStartingPersistedAt; + + const std::int64_t dispatchStartedAt = context.now; + + BackendAgentNativeTimerCreateTransportReply reply; + try + { + reply = transport.createTimer(request); + } + catch (...) + { + return finish( + localState, + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown, + dispatchStartedAt, + context.now, + "executor:dispatch-exception", + "native_timer_create_executor_outcome_unknown", + evidence, + reasonCode); + } + + switch (reply.disposition) + { + case BackendAgentNativeTimerCreateTransportDisposition::rejectedWithoutEffect: + return rejectWithoutEffect( + localState, + context.now, + safeEvidenceReference(reply.evidenceReference) + ? reply.evidenceReference + : "executor:transport-rejected", + evidence, + reasonCode); + + case BackendAgentNativeTimerCreateTransportDisposition::acceptedUnverified: + if (!safeEvidenceReference(reply.evidenceReference)) + { + return finish( + localState, + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown, + dispatchStartedAt, + context.now, + "executor:accepted-evidence-invalid", + "native_timer_create_executor_outcome_unknown", + evidence, + reasonCode); + } + + return finish( + localState, + BackendAgentNativeTimerCreateOutcomeCategory::acceptedUnverified, + dispatchStartedAt, + context.now, + reply.evidenceReference, + "native_timer_create_executor_accepted_unverified", + evidence, + reasonCode); + + case BackendAgentNativeTimerCreateTransportDisposition::outcomeUnknown: + return finish( + localState, + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown, + dispatchStartedAt, + context.now, + safeEvidenceReference(reply.evidenceReference) + ? reply.evidenceReference + : "executor:transport-outcome-unknown", + "native_timer_create_executor_outcome_unknown", + evidence, + reasonCode); + } + + return finish( + localState, + BackendAgentNativeTimerCreateOutcomeCategory::outcomeUnknown, + dispatchStartedAt, + context.now, + "executor:transport-disposition-invalid", + "native_timer_create_executor_outcome_unknown", + evidence, + reasonCode); +} + +} // namespace vdrsuite::agent diff --git a/core/agent/tests/test_backend_agent_native_timer_create_executor.cpp b/core/agent/tests/test_backend_agent_native_timer_create_executor.cpp new file mode 100644 index 000000000..1c934e2fa --- /dev/null +++ b/core/agent/tests/test_backend_agent_native_timer_create_executor.cpp @@ -0,0 +1,612 @@ +#include "BackendAgentCommand.h" +#include "BackendAgentCommandStateStore.h" +#include "BackendAgentNativeTimerCreateCommandHandler.h" +#include "BackendAgentNativeTimerCreateExecutor.h" +#include "BackendAgentNativeTimerCreatePayload.h" + +#include +#include +#include +#include +#include + +using namespace vdrsuite::agent; + +namespace +{ + +std::int64_t now() +{ + return static_cast(std::time(nullptr)); +} + +BackendAgentNativeTimerCreateSpecification specification() +{ + BackendAgentNativeTimerCreateSpecification value; + value.channelId = "C-1-2-3"; + value.title = "Phase 64 CREATE executor"; + value.directory = "Tests"; + value.day = "2026-08-17"; + value.weekdays = "-------"; + value.startTime = "1930"; + value.endTime = "2030"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +BackendAgentCommandAssignment assignment( + const std::string& suffix, + std::int64_t current) +{ + BackendAgentLocalProviderSelection selection; + selection.backendId = "default"; + selection.authorityDomain = + kBackendAgentNativeTimerCreateAuthorityDomain; + selection.providerId = + kBackendAgentNativeTimerCreateProviderId; + selection.providerKind = + kBackendAgentNativeTimerCreateProviderKind; + selection.ownershipGeneration = 41; + selection.providerInstanceEpoch = + "suitebridge-create-executor-" + suffix; + selection.providerGeneration = 13; + selection.capabilityRevision = 6; + selection.requiredCapability = + kBackendAgentNativeTimerCreateCapability; + + BackendAgentNativeTimerCreatePayload payload; + payload.operationRevision = "2"; + payload.timerAssignmentId = "timer-assignment-" + suffix; + payload.expectedAssignmentRevision = "4"; + payload.expectedIntentRevision = "9"; + payload.assignmentEpoch = 3; + payload.nativeTimerBindingId = "binding-" + suffix; + payload.controlPlaneClaimedAt = current - 20; + payload.specification = specification(); + payload.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint( + payload.specification); + payload.localProviderSelection = selection; + + BackendAgentCommandAssignment value; + value.present = true; + value.protocolVersion = "vdr-suite-agent/1"; + value.requestId = "request-" + suffix; + value.correlationId = "correlation-" + suffix; + value.operationId = "operation-" + suffix; + value.jobId = "job-" + suffix; + value.attemptId = "attempt-" + suffix; + value.claimEpoch = 5; + value.commandId = "command-" + suffix; + value.backendId = "default"; + value.agentId = "agent-" + suffix; + value.agentInstanceId = "instance-" + suffix; + value.backendGeneration = 61; + value.commandType = + kBackendAgentNativeTimerCreateCommandType; + value.payloadVersion = + kBackendAgentNativeTimerCreatePayloadVersion; + value.payload = + backendAgentNativeTimerCreatePayload(payload); + value.verificationPolicy = "readback_required"; + value.assignedAt = current - 10; + value.deadline = current + 600; + value.requestFingerprint = + backendAgentCommandFingerprint(value); + + assert(backendAgentCommandValidAssignment(value)); + return value; +} + +BackendAgentNativeTimerCreateLocalState starting( + const BackendAgentCommandAssignment& value, + std::int64_t current) +{ + BackendAgentNativeTimerCreateCommand command; + std::string reason; + + assert(backendAgentNativeTimerCreateCommandFromAssignment( + value, command, reason)); + + BackendAgentNativeTimerCreateLocalState state; + + assert(backendAgentNativeTimerCreatePrepareLocalStarting( + command, current, state, reason)); + + assert( + state.phase == + BackendAgentNativeTimerCreateLocalPhase::starting); + + return state; +} + +BackendAgentLocalProviderFacts factsFor( + const BackendAgentNativeTimerCreateLocalState& state) +{ + BackendAgentLocalProviderFacts facts; + facts.providerId = + state.command.localProviderSelection.providerId; + facts.providerKind = + state.command.localProviderSelection.providerKind; + facts.providerInstanceEpoch = + state.command.localProviderSelection.providerInstanceEpoch; + facts.providerGeneration = + state.command.localProviderSelection.providerGeneration; + facts.capabilityRevision = + state.command.localProviderSelection.capabilityRevision; + facts.available = true; + facts.capabilities = { + state.command.localProviderSelection.requiredCapability}; + + assert(backendAgentLocalProviderValidFacts(facts)); + return facts; +} + +BackendAgentNativeTimerCreateExecutorContext contextFor( + const BackendAgentCommandAssignment& value, + std::int64_t current) +{ + return { + value.backendId, + value.agentId, + value.agentInstanceId, + value.backendGeneration, + current}; +} + +BackendAgentCommandReceipt receiptFor( + const BackendAgentCommandAssignment& value, + std::int64_t current) +{ + BackendAgentCommandReceipt receipt; + receipt.protocolVersion = "vdr-suite-agent/1"; + receipt.commandId = value.commandId; + receipt.requestFingerprint = value.requestFingerprint; + receipt.jobId = value.jobId; + receipt.attemptId = value.attemptId; + receipt.claimEpoch = value.claimEpoch; + receipt.backendId = value.backendId; + receipt.agentId = value.agentId; + receipt.agentInstanceId = value.agentInstanceId; + receipt.backendGeneration = value.backendGeneration; + receipt.receiptCategory = "accepted"; + receipt.receivedAt = current; + receipt.reasonCode = "durably_recorded"; + + assert(backendAgentCommandValidReceipt(receipt)); + return receipt; +} + +class Transport final + : public IBackendAgentNativeTimerCreateTransport +{ +public: + BackendAgentLocalProviderFacts facts; + bool discoverySucceeds = true; + bool throwDiscovery = false; + bool throwCreate = false; + int discoveryCalls = 0; + int createCalls = 0; + + BackendAgentNativeTimerCreateTransportReply reply; + + bool discoverProvider( + BackendAgentLocalProviderFacts& output, + std::string& reason) override + { + ++discoveryCalls; + + if (throwDiscovery) + throw std::runtime_error("discovery"); + + if (!discoverySucceeds) + { + reason = "provider_unavailable"; + return false; + } + + output = facts; + reason = "provider_current"; + return true; + } + + BackendAgentNativeTimerCreateTransportReply createTimer( + const BackendAgentNativeTimerCreateTransportRequest& request) + override + { + ++createCalls; + + assert(!request.command.commandId.empty()); + assert( + request.command.expectedSpecificationFingerprint == + backendAgentNativeTimerCreateSpecificationFingerprint( + request.command.specification)); + assert(request.localStartingPersistedAt > 0); + + if (throwCreate) + throw std::runtime_error("create"); + + return reply; + } +}; + +void assertEvidence( + const BackendAgentNativeTimerCreateEvidence& evidence, + const BackendAgentNativeTimerCreateLocalState& state, + BackendAgentNativeTimerCreateOutcomeCategory outcome) +{ + std::string reason; + + assert(evidence.outcome == outcome); + assert(backendAgentNativeTimerCreateEvidenceMatches( + evidence, state.command, reason)); +} + +std::string statePath(const std::string& suffix) +{ + return "/tmp/vdr-suite-phase64-create-" + suffix + ".state"; +} + +void cleanup(const std::string& path) +{ + std::remove(path.c_str()); + std::remove((path + ".tmp").c_str()); +} + +} // namespace + +int main() +{ + const std::int64_t current = now(); + std::string reason; + BackendAgentNativeTimerCreateEvidence evidence; + + const auto assigned = + assignment("executor", current); + const auto localStarting = + starting(assigned, current); + const auto context = + contextFor(assigned, current + 1); + + // Exact persisted provider selection permits one typed dispatch. + Transport accepted; + accepted.facts = factsFor(localStarting); + accepted.reply.disposition = + BackendAgentNativeTimerCreateTransportDisposition:: + acceptedUnverified; + accepted.reply.evidenceReference = + "suitebridge:create:accepted:1"; + + assert(backendAgentNativeTimerCreateExecuteFreshStartingOnce( + assigned, + localStarting, + context, + accepted, + evidence, + reason)); + + assert( + reason == + "native_timer_create_executor_accepted_unverified"); + assert(accepted.discoveryCalls == 1); + assert(accepted.createCalls == 1); + + assertEvidence( + evidence, + localStarting, + BackendAgentNativeTimerCreateOutcomeCategory:: + acceptedUnverified); + + assert(evidence.dispatchStartedAt == context.now); + assert( + evidence.nativeTimerBindingId == + localStarting.command.nativeTimerBindingId); + + // Agent/backend generation drift fences before discovery. + Transport stale; + stale.facts = factsFor(localStarting); + + auto staleContext = context; + ++staleContext.backendGeneration; + + assert(backendAgentNativeTimerCreateExecuteFreshStartingOnce( + assigned, + localStarting, + staleContext, + stale, + evidence, + reason)); + + assert(stale.discoveryCalls == 0); + assert(stale.createCalls == 0); + + assertEvidence( + evidence, + localStarting, + BackendAgentNativeTimerCreateOutcomeCategory:: + rejectedWithoutEffect); + + assert(evidence.dispatchStartedAt == 0); + + // Deadline is rechecked immediately before discovery. + Transport expired; + expired.facts = factsFor(localStarting); + + auto expiredContext = context; + expiredContext.now = assigned.deadline; + + assert(backendAgentNativeTimerCreateExecuteFreshStartingOnce( + assigned, + localStarting, + expiredContext, + expired, + evidence, + reason)); + + assert(expired.discoveryCalls == 0); + assert(expired.createCalls == 0); + + // Provider epoch/generation/revision remains a hard fence. + Transport changedProvider; + changedProvider.facts = factsFor(localStarting); + changedProvider.facts.providerInstanceEpoch = + "suitebridge-create-changed"; + + assert(backendAgentNativeTimerCreateExecuteFreshStartingOnce( + assigned, + localStarting, + context, + changedProvider, + evidence, + reason)); + + assert(changedProvider.discoveryCalls == 1); + assert(changedProvider.createCalls == 0); + + // Provider discovery failure is still pre-dispatch/no-effect. + Transport discoveryFailure; + discoveryFailure.facts = factsFor(localStarting); + discoveryFailure.discoverySucceeds = false; + + assert(backendAgentNativeTimerCreateExecuteFreshStartingOnce( + assigned, + localStarting, + context, + discoveryFailure, + evidence, + reason)); + + assert(discoveryFailure.discoveryCalls == 1); + assert(discoveryFailure.createCalls == 0); + + assertEvidence( + evidence, + localStarting, + BackendAgentNativeTimerCreateOutcomeCategory:: + rejectedWithoutEffect); + + // Once native dispatch starts, an exception can never authorize retry. + Transport exception; + exception.facts = factsFor(localStarting); + exception.throwCreate = true; + + assert(backendAgentNativeTimerCreateExecuteFreshStartingOnce( + assigned, + localStarting, + context, + exception, + evidence, + reason)); + + assert(exception.createCalls == 1); + + assertEvidence( + evidence, + localStarting, + BackendAgentNativeTimerCreateOutcomeCategory:: + outcomeUnknown); + + assert(evidence.dispatchStartedAt == context.now); + + // Invalid accepted evidence is also outcome_unknown. + Transport invalidAccepted; + invalidAccepted.facts = factsFor(localStarting); + invalidAccepted.reply.disposition = + BackendAgentNativeTimerCreateTransportDisposition:: + acceptedUnverified; + invalidAccepted.reply.evidenceReference = + "invalid\nreference"; + + assert(backendAgentNativeTimerCreateExecuteFreshStartingOnce( + assigned, + localStarting, + context, + invalidAccepted, + evidence, + reason)); + + assert(invalidAccepted.createCalls == 1); + + assertEvidence( + evidence, + localStarting, + BackendAgentNativeTimerCreateOutcomeCategory:: + outcomeUnknown); + + // Handler path: persist local starting, acknowledge receipt, + // dispatch once, and durably persist typed evidence/result. + { + const std::string path = + statePath("handler-accepted"); + cleanup(path); + + const auto handlerAssignment = + assignment("handleraccepted", current); + + commandstate::LocalState state; + state.assignment = handlerAssignment; + state.receipt = + receiptFor(handlerAssignment, current); + + assert( + backendAgentNativeTimerCreateCommandPrepareFreshStarting( + path, + state, + current, + reason)); + + assert(state.stateExtensionPresent); + assert(state.dispatchState == "starting"); + + state.receiptAcknowledged = true; + assert(commandstate::persist(path, state, reason)); + + BackendAgentNativeTimerCreateLocalState handlerStarting; + assert(backendAgentNativeTimerCreateParseLocalState( + state.stateExtension.payload, + handlerStarting, + reason)); + + Transport handlerTransport; + handlerTransport.facts = + factsFor(handlerStarting); + handlerTransport.reply.disposition = + BackendAgentNativeTimerCreateTransportDisposition:: + acceptedUnverified; + handlerTransport.reply.evidenceReference = + "suitebridge:create:handler:accepted"; + + BackendAgentNativeTimerCreateCommandContext handlerContext{ + handlerAssignment.backendId, + handlerAssignment.agentId, + handlerAssignment.agentInstanceId, + handlerAssignment.backendGeneration}; + + assert( + backendAgentNativeTimerCreateCommandExecuteFreshStartingAndPersistOutcome( + path, + handlerContext, + &handlerTransport, + state, + reason)); + + assert( + reason == + "native_create_executor_outcome_persisted"); + assert(handlerTransport.createCalls == 1); + assert(state.resultPresent); + assert( + state.dispatchState == + "accepted_by_executor"); + assert( + state.result.retryClassification == + "reconcile_only"); + + commandstate::LocalState persisted; + assert(commandstate::load( + path, persisted, reason)); + assert(persisted.resultPresent); + assert(persisted.stateExtensionPresent); + + BackendAgentNativeTimerCreateLocalState completed; + assert(backendAgentNativeTimerCreateParseLocalState( + persisted.stateExtension.payload, + completed, + reason)); + + assert( + completed.phase == + BackendAgentNativeTimerCreateLocalPhase:: + completed); + assert( + completed.evidence.outcome == + BackendAgentNativeTimerCreateOutcomeCategory:: + acceptedUnverified); + + cleanup(path); + } + + // Crash boundary: once starting is durable, recovery completes it as + // outcome_unknown/reconcile_only and must never call CREATE again. + { + const std::string path = + statePath("handler-recovery"); + cleanup(path); + + const auto crashAssignment = + assignment("handlerrecovery", current); + + commandstate::LocalState state; + state.assignment = crashAssignment; + state.receipt = + receiptFor(crashAssignment, current); + + assert( + backendAgentNativeTimerCreateCommandPrepareFreshStarting( + path, + state, + current, + reason)); + + BackendAgentNativeTimerCreateCommandContext crashContext{ + crashAssignment.backendId, + crashAssignment.agentId, + crashAssignment.agentInstanceId, + crashAssignment.backendGeneration}; + + assert( + backendAgentNativeTimerCreateCommandReconcileExisting( + path, + crashContext, + state, + reason)); + + assert(state.resultPresent); + assert(state.dispatchState == "starting"); + assert( + state.result.retryClassification == + "reconcile_only"); + assert( + state.result.resultCategory == + "outcome_unknown"); + + BackendAgentNativeTimerCreateLocalState recovered; + assert(backendAgentNativeTimerCreateParseLocalState( + state.stateExtension.payload, + recovered, + reason)); + + assert( + recovered.phase == + BackendAgentNativeTimerCreateLocalPhase:: + completed); + assert( + recovered.evidence.outcome == + BackendAgentNativeTimerCreateOutcomeCategory:: + outcomeUnknown); + + Transport forbiddenReplay; + forbiddenReplay.facts = + factsFor(recovered); + + state.receiptAcknowledged = true; + + assert( + !backendAgentNativeTimerCreateCommandExecuteFreshStartingAndPersistOutcome( + path, + crashContext, + &forbiddenReplay, + state, + reason)); + + assert(forbiddenReplay.discoveryCalls == 0); + assert(forbiddenReplay.createCalls == 0); + + cleanup(path); + } + + return 0; +} diff --git a/mk/agent-sources.mk b/mk/agent-sources.mk index 89fdb590a..4025ca120 100644 --- a/mk/agent-sources.mk +++ b/mk/agent-sources.mk @@ -46,6 +46,7 @@ AGENT_COMMAND_DELIVERY_SRC := \ AGENT_COMMAND_STATE_SRC := \ core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ + core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ core/agent/src/BackendAgentNativeTimerDelete.cpp \ core/agent/src/BackendAgentNativeTimerDeleteLocalState.cpp \ core/agent/src/BackendAgentCommandStateExtension.cpp \ @@ -57,13 +58,21 @@ AGENT_TIMER_DELETE_EXECUTOR_SRC := \ AGENT_NATIVE_PROBE_COMMAND_HANDLER_SRC := \ core/agent/src/BackendAgentNativeProbeCommandHandler.cpp +AGENT_TIMER_CREATE_EXECUTOR_SRC := \ + core/agent/src/BackendAgentNativeTimerCreateExecutor.cpp + +AGENT_NATIVE_TIMER_CREATE_COMMAND_HANDLER_SRC := \ + core/agent/src/BackendAgentNativeTimerCreateCommandHandler.cpp + AGENT_NATIVE_TIMER_DELETE_COMMAND_HANDLER_SRC := \ core/agent/src/BackendAgentNativeTimerDeleteCommandHandler.cpp AGENT_COMMAND_CLIENT_SRC := \ $(AGENT_COMMAND_STATE_SRC) \ + $(AGENT_TIMER_CREATE_EXECUTOR_SRC) \ $(AGENT_TIMER_DELETE_EXECUTOR_SRC) \ $(AGENT_NATIVE_PROBE_COMMAND_HANDLER_SRC) \ + $(AGENT_NATIVE_TIMER_CREATE_COMMAND_HANDLER_SRC) \ $(AGENT_NATIVE_TIMER_DELETE_COMMAND_HANDLER_SRC) \ core/agent/src/BackendAgentCommandClient.cpp diff --git a/mk/phase64-native-timer-create-executor-tests.mk b/mk/phase64-native-timer-create-executor-tests.mk new file mode 100644 index 000000000..ce3e08ed5 --- /dev/null +++ b/mk/phase64-native-timer-create-executor-tests.mk @@ -0,0 +1,20 @@ +.PHONY: test-phase64-native-timer-create-executor-architecture test-phase64-native-timer-create-executor + +test-phase64-native-timer-create-executor-architecture: + python3 tools/check_phase64_native_timer_create_executor.py + +test-phase64-native-timer-create-executor: \ + test-phase64-native-timer-create-executor-architecture \ + test-phase64-native-timer-create-local-state + $(BUILD_CXX) $(CXXFLAGS) \ + $(AGENT_COMMAND_DOMAIN_SRC) \ + $(AGENT_COMMAND_STATE_SRC) \ + $(AGENT_TIMER_CREATE_EXECUTOR_SRC) \ + $(AGENT_NATIVE_TIMER_CREATE_COMMAND_HANDLER_SRC) \ + core/agent/tests/test_backend_agent_native_timer_create_executor.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_backend_agent_native_timer_create_executor + $(BUILD_DIR)/test_backend_agent_native_timer_create_executor + +test-fast: test-phase64-native-timer-create-executor +test-architecture: test-phase64-native-timer-create-executor-architecture diff --git a/tools/check_phase63_fenced_native_operation_runtime.py b/tools/check_phase63_fenced_native_operation_runtime.py index 99dc0bf74..79780cf0b 100755 --- a/tools/check_phase63_fenced_native_operation_runtime.py +++ b/tools/check_phase63_fenced_native_operation_runtime.py @@ -389,25 +389,57 @@ def text(relative: str) -> str: if "mutations=enabled" in scoped_runtime or '"enabled"' in scoped_runtime: errors.append("mutations=enabled is forbidden") -# Phase 64 may classify the already-validated Timer-delete command state in the -# generic Agent command owner. This single boolean discriminator carries no -# execution authority and must remain paired with the bounded fresh-starting -# successor helper. Strip only that exact declaration from the old Phase-63 -# mutation-name heuristic; every other timer/recording/channel/etc assignment +# Phase 64 may classify already-validated Timer command state in the generic +# Agent command owner. These boolean discriminators carry no execution authority. +# Strip only the exact CREATE/DELETE declarations from the old Phase-63 +# mutation-name heuristic, and only while each remains paired with its bounded +# fresh-starting handoff. Every other timer/recording/channel/etc assignment # remains forbidden by the original scan below. -allowed_timer_delete_discriminator = ( - "const bool timerDeleteCommand = state.assignment.commandType ==\n" - " vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType;" +allowed_timer_discriminators = ( + ( + "Timer-create", + ( + "const bool timerCreateCommand = state.assignment.commandType ==\n" + " vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType;" + ), + ( + "prepareFreshNativeTimerCreateLocalStarting", + "executeFreshNativeTimerCreateAndPersistOutcome", + ), + ), + ( + "Timer-delete", + ( + "const bool timerDeleteCommand = state.assignment.commandType ==\n" + " vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType;" + ), + ( + "prepareFreshNativeTimerDeleteLocalStarting", + ), + ), ) + scoped_runtime_boundary = scoped_runtime -if allowed_timer_delete_discriminator in agent_client: - if agent_client.count(allowed_timer_delete_discriminator) != 1 or \ - "prepareFreshNativeTimerDeleteLocalStarting" not in agent_client: + +for label, discriminator, required_handoffs in allowed_timer_discriminators: + if discriminator not in agent_client: + continue + + if ( + agent_client.count(discriminator) != 1 + or any( + handoff not in agent_client + for handoff in required_handoffs + ) + ): errors.append( - "Timer-delete discriminator must remain a single bounded Phase-64 state handoff" + f"{label} discriminator must remain a single bounded " + "Phase-64 state handoff" ) + continue + scoped_runtime_boundary = scoped_runtime_boundary.replace( - allowed_timer_delete_discriminator, "", 1 + discriminator, "", 1 ) for pattern in [ diff --git a/tools/check_phase64_native_timer_create_executor.py b/tools/check_phase64_native_timer_create_executor.py new file mode 100644 index 000000000..48f044f7c --- /dev/null +++ b/tools/check_phase64_native_timer_create_executor.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +from pathlib import Path + +required = { + "core/agent/include/BackendAgentNativeTimerCreateExecutor.h": [ + "IBackendAgentNativeTimerCreateTransport", + "backendAgentNativeTimerCreateExecuteFreshStartingOnce", + "rejectedWithoutEffect", + "acceptedUnverified", + "outcomeUnknown", + ], + "core/agent/src/BackendAgentNativeTimerCreateExecutor.cpp": [ + "executor-fence:agent-generation", + "executor-fence:deadline", + "executor-fence:provider-changed", + "native_timer_create_executor_outcome_unknown", + "transport.createTimer(request)", + ], + "core/agent/include/BackendAgentNativeTimerCreateCommandHandler.h": [ + "backendAgentNativeTimerCreateCommandReconcileExisting", + "backendAgentNativeTimerCreateCommandPrepareFreshStarting", + "backendAgentNativeTimerCreateCommandExecuteFreshStartingAndPersistOutcome", + ], + "core/agent/src/BackendAgentNativeTimerCreateCommandHandler.cpp": [ + "native_create_local_starting_persisted", + "native_create_executor_outcome_persisted", + "BackendAgentNativeTimerCreateRecoveryDecision::reconcileOnly", + ], + "core/agent/tests/test_backend_agent_native_timer_create_executor.cpp": [ + "forbiddenReplay.createCalls == 0", + "acceptedUnverified", + "outcomeUnknown", + "changedProvider.createCalls == 0", + ], +} + +for filename, markers in required.items(): + path = Path(filename) + if not path.is_file(): + raise SystemExit(f"missing required file: {filename}") + + text = path.read_text() + + for marker in markers: + if marker not in text: + raise SystemExit( + f"{filename}: missing architecture marker: {marker}" + ) + +client = Path("core/agent/src/BackendAgentCommandClient.cpp").read_text() + +if "kBackendAgentNativeTimerCreateCommandType" not in client: + raise SystemExit("CREATE command type missing from command client") + +if "config.nativeTimerCreateTransport" not in client: + raise SystemExit("CREATE transport not wired into command client") + +# Shipping boundary stays closed in this slice. CREATE must still be filtered +# from the advertised polling capability until exact-candidate yaVDR acceptance. +availability_start = client.find("CommandAvailability availableCommands") +availability_end = client.find( + "bool reconcileBackendAgentCommandState", + availability_start, +) + +if availability_start < 0 or availability_end < 0: + raise SystemExit("command availability function not found") + +availability = client[availability_start:availability_end] + +if ( + "kBackendAgentNativeTimerCreateCommandType" not in availability + or "continue;" not in availability +): + raise SystemExit( + "CREATE capability advertisement is not visibly held closed" + ) + +if Path( + "core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp" +).exists(): + raise SystemExit( + "SuiteBridge CREATE transport must not land in executor slice" + ) + +sources = Path("mk/agent-sources.mk").read_text() + +for marker in ( + "AGENT_TIMER_CREATE_EXECUTOR_SRC", + "BackendAgentNativeTimerCreateExecutor.cpp", + "AGENT_NATIVE_TIMER_CREATE_COMMAND_HANDLER_SRC", + "BackendAgentNativeTimerCreateCommandHandler.cpp", + "BackendAgentNativeTimerCreateRecovery.cpp", +): + if marker not in sources: + raise SystemExit( + f"mk/agent-sources.mk missing runtime marker: {marker}" + ) + +print( + "Phase 64 native Timer CREATE executor architecture guard passed" +) From 7022a72c71a577348aeaddd3b672b956048e1ffe Mon Sep 17 00:00:00 2001 From: Holger Schvestka Date: Sun, 16 Aug 2026 21:26:55 +0200 Subject: [PATCH 42/85] Add private SuiteBridge Timer CREATE transport --- Makefile | 1 + .../SuiteBridgeNativeTimerCreateTransport.h | 27 + .../agent/include/SuiteBridgeSvdrpTransport.h | 11 +- ...eBridgeSvdrpNativeTimerCreateTransport.cpp | 434 ++++++++++++++ ...ge_svdrp_native_timer_create_transport.cpp | 533 ++++++++++++++++++ mk/agent-sources.mk | 3 + ...dge-native-timer-create-transport-tests.mk | 23 + ...ck_phase64_native_timer_create_executor.py | 9 +- ...itebridge_native_timer_create_transport.py | 334 +++++++++++ 9 files changed, 1369 insertions(+), 6 deletions(-) create mode 100644 core/agent/include/SuiteBridgeNativeTimerCreateTransport.h create mode 100644 core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp create mode 100644 core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp create mode 100644 mk/phase64-suitebridge-native-timer-create-transport-tests.mk create mode 100755 tools/check_phase64_suitebridge_native_timer_create_transport.py diff --git a/Makefile b/Makefile index 895721982..cb3e988d4 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ include mk/phase64-native-timer-create-delivery-tests.mk include mk/phase64-native-timer-create-activation-tests.mk include mk/phase64-native-timer-create-local-state-tests.mk include mk/phase64-native-timer-create-executor-tests.mk +include mk/phase64-suitebridge-native-timer-create-transport-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk diff --git a/core/agent/include/SuiteBridgeNativeTimerCreateTransport.h b/core/agent/include/SuiteBridgeNativeTimerCreateTransport.h new file mode 100644 index 000000000..9d6a0d451 --- /dev/null +++ b/core/agent/include/SuiteBridgeNativeTimerCreateTransport.h @@ -0,0 +1,27 @@ +#pragma once + +#include "BackendAgentNativeTimerCreateExecutor.h" +#include "SuiteBridgeSvdrpTransport.h" + +namespace vdrsuite::agent +{ + +class SuiteBridgeNativeTimerCreateTransport final : + public IBackendAgentNativeTimerCreateTransport +{ +public: + explicit SuiteBridgeNativeTimerCreateTransport( + SuiteBridgeSvdrpTransportConfig config = {}); + + bool discoverProvider( + BackendAgentLocalProviderFacts& facts, + std::string& reasonCode) override; + + BackendAgentNativeTimerCreateTransportReply createTimer( + const BackendAgentNativeTimerCreateTransportRequest& request) override; + +private: + SuiteBridgeSvdrpTransport transport_; +}; + +} // namespace vdrsuite::agent diff --git a/core/agent/include/SuiteBridgeSvdrpTransport.h b/core/agent/include/SuiteBridgeSvdrpTransport.h index aeda6dceb..3e1e4fddf 100644 --- a/core/agent/include/SuiteBridgeSvdrpTransport.h +++ b/core/agent/include/SuiteBridgeSvdrpTransport.h @@ -18,6 +18,7 @@ namespace vdrsuite::agent { +struct BackendAgentNativeTimerCreateTransportRequest; struct BackendAgentNativeTimerDeleteTransportRequest; struct SuiteBridgeSvdrpTransportConfig @@ -120,9 +121,13 @@ class SuiteBridgeSvdrpTransport final : return executeRequest(wire.str()); } - // Narrow typed raw-wire hooks used only by the dedicated disabled - // Timer-delete adapter. They are deliberately non-virtual so generic - // SuiteBridge users do not acquire Timer-delete link dependencies. + // Narrow typed raw-wire hooks used only by dedicated native Timer + // adapters. They are deliberately non-virtual so generic SuiteBridge + // users do not acquire native Timer mutation link dependencies. + SuiteBridgeCommandReply discoverNativeTimerCreateContract(); + SuiteBridgeCommandReply executeNativeTimerCreateContract( + const BackendAgentNativeTimerCreateTransportRequest& request); + SuiteBridgeCommandReply discoverNativeTimerDeleteContract(); SuiteBridgeCommandReply executeNativeTimerDeleteContract( const BackendAgentNativeTimerDeleteTransportRequest& request); diff --git a/core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp b/core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp new file mode 100644 index 000000000..9c7878f34 --- /dev/null +++ b/core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp @@ -0,0 +1,434 @@ +#include "SuiteBridgeNativeTimerCreateTransport.h" + +#include "BackendAgentCommand.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace +{ + +constexpr const char* CapabilityProtocol = "vdr-suite-ntcreate-cap/1"; +constexpr const char* ResultProtocol = "vdr-suite-ntcreate-result/1"; +constexpr const char* SideEffectClass = "timer-create"; +constexpr const char* DisabledState = "disabled"; +constexpr const char* EnabledState = "enabled"; + +constexpr std::uint64_t ProviderGeneration = 1; +constexpr std::uint64_t CapabilityRevision = 1; + +constexpr int CapabilityReplyCode = 900; +constexpr int StaleReplyCode = 555; +constexpr int RejectedReplyCode = 556; +constexpr int AcceptedUnverifiedReplyCode = 557; +constexpr int OutcomeUnknownReplyCode = 558; +constexpr int ReplayConflictReplyCode = 559; +constexpr int ReplayLedgerFullReplyCode = 560; + +std::vector split( + const std::string& input, + std::size_t maximum) +{ + std::vector values; + std::size_t position = 0; + + while (position < input.size()) + { + while (position < input.size() && input[position] == ' ') + ++position; + + if (position == input.size()) + break; + + const std::size_t end = input.find(' ', position); + + values.push_back(input.substr( + position, + end == std::string::npos + ? std::string::npos + : end - position)); + + if (values.size() > maximum) + return {}; + + if (end == std::string::npos) + break; + + position = end + 1; + } + + return values; +} + +bool unsignedValue( + const std::string& value, + std::uint64_t& parsed) +{ + if (value.empty() || value.size() > 19) + return false; + + parsed = 0; + + for (unsigned char character : value) + { + if (character < '0' || character > '9') + return false; + + const unsigned digit = + static_cast(character - '0'); + + if (parsed > + (static_cast( + std::numeric_limits::max()) - digit) / + 10U) + { + return false; + } + + parsed = parsed * 10U + digit; + } + + return parsed > 0; +} + +bool safeWireToken( + const std::string& value, + std::size_t maximum = 512) +{ + return !value.empty() && + value.size() <= maximum && + std::all_of( + value.begin(), + value.end(), + [](unsigned char character) { + return std::isalnum(character) != 0 || + character == '-' || + character == '_' || + character == '.' || + character == ':'; + }); +} + +std::string hexToken(const std::string& input) +{ + if (input.empty()) + return "-"; + + static constexpr char Digits[] = "0123456789abcdef"; + + std::string output; + output.reserve(input.size() * 2U); + + for (unsigned char character : input) + { + output.push_back(Digits[(character >> 4U) & 0x0fU]); + output.push_back(Digits[character & 0x0fU]); + } + + return output; +} + +BackendAgentNativeTimerCreateTransportReply rejected( + std::string evidence) +{ + BackendAgentNativeTimerCreateTransportReply reply; + reply.disposition = + BackendAgentNativeTimerCreateTransportDisposition:: + rejectedWithoutEffect; + reply.evidenceReference = std::move(evidence); + return reply; +} + +BackendAgentNativeTimerCreateTransportReply accepted( + std::string evidence) +{ + BackendAgentNativeTimerCreateTransportReply reply; + reply.disposition = + BackendAgentNativeTimerCreateTransportDisposition:: + acceptedUnverified; + reply.evidenceReference = std::move(evidence); + return reply; +} + +BackendAgentNativeTimerCreateTransportReply unknown( + std::string evidence) +{ + BackendAgentNativeTimerCreateTransportReply reply; + reply.disposition = + BackendAgentNativeTimerCreateTransportDisposition:: + outcomeUnknown; + reply.evidenceReference = std::move(evidence); + return reply; +} + +bool safeRequest( + const BackendAgentNativeTimerCreateTransportRequest& request) +{ + std::string reason; + const auto& command = request.command; + const auto& selection = command.localProviderSelection; + + return + backendAgentNativeTimerCreateValidCommand(command, reason) && + request.localStartingPersistedAt >= + command.controlPlaneClaimedAt && + safeWireToken(command.commandId, 192) && + safeWireToken(command.requestFingerprint, 192) && + safeWireToken(command.operationId, 192) && + safeWireToken(command.operationRevision, 192) && + safeWireToken(command.timerAssignmentId, 192) && + safeWireToken(command.expectedAssignmentRevision, 192) && + safeWireToken(command.expectedIntentRevision, 192) && + safeWireToken(command.nativeTimerBindingId, 192) && + safeWireToken(command.jobId, 192) && + safeWireToken(command.attemptId, 192) && + safeWireToken(command.backendId, 192) && + safeWireToken(command.agentId, 192) && + safeWireToken(command.agentInstanceId, 192) && + safeWireToken(selection.authorityDomain, 192) && + safeWireToken(selection.providerId, 192) && + safeWireToken(selection.providerKind, 192) && + safeWireToken(selection.providerInstanceEpoch, 192) && + safeWireToken(selection.requiredCapability, 192); +} + +} // namespace + +SuiteBridgeCommandReply +SuiteBridgeSvdrpTransport::discoverNativeTimerCreateContract() +{ + return executeRequest( + "PLUG suitebridge NTCREATE CAP 1\r\n"); +} + +SuiteBridgeCommandReply +SuiteBridgeSvdrpTransport::executeNativeTimerCreateContract( + const BackendAgentNativeTimerCreateTransportRequest& request) +{ + const auto& command = request.command; + const auto& selection = command.localProviderSelection; + const auto& specification = command.specification; + + std::ostringstream wire; + + wire + << "PLUG suitebridge NTCREATE EXEC vdr-suite-native/1 " + << kBackendAgentNativeTimerCreateCapability << " 1 " + << command.commandId << ' ' + << command.requestFingerprint << ' ' + << command.operationId << ' ' + << command.operationRevision << ' ' + << command.timerAssignmentId << ' ' + << command.expectedAssignmentRevision << ' ' + << command.expectedIntentRevision << ' ' + << command.assignmentEpoch << ' ' + << command.nativeTimerBindingId << ' ' + << hexToken(command.expectedSpecificationFingerprint) << ' ' + << command.jobId << ' ' + << command.attemptId << ' ' + << command.claimEpoch << ' ' + << command.backendId << ' ' + << command.agentId << ' ' + << command.agentInstanceId << ' ' + << command.backendGeneration << ' ' + << command.controlPlaneClaimedAt << ' ' + << selection.authorityDomain << ' ' + << selection.providerId << ' ' + << selection.providerKind << ' ' + << selection.ownershipGeneration << ' ' + << selection.providerInstanceEpoch << ' ' + << selection.providerGeneration << ' ' + << selection.capabilityRevision << ' ' + << selection.requiredCapability << ' ' + << request.localStartingPersistedAt << ' ' + << hexToken(specification.channelId) << ' ' + << hexToken(specification.title) << ' ' + << hexToken(specification.directory) << ' ' + << hexToken(specification.day) << ' ' + << hexToken(specification.weekdays) << ' ' + << hexToken(specification.startTime) << ' ' + << hexToken(specification.endTime) << ' ' + << specification.priority << ' ' + << specification.lifetime << ' ' + << (specification.enabled ? 1 : 0) << ' ' + << (specification.vps ? 1 : 0) + << "\r\n"; + + return executeRequest(wire.str()); +} + +SuiteBridgeNativeTimerCreateTransport:: +SuiteBridgeNativeTimerCreateTransport( + SuiteBridgeSvdrpTransportConfig config) + : transport_(std::move(config)) +{ +} + +bool SuiteBridgeNativeTimerCreateTransport::discoverProvider( + BackendAgentLocalProviderFacts& facts, + std::string& reasonCode) +{ + facts = {}; + + const SuiteBridgeCommandReply reply = + transport_.discoverNativeTimerCreateContract(); + + if (!reply.transportSucceeded() || + reply.replyCode != CapabilityReplyCode) + { + reasonCode = + "native_timer_create_suitebridge_capability_unavailable"; + return false; + } + + const std::vector values = + split(reply.payload, 10); + + std::uint64_t providerGeneration = 0; + std::uint64_t capabilityRevision = 0; + + const bool stateValid = + values.size() == 10 && + (values[4] == DisabledState || + values[4] == EnabledState) && + values[9] == values[4]; + + if (!stateValid || + values[0] != CapabilityProtocol || + values[1] != kBackendAgentNativeTimerCreateCapability || + values[2] != "1" || + values[3] != SideEffectClass || + values[5] != kBackendAgentNativeTimerCreateProviderKind || + !safeWireToken(values[6], 192) || + !unsignedValue(values[7], providerGeneration) || + !unsignedValue(values[8], capabilityRevision) || + providerGeneration != ProviderGeneration || + capabilityRevision != CapabilityRevision) + { + reasonCode = + "native_timer_create_suitebridge_capability_invalid"; + return false; + } + + facts.providerId = + kBackendAgentNativeTimerCreateProviderId; + facts.providerKind = + kBackendAgentNativeTimerCreateProviderKind; + facts.providerInstanceEpoch = values[6]; + facts.providerGeneration = providerGeneration; + facts.capabilityRevision = capabilityRevision; + facts.available = true; + facts.capabilities = { + kBackendAgentNativeTimerCreateCapability + }; + + if (!backendAgentLocalProviderValidFacts(facts)) + { + facts = {}; + reasonCode = + "native_timer_create_suitebridge_provider_facts_invalid"; + return false; + } + + reasonCode = + values[4] == EnabledState + ? "native_timer_create_suitebridge_provider_discovered_enabled" + : "native_timer_create_suitebridge_provider_discovered_disabled"; + + return true; +} + +BackendAgentNativeTimerCreateTransportReply +SuiteBridgeNativeTimerCreateTransport::createTimer( + const BackendAgentNativeTimerCreateTransportRequest& request) +{ + if (!safeRequest(request)) + { + return rejected( + "suitebridge:ntcreate:local-request-invalid"); + } + + const auto& command = request.command; + const auto& selection = command.localProviderSelection; + + const SuiteBridgeCommandReply reply = + transport_.executeNativeTimerCreateContract(request); + + if (!reply.transportSucceeded()) + { + return unknown( + "suitebridge:ntcreate:transport-outcome-unknown"); + } + + const std::vector values = + split(reply.payload, 11); + + std::uint64_t providerGeneration = 0; + std::uint64_t capabilityRevision = 0; + + const bool typedResult = + values.size() == 11 && + values[0] == ResultProtocol && + values[1] == command.commandId && + values[2] == command.requestFingerprint && + values[3] == kBackendAgentNativeTimerCreateCapability && + values[4] == "1" && + safeWireToken(values[5], 192) && + unsignedValue(values[6], providerGeneration) && + unsignedValue(values[7], capabilityRevision) && + safeWireToken(values[8], 64) && + safeWireToken(values[9], 64) && + safeWireToken(values[10], 1024); + + if (!typedResult) + { + return unknown( + "suitebridge:ntcreate:reply-outcome-unknown"); + } + + const bool staleReply = + reply.replyCode == StaleReplyCode; + + if (!staleReply && + (values[5] != selection.providerInstanceEpoch || + providerGeneration != selection.providerGeneration || + capabilityRevision != selection.capabilityRevision)) + { + return unknown( + "suitebridge:ntcreate:reply-fence-mismatch"); + } + + if (reply.replyCode == AcceptedUnverifiedReplyCode && + values[8] == "accepted_unverified") + { + return accepted(values[10]); + } + + if (reply.replyCode == OutcomeUnknownReplyCode && + values[8] == "outcome_unknown") + { + return unknown(values[10]); + } + + if ((reply.replyCode == StaleReplyCode || + reply.replyCode == RejectedReplyCode || + reply.replyCode == ReplayConflictReplyCode || + reply.replyCode == ReplayLedgerFullReplyCode) && + values[8] == "rejected_without_effect") + { + return rejected(values[10]); + } + + return unknown( + "suitebridge:ntcreate:reply-outcome-unknown"); +} + +} // namespace vdrsuite::agent diff --git a/core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp b/core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp new file mode 100644 index 000000000..4fd134e2a --- /dev/null +++ b/core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp @@ -0,0 +1,533 @@ +#include "SuiteBridgeNativeTimerCreateTransport.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace vdrsuite::agent; + +namespace +{ + +class OneShotServer +{ +public: + explicit OneShotServer(std::string response) + : response_(std::move(response)) + { + fd_ = socket(AF_INET, SOCK_STREAM, 0); + assert(fd_ >= 0); + + int reuse = 1; + assert(setsockopt( + fd_, + SOL_SOCKET, + SO_REUSEADDR, + &reuse, + sizeof(reuse)) == 0); + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + + assert(bind( + fd_, + reinterpret_cast(&address), + sizeof(address)) == 0); + + socklen_t length = sizeof(address); + + assert(getsockname( + fd_, + reinterpret_cast(&address), + &length) == 0); + + port_ = ntohs(address.sin_port); + + assert(listen(fd_, 1) == 0); + + worker_ = std::thread([this] { + serve(); + }); + } + + ~OneShotServer() + { + wait(); + + if (fd_ >= 0) + close(fd_); + } + + int port() const + { + return port_; + } + + const std::string& request() const + { + return request_; + } + + void wait() + { + if (worker_.joinable()) + worker_.join(); + } + +private: + void serve() + { + const int client = + accept(fd_, nullptr, nullptr); + + assert(client >= 0); + + const std::string greeting = + "220 local-vdr ready\r\n"; + + assert(send( + client, + greeting.data(), + greeting.size(), + 0) == + static_cast(greeting.size())); + + char buffer[4096]; + + while (request_.find('\n') == std::string::npos) + { + const ssize_t count = + recv(client, buffer, sizeof(buffer), 0); + + assert(count > 0); + + request_.append( + buffer, + static_cast(count)); + } + + assert(send( + client, + response_.data(), + response_.size(), + 0) == + static_cast(response_.size())); + + shutdown(client, SHUT_WR); + close(client); + } + + std::string response_; + int fd_ = -1; + int port_ = 0; + std::thread worker_; + std::string request_; +}; + +SuiteBridgeSvdrpTransportConfig config(int port) +{ + SuiteBridgeSvdrpTransportConfig value; + value.host = "127.0.0.1"; + value.port = port; + return value; +} + +std::string hexToken(const std::string& input) +{ + if (input.empty()) + return "-"; + + static constexpr char Digits[] = + "0123456789abcdef"; + + std::string output; + + for (unsigned char character : input) + { + output.push_back( + Digits[(character >> 4U) & 0x0fU]); + output.push_back( + Digits[character & 0x0fU]); + } + + return output; +} + +BackendAgentNativeTimerCreateTransportRequest validRequest() +{ + BackendAgentNativeTimerCreateTransportRequest request; + auto& command = request.command; + + command.commandId = "cmd_1"; + command.requestFingerprint = "fp_1"; + command.operationId = "op_1"; + command.operationRevision = "opr_1"; + command.timerAssignmentId = "tas_1"; + command.expectedAssignmentRevision = "tar_1"; + command.expectedIntentRevision = "tir_1"; + command.assignmentEpoch = 4; + command.nativeTimerBindingId = "ntb_1"; + command.jobId = "job_1"; + command.attemptId = "att_1"; + command.claimEpoch = 3; + command.backendId = "default"; + command.agentId = "agt_1"; + command.agentInstanceId = "agi_1"; + command.backendGeneration = 7; + command.controlPlaneClaimedAt = 100; + + command.specification.channelId = + "S19.2E-1-1011-11100"; + command.specification.title = + "Tagesschau 20 Uhr"; + command.specification.directory = + "TV News"; + command.specification.day = + "2026-08-16"; + command.specification.weekdays = + "-------"; + command.specification.startTime = + "2000"; + command.specification.endTime = + "2020"; + command.specification.priority = 50; + command.specification.lifetime = 99; + command.specification.enabled = true; + command.specification.vps = false; + + command.expectedSpecificationFingerprint = + backendAgentNativeTimerCreateSpecificationFingerprint( + command.specification); + + command.localProviderSelection.backendId = + "default"; + command.localProviderSelection.authorityDomain = + "vdr.timer"; + command.localProviderSelection.providerId = + "suitebridge:local"; + command.localProviderSelection.providerKind = + "suitebridge"; + command.localProviderSelection.ownershipGeneration = + 9; + command.localProviderSelection.providerInstanceEpoch = + "pie_1"; + command.localProviderSelection.providerGeneration = + 1; + command.localProviderSelection.capabilityRevision = + 1; + command.localProviderSelection.requiredCapability = + "vdr.timer.create"; + + request.localStartingPersistedAt = 101; + + return request; +} + +} // namespace + +int main() +{ + { + OneShotServer server( + "900 vdr-suite-ntcreate-cap/1 " + "vdr.timer.create 1 timer-create " + "disabled suitebridge pie_1 1 1 disabled\r\n"); + + SuiteBridgeNativeTimerCreateTransport transport( + config(server.port())); + + BackendAgentLocalProviderFacts facts; + std::string reason; + + assert(transport.discoverProvider( + facts, + reason)); + + server.wait(); + + assert(reason == + "native_timer_create_suitebridge_provider_discovered_disabled"); + + assert(facts.providerId == + "suitebridge:local"); + assert(facts.providerKind == + "suitebridge"); + assert(facts.providerInstanceEpoch == + "pie_1"); + assert(facts.providerGeneration == 1); + assert(facts.capabilityRevision == 1); + assert(facts.available); + assert(facts.capabilities.size() == 1); + assert(facts.capabilities.front() == + "vdr.timer.create"); + + assert(server.request() == + "PLUG suitebridge NTCREATE CAP 1\r\n"); + } + + { + OneShotServer server( + "900 vdr-suite-ntcreate-cap/1 " + "vdr.timer.create 1 timer-create " + "enabled suitebridge pie_1 1 1 enabled\r\n"); + + SuiteBridgeNativeTimerCreateTransport transport( + config(server.port())); + + BackendAgentLocalProviderFacts facts; + std::string reason; + + assert(transport.discoverProvider( + facts, + reason)); + + server.wait(); + + assert(reason == + "native_timer_create_suitebridge_provider_discovered_enabled"); + } + + { + OneShotServer server( + "556 vdr-suite-ntcreate-result/1 " + "cmd_1 fp_1 vdr.timer.create 1 " + "pie_1 1 1 rejected_without_effect " + "disabled ntcreate:disabled:cmd_1\r\n"); + + SuiteBridgeNativeTimerCreateTransport transport( + config(server.port())); + + const auto request = validRequest(); + const auto reply = + transport.createTimer(request); + + server.wait(); + + assert(reply.disposition == + BackendAgentNativeTimerCreateTransportDisposition:: + rejectedWithoutEffect); + + assert(reply.evidenceReference == + "ntcreate:disabled:cmd_1"); + + const auto& command = request.command; + const auto& spec = command.specification; + + const std::string expected = + std::string( + "PLUG suitebridge NTCREATE EXEC " + "vdr-suite-native/1 vdr.timer.create 1 ") + + command.commandId + " " + + command.requestFingerprint + " " + + command.operationId + " " + + command.operationRevision + " " + + command.timerAssignmentId + " " + + command.expectedAssignmentRevision + " " + + command.expectedIntentRevision + " " + + std::to_string(command.assignmentEpoch) + " " + + command.nativeTimerBindingId + " " + + hexToken( + command.expectedSpecificationFingerprint) + " " + + command.jobId + " " + + command.attemptId + " " + + std::to_string(command.claimEpoch) + " " + + command.backendId + " " + + command.agentId + " " + + command.agentInstanceId + " " + + std::to_string(command.backendGeneration) + " " + + std::to_string(command.controlPlaneClaimedAt) + " " + + command.localProviderSelection.authorityDomain + " " + + command.localProviderSelection.providerId + " " + + command.localProviderSelection.providerKind + " " + + std::to_string( + command.localProviderSelection.ownershipGeneration) + " " + + command.localProviderSelection.providerInstanceEpoch + " " + + std::to_string( + command.localProviderSelection.providerGeneration) + " " + + std::to_string( + command.localProviderSelection.capabilityRevision) + " " + + command.localProviderSelection.requiredCapability + " " + + std::to_string(request.localStartingPersistedAt) + " " + + hexToken(spec.channelId) + " " + + hexToken(spec.title) + " " + + hexToken(spec.directory) + " " + + hexToken(spec.day) + " " + + hexToken(spec.weekdays) + " " + + hexToken(spec.startTime) + " " + + hexToken(spec.endTime) + " " + + std::to_string(spec.priority) + " " + + std::to_string(spec.lifetime) + " 1 0\r\n"; + + assert(server.request() == expected); + + assert(server.request().find( + "Tagesschau 20 Uhr") == + std::string::npos); + + assert(server.request().find( + "TV News") == + std::string::npos); + } + + { + OneShotServer server( + "557 vdr-suite-ntcreate-result/1 " + "cmd_1 fp_1 vdr.timer.create 1 " + "pie_1 1 1 accepted_unverified " + "callback_applied " + "ntcreate:vdr:created-unverified:cmd_1\r\n"); + + SuiteBridgeNativeTimerCreateTransport transport( + config(server.port())); + + const auto reply = + transport.createTimer(validRequest()); + + server.wait(); + + assert(reply.disposition == + BackendAgentNativeTimerCreateTransportDisposition:: + acceptedUnverified); + + assert(reply.evidenceReference == + "ntcreate:vdr:created-unverified:cmd_1"); + } + + { + OneShotServer server( + "558 vdr-suite-ntcreate-result/1 " + "cmd_1 fp_1 vdr.timer.create 1 " + "pie_1 1 1 outcome_unknown " + "callback_unknown " + "ntcreate:vdr:exception:cmd_1\r\n"); + + SuiteBridgeNativeTimerCreateTransport transport( + config(server.port())); + + const auto reply = + transport.createTimer(validRequest()); + + server.wait(); + + assert(reply.disposition == + BackendAgentNativeTimerCreateTransportDisposition:: + outcomeUnknown); + + assert(reply.evidenceReference == + "ntcreate:vdr:exception:cmd_1"); + } + + { + OneShotServer server( + "557 vdr-suite-ntcreate-result/1 " + "cmd_1 fp_1 vdr.timer.create 1 " + "pie_other 1 1 accepted_unverified " + "callback_applied " + "ntcreate:vdr:created-unverified:cmd_1\r\n"); + + SuiteBridgeNativeTimerCreateTransport transport( + config(server.port())); + + const auto reply = + transport.createTimer(validRequest()); + + server.wait(); + + assert(reply.disposition == + BackendAgentNativeTimerCreateTransportDisposition:: + outcomeUnknown); + + assert(reply.evidenceReference == + "suitebridge:ntcreate:reply-fence-mismatch"); + } + + { + OneShotServer server( + "555 vdr-suite-ntcreate-result/1 " + "cmd_1 fp_1 vdr.timer.create 1 " + "pie_other 1 1 rejected_without_effect " + "stale ntcreate:stale:cmd_1\r\n"); + + SuiteBridgeNativeTimerCreateTransport transport( + config(server.port())); + + const auto reply = + transport.createTimer(validRequest()); + + server.wait(); + + assert(reply.disposition == + BackendAgentNativeTimerCreateTransportDisposition:: + rejectedWithoutEffect); + + assert(reply.evidenceReference == + "ntcreate:stale:cmd_1"); + } + + { + auto request = validRequest(); + request.command.specification.title = + "changed after fingerprint"; + + SuiteBridgeSvdrpTransportConfig disabled; + disabled.host.clear(); + + SuiteBridgeNativeTimerCreateTransport transport( + disabled); + + const auto reply = + transport.createTimer(request); + + assert(reply.disposition == + BackendAgentNativeTimerCreateTransportDisposition:: + rejectedWithoutEffect); + + assert(reply.evidenceReference == + "suitebridge:ntcreate:local-request-invalid"); + } + + { + auto request = validRequest(); + request.command.operationRevision = + "bad revision"; + + SuiteBridgeSvdrpTransportConfig disabled; + disabled.host.clear(); + + SuiteBridgeNativeTimerCreateTransport transport( + disabled); + + const auto reply = + transport.createTimer(request); + + assert(reply.disposition == + BackendAgentNativeTimerCreateTransportDisposition:: + rejectedWithoutEffect); + } + + { + SuiteBridgeSvdrpTransportConfig disabled; + disabled.host.clear(); + + SuiteBridgeNativeTimerCreateTransport transport( + disabled); + + const auto reply = + transport.createTimer(validRequest()); + + assert(reply.disposition == + BackendAgentNativeTimerCreateTransportDisposition:: + outcomeUnknown); + + assert(reply.evidenceReference == + "suitebridge:ntcreate:transport-outcome-unknown"); + } + + return 0; +} diff --git a/mk/agent-sources.mk b/mk/agent-sources.mk index 4025ca120..b2b5f0e0b 100644 --- a/mk/agent-sources.mk +++ b/mk/agent-sources.mk @@ -10,6 +10,9 @@ AGENT_SVDRP_TRANSPORT_SRC := \ core/agent/src/SuiteBridgeSvdrpMetadataTransport.cpp \ core/agent/src/SuiteBridgeSvdrpRecordingMetadataTransport.cpp +AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC := \ + core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp + AGENT_NATIVE_TIMER_DELETE_TRANSPORT_SRC := \ core/agent/src/SuiteBridgeSvdrpNativeTimerDeleteTransport.cpp diff --git a/mk/phase64-suitebridge-native-timer-create-transport-tests.mk b/mk/phase64-suitebridge-native-timer-create-transport-tests.mk new file mode 100644 index 000000000..b87289778 --- /dev/null +++ b/mk/phase64-suitebridge-native-timer-create-transport-tests.mk @@ -0,0 +1,23 @@ +.PHONY: test-phase64-suitebridge-native-timer-create-transport-architecture test-phase64-suitebridge-native-timer-create-transport + +test-phase64-suitebridge-native-timer-create-transport-architecture: + python3 tools/check_phase64_suitebridge_native_timer_create_transport.py + +test-phase64-suitebridge-native-timer-create-transport: \ + test-phase64-suitebridge-native-timer-create-transport-architecture \ + test-phase64-native-timer-create-executor + $(BUILD_CXX) $(CXXFLAGS) -pthread -Icore/agent/include -Icore/vdr/include \ + core/agent/src/BackendAgentCommand.cpp \ + core/agent/src/BackendAgentLocalProvider.cpp \ + core/agent/src/BackendAgentNativeProbe.cpp \ + core/agent/src/BackendAgentNativeTimerCreate.cpp \ + core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ + $(AGENT_SVDRP_TRANSPORT_STANDALONE_SRC) \ + $(AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC) \ + core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_suite_bridge_svdrp_native_timer_create_transport + $(BUILD_DIR)/test_suite_bridge_svdrp_native_timer_create_transport + +test-fast: test-phase64-suitebridge-native-timer-create-transport +test-architecture: test-phase64-suitebridge-native-timer-create-transport-architecture diff --git a/tools/check_phase64_native_timer_create_executor.py b/tools/check_phase64_native_timer_create_executor.py index 48f044f7c..1e23e5d20 100644 --- a/tools/check_phase64_native_timer_create_executor.py +++ b/tools/check_phase64_native_timer_create_executor.py @@ -77,11 +77,14 @@ "CREATE capability advertisement is not visibly held closed" ) -if Path( +create_transport_source = Path( "core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp" -).exists(): +) +if create_transport_source.exists() and not Path( + "tools/check_phase64_suitebridge_native_timer_create_transport.py" +).is_file(): raise SystemExit( - "SuiteBridge CREATE transport must not land in executor slice" + "SuiteBridge CREATE transport requires bounded successor guard" ) sources = Path("mk/agent-sources.mk").read_text() diff --git a/tools/check_phase64_suitebridge_native_timer_create_transport.py b/tools/check_phase64_suitebridge_native_timer_create_transport.py new file mode 100755 index 000000000..6e9c975d1 --- /dev/null +++ b/tools/check_phase64_suitebridge_native_timer_create_transport.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def read(path: str) -> str: + target = ROOT / path + if not target.is_file(): + raise SystemExit(f"missing required CREATE transport file: {path}") + return target.read_text(encoding="utf-8") + + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f"missing {label}: {needle}") + + +def forbid(text: str, needle: str, label: str) -> None: + if needle in text: + raise SystemExit(f"forbidden {label}: {needle}") + + +makefile = read("Makefile") +sources = read("mk/agent-sources.mk") +svdrp_header = read( + "core/agent/include/SuiteBridgeSvdrpTransport.h" +) +adapter_header = read( + "core/agent/include/SuiteBridgeNativeTimerCreateTransport.h" +) +transport = read( + "core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp" +) +test = read( + "core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp" +) +mk = read( + "mk/phase64-suitebridge-native-timer-create-transport-tests.mk" +) +client = read( + "core/agent/src/BackendAgentCommandClient.cpp" +) +agent_client = read( + "core/agent/src/BackendAgentClient.cpp" +) +packaged = read( + "packaging/systemd/backend-agent.conf" +) +executor_guard = read( + "tools/check_phase64_native_timer_create_executor.py" +) + +include = ( + "include " + "mk/phase64-suitebridge-native-timer-create-transport-tests.mk" +) +require(makefile, include, "CREATE transport Make include") + +if makefile.count(include) != 1: + raise SystemExit( + "CREATE transport Make include must occur exactly once" + ) + +source_path = ( + "core/agent/src/" + "SuiteBridgeSvdrpNativeTimerCreateTransport.cpp" +) + +require( + sources, + "AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC :=", + "dedicated CREATE transport source set", +) + +if sources.count(source_path) != 1: + raise SystemExit( + "CREATE transport source must occur exactly once" + ) + +create_block = sources.split( + "AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC :=", 1 +)[1].split( + "\n\nAGENT_NATIVE_TIMER_DELETE_TRANSPORT_SRC", 1 +)[0] + +require( + create_block, + source_path, + "dedicated CREATE transport ownership", +) + +command_client_block = sources.split( + "AGENT_COMMAND_CLIENT_SRC :=", 1 +)[1].split( + "\n\nAGENT_CONTROL_PLANE_DOMAIN_SRC", 1 +)[0] + +forbid( + command_client_block, + "AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC", + "CREATE transport in installed CommandClient source set", +) + +forbid( + svdrp_header, + "public IBackendAgentNativeTimerCreateTransport", + "CREATE interface on generic SVDRP transport", +) + +require( + svdrp_header, + "SuiteBridgeCommandReply discoverNativeTimerCreateContract();", + "typed CREATE capability hook", +) + +require( + svdrp_header, + "SuiteBridgeCommandReply executeNativeTimerCreateContract(", + "typed CREATE execute hook", +) + +require( + adapter_header, + "public IBackendAgentNativeTimerCreateTransport", + "dedicated CREATE adapter interface", +) + +require( + adapter_header, + "SuiteBridgeSvdrpTransport transport_;", + "composed SVDRP transport", +) + +for needle, label in ( + ( + '"PLUG suitebridge NTCREATE CAP 1\\r\\n"', + "typed CREATE capability wire", + ), + ( + '"PLUG suitebridge NTCREATE EXEC vdr-suite-native/1 "', + "typed CREATE execute wire", + ), + ( + "hexToken(command.expectedSpecificationFingerprint)", + "specification fingerprint wire encoding", + ), + ( + "hexToken(specification.title)", + "title whitespace-safe wire encoding", + ), + ( + "hexToken(specification.directory)", + "directory whitespace-safe wire encoding", + ), + ( + "selection.ownershipGeneration", + "provider ownership fence", + ), + ( + "selection.providerInstanceEpoch", + "provider epoch fence", + ), + ( + "selection.providerGeneration", + "provider generation fence", + ), + ( + "selection.capabilityRevision", + "capability revision fence", + ), + ( + "request.localStartingPersistedAt", + "durable starting evidence", + ), + ( + "BackendAgentNativeTimerCreateTransportDisposition::", + "typed CREATE disposition mapping", + ), + ( + "acceptedUnverified", + "accepted-unverified mapping", + ), + ( + "outcomeUnknown", + "outcome-unknown mapping", + ), + ( + "rejectedWithoutEffect", + "no-effect mapping", + ), +): + require(transport, needle, label) + +for token in ( + "", + "cTimers", + "Timers->", + '"NEWT"', + '"MODT"', + "RESTfulAPI", + "restfulapi", + "system(", + "popen(", +): + forbid( + transport, + token, + "direct VDR Timer mutation coupling in Agent CREATE transport", + ) + +for needle, label in ( + ( + "Tagesschau 20 Uhr", + "whitespace-bearing title regression", + ), + ( + 'server.request().find(\n "Tagesschau 20 Uhr")', + "raw title absence assertion", + ), + ( + "acceptedUnverified", + "accepted transport regression", + ), + ( + "suitebridge:ntcreate:reply-fence-mismatch", + "post-dispatch fence mismatch regression", + ), + ( + "changed after fingerprint", + "specification fingerprint mismatch regression", + ), +): + require(test, needle, label) + +require( + mk, + "$(AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC)", + "dedicated CREATE transport test source", +) + +require( + mk, + "test-phase64-native-timer-create-executor", + "CREATE executor regression dependency", +) + +require( + executor_guard, + "SuiteBridge CREATE transport requires bounded successor guard", + "executor successor-awareness", +) + +availability_start = client.find( + "CommandAvailability availableCommands" +) +availability_end = client.find( + "bool reconcileBackendAgentCommandState", + availability_start, +) + +if availability_start < 0 or availability_end < 0: + raise SystemExit( + "CREATE advertisement function boundary not found" + ) + +availability = client[ + availability_start:availability_end +] + +require( + availability, + "kBackendAgentNativeTimerCreateCommandType", + "CREATE advertisement fence", +) + +require( + availability, + "continue;", + "CREATE advertisement suppression", +) + +forbid( + agent_client, + "SuiteBridgeNativeTimerCreateTransport", + "production CREATE adapter construction", +) + +forbid( + agent_client, + "nativeTimerCreateTransport", + "production CREATE transport injection", +) + +forbid( + agent_client, + "vdr.timer.create", + "production CREATE Agent configuration", +) + +forbid( + packaged, + "vdr.timer.create", + "packaged CREATE configuration", +) + +# This slice deliberately stops before any SuiteBridge plugin command family +# or VDR Timer mutation implementation exists. +for plugin_path in ( + "vdr-plugin-suite-bridge/suitebridge_native_timer_create.h", + "vdr-plugin-suite-bridge/suitebridge_native_timer_create.cpp", + "vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp", +): + if (ROOT / plugin_path).exists(): + raise SystemExit( + "CREATE plugin mutation boundary landed too early: " + + plugin_path + ) + +plugin_svdrp = read( + "vdr-plugin-suite-bridge/suitebridge_svdrp.cpp" +) + +forbid( + plugin_svdrp, + "NTCREATE", + "CREATE plugin command family before dedicated plugin slice", +) + +print( + "Phase 64 private SuiteBridge native Timer CREATE " + "transport architecture guard passed" +) From 77004d2e7eb121f4274e131d9bb97b5cc43e3e9d Mon Sep 17 00:00:00 2001 From: Holger Schvestka Date: Sun, 16 Aug 2026 22:37:07 +0200 Subject: [PATCH 43/85] WIP: add SuiteBridge NTCREATE command service --- Makefile | 1 + ...tive-timer-create-command-service-tests.mk | 16 + ...dge_native_timer_create_command_service.py | 81 +++ ...itebridge_native_timer_create_transport.py | 29 +- vdr-plugin-suite-bridge/Makefile | 17 +- vdr-plugin-suite-bridge/suitebridge.cpp | 1 + vdr-plugin-suite-bridge/suitebridge.h | 2 + .../suitebridge_native_timer_create.cpp | 567 ++++++++++++++++++ .../suitebridge_native_timer_create.h | 113 ++++ vdr-plugin-suite-bridge/suitebridge_svdrp.cpp | 6 + .../test_suitebridge_native_timer_create.cpp | 289 +++++++++ 11 files changed, 1110 insertions(+), 12 deletions(-) create mode 100644 mk/phase64-suitebridge-native-timer-create-command-service-tests.mk create mode 100644 tools/check_phase64_suitebridge_native_timer_create_command_service.py create mode 100644 vdr-plugin-suite-bridge/suitebridge_native_timer_create.cpp create mode 100644 vdr-plugin-suite-bridge/suitebridge_native_timer_create.h create mode 100644 vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_create.cpp diff --git a/Makefile b/Makefile index cb3e988d4..60c70f51f 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ include mk/phase64-native-timer-create-activation-tests.mk include mk/phase64-native-timer-create-local-state-tests.mk include mk/phase64-native-timer-create-executor-tests.mk include mk/phase64-suitebridge-native-timer-create-transport-tests.mk +include mk/phase64-suitebridge-native-timer-create-command-service-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk diff --git a/mk/phase64-suitebridge-native-timer-create-command-service-tests.mk b/mk/phase64-suitebridge-native-timer-create-command-service-tests.mk new file mode 100644 index 000000000..33be3f818 --- /dev/null +++ b/mk/phase64-suitebridge-native-timer-create-command-service-tests.mk @@ -0,0 +1,16 @@ +.PHONY: test-phase64-suitebridge-native-timer-create-command-service-architecture test-phase64-suitebridge-native-timer-create-command-service + +test-phase64-suitebridge-native-timer-create-command-service-architecture: + python3 tools/check_phase64_suitebridge_native_timer_create_command_service.py + +test-phase64-suitebridge-native-timer-create-command-service: \ + test-phase64-suitebridge-native-timer-create-command-service-architecture + $(BUILD_CXX) $(CXXFLAGS) -pthread -Ivdr-plugin-suite-bridge \ + vdr-plugin-suite-bridge/suitebridge_native_timer_create.cpp \ + vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_create.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_suitebridge_native_timer_create + $(BUILD_DIR)/test_suitebridge_native_timer_create + +test-fast: test-phase64-suitebridge-native-timer-create-command-service +test-architecture: test-phase64-suitebridge-native-timer-create-command-service-architecture diff --git a/tools/check_phase64_suitebridge_native_timer_create_command_service.py b/tools/check_phase64_suitebridge_native_timer_create_command_service.py new file mode 100644 index 000000000..a3c0ff4fd --- /dev/null +++ b/tools/check_phase64_suitebridge_native_timer_create_command_service.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def read(path: str) -> str: + target = ROOT / path + if not target.is_file(): + raise SystemExit(f"missing CREATE command-service file: {path}") + return target.read_text(encoding="utf-8") + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f"missing {label}: {needle}") + +def forbid(text: str, needle: str, label: str) -> None: + if needle in text: + raise SystemExit(f"forbidden {label}: {needle}") + +header = read("vdr-plugin-suite-bridge/suitebridge_native_timer_create.h") +source = read("vdr-plugin-suite-bridge/suitebridge_native_timer_create.cpp") +test = read("vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_create.cpp") +plugin = read("vdr-plugin-suite-bridge/suitebridge.h") +svdrp = read("vdr-plugin-suite-bridge/suitebridge_svdrp.cpp") +plugin_makefile = read("vdr-plugin-suite-bridge/Makefile") +client = read("core/agent/src/BackendAgentCommandClient.cpp") +agent_client = read("core/agent/src/BackendAgentClient.cpp") + +for needle in ( + "timerAssignmentId", "expectedAssignmentRevision", + "expectedIntentRevision", "assignmentEpoch", "nativeTimerBindingId", + "expectedSpecificationFingerprint", "localStartingPersistedAt", + "channelId", "title", "directory", "day", "weekdays", + "startTime", "endTime", "priority", "lifetime", "enabled", "vps", +): + require(header, needle, "CREATE request field") + +for needle in ( + 'strcasecmp(command, "NTCREATE")', 'values.front() == "CAP"', + 'values.front() == "EXEC"', "values.size() != 42", + "hexValue(", "canonicalRequest(", "replayByOperationId_", + '"replay_conflict"', '"in_progress"', '"ledger_full"', + "localStartingPersistedAt >= request.controlPlaneClaimedAt", +): + require(source, needle, "CREATE parser/replay/fence contract") + +for token in ( + "", "cTimers", "Timers->", '"NEWT"', '"MODT"', + "RESTfulAPI", "restfulapi", "system(", "popen(", +): + forbid(source, token, "direct CREATE mutation") + +require(test, "Tagesschau 20 Uhr", "decoded title regression") +require(plugin, "SuiteBridgeNativeTimerCreateService nativeTimerCreate_", + "private CREATE service owner") +require(svdrp, "nativeTimerCreate_.Handle(Command, Option)", + "private CREATE dispatch") +forbid(svdrp[svdrp.find("SVDRPHelpPages"):svdrp.find("SVDRPCommand")], + "NTCREATE", "public CREATE help advertisement") +require(plugin_makefile, "suitebridge_native_timer_create.o", + "CREATE service plugin object") +require(plugin_makefile, "test-native-timer-create", + "CREATE plugin unit test") + +if (ROOT / "vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp").exists(): + raise SystemExit("CREATE VDR mutation successor landed too early") + +availability = client[ + client.find("CommandAvailability availableCommands"): + client.find("bool reconcileBackendAgentCommandState") +] +require(availability, "kBackendAgentNativeTimerCreateCommandType", + "CREATE advertisement fence") +require(availability, "continue;", "CREATE advertisement suppression") +forbid(agent_client, "SuiteBridgeNativeTimerCreateTransport", + "production CREATE adapter construction") +forbid(agent_client, "nativeTimerCreateTransport", + "production CREATE transport injection") + +print("Phase 64 SuiteBridge NTCREATE command service architecture guard passed") diff --git a/tools/check_phase64_suitebridge_native_timer_create_transport.py b/tools/check_phase64_suitebridge_native_timer_create_transport.py index 6e9c975d1..d10f6e335 100755 --- a/tools/check_phase64_suitebridge_native_timer_create_transport.py +++ b/tools/check_phase64_suitebridge_native_timer_create_transport.py @@ -305,27 +305,40 @@ def forbid(text: str, needle: str, label: str) -> None: "packaged CREATE configuration", ) -# This slice deliberately stops before any SuiteBridge plugin command family -# or VDR Timer mutation implementation exists. +# The private command-service successor may now own the parser/replay layer. for plugin_path in ( "vdr-plugin-suite-bridge/suitebridge_native_timer_create.h", "vdr-plugin-suite-bridge/suitebridge_native_timer_create.cpp", - "vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp", ): - if (ROOT / plugin_path).exists(): + if not (ROOT / plugin_path).is_file(): raise SystemExit( - "CREATE plugin mutation boundary landed too early: " - + plugin_path + "missing CREATE command-service successor: " + plugin_path ) +if ( + ROOT + / "vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp" +).exists(): + raise SystemExit("CREATE VDR mutation boundary landed too early") + plugin_svdrp = read( "vdr-plugin-suite-bridge/suitebridge_svdrp.cpp" ) -forbid( +require( plugin_svdrp, + "nativeTimerCreate_.Handle(Command, Option)", + "private CREATE command-service dispatch", +) + +help_start = plugin_svdrp.find("SVDRPHelpPages") +command_start = plugin_svdrp.find("SVDRPCommand", help_start) +if help_start < 0 or command_start < 0: + raise SystemExit("SuiteBridge help boundary not found") +forbid( + plugin_svdrp[help_start:command_start], "NTCREATE", - "CREATE plugin command family before dedicated plugin slice", + "public CREATE Help advertisement", ) print( diff --git a/vdr-plugin-suite-bridge/Makefile b/vdr-plugin-suite-bridge/Makefile index 40f27ca76..2a7060a30 100644 --- a/vdr-plugin-suite-bridge/Makefile +++ b/vdr-plugin-suite-bridge/Makefile @@ -20,7 +20,7 @@ SOFILE = libvdr-$(PLUGIN).so DEFINES += -DPLUGIN_NAME_I18N='"$(PLUGIN)"' -OBJS = $(PLUGIN).o suitebridge_svdrp.o suitebridge_native_probe.o suitebridge_native_timer_delete.o suitebridge_native_timer_delete_vdr.o suitebridge_epg_command_handler.o suitebridge_epg_type_snapshot_command.o suitebridge_recording_metadata_command.o suitebridge_lifecycle.o suitebridge_capabilities.o suitebridge_capability_discovery.o suitebridge_counter_continuity.o suitebridge_status_snapshot.o suitebridge_local_contract.o suitebridge_svdrp_contract.o suitebridge_status_events.o suitebridge_status_monitor.o suitebridge_artwork_reference.o suitebridge_image_dimensions.o suitebridge_tvscraper_adapter.o suitebridge_tvscraper_type_adapter.o suitebridge_epg_artwork_contract.o suitebridge_epg_metadata_contract.o suitebridge_epg_type_snapshot_contract.o suitebridge_recording_identity.o suitebridge_recording_metadata.o suitebridge_recording_metadata_contract.o suitebridge_tvscraper_recording_adapter.o +OBJS = $(PLUGIN).o suitebridge_svdrp.o suitebridge_native_probe.o suitebridge_native_timer_create.o suitebridge_native_timer_delete.o suitebridge_native_timer_delete_vdr.o suitebridge_epg_command_handler.o suitebridge_epg_type_snapshot_command.o suitebridge_recording_metadata_command.o suitebridge_lifecycle.o suitebridge_capabilities.o suitebridge_capability_discovery.o suitebridge_counter_continuity.o suitebridge_status_snapshot.o suitebridge_local_contract.o suitebridge_svdrp_contract.o suitebridge_status_events.o suitebridge_status_monitor.o suitebridge_artwork_reference.o suitebridge_image_dimensions.o suitebridge_tvscraper_adapter.o suitebridge_tvscraper_type_adapter.o suitebridge_epg_artwork_contract.o suitebridge_epg_metadata_contract.o suitebridge_epg_type_snapshot_contract.o suitebridge_recording_identity.o suitebridge_recording_metadata.o suitebridge_recording_metadata_contract.o suitebridge_tvscraper_recording_adapter.o DEPFILE = .dependencies LIFECYCLE_TEST = /tmp/test_suitebridge_lifecycle CAPABILITIES_TEST = /tmp/test_suitebridge_capabilities @@ -31,6 +31,7 @@ STATUS_SNAPSHOT_TEST = /tmp/test_suitebridge_status_snapshot LOCAL_CONTRACT_TEST = /tmp/test_suitebridge_local_contract SVDRP_CONTRACT_TEST = /tmp/test_suitebridge_svdrp_contract NATIVE_PROBE_TEST = /tmp/test_suitebridge_native_probe +NATIVE_TIMER_CREATE_TEST = /tmp/test_suitebridge_native_timer_create NATIVE_TIMER_DELETE_TEST = /tmp/test_suitebridge_native_timer_delete ARTWORK_REFERENCE_TEST = /tmp/test_suitebridge_artwork_reference IMAGE_DIMENSIONS_TEST = /tmp/test_suitebridge_image_dimensions @@ -40,7 +41,7 @@ EPG_TYPE_SNAPSHOT_CONTRACT_TEST = /tmp/test_suitebridge_epg_type_snapshot_contra RECORDING_IDENTITY_TEST = /tmp/test_suitebridge_recording_identity RECORDING_METADATA_CONTRACT_TEST = /tmp/test_suitebridge_recording_metadata_contract -.PHONY: all install install-lib check check-contract check-capabilities-contract check-capability-discovery-contract check-counter-continuity-contract check-status-events-contract check-status-snapshot-contract check-local-contract-contract check-svdrp-contract check-recording-metadata-contract check-version test-lifecycle test-capabilities test-capability-discovery test-counter-continuity test-status-events test-status-snapshot test-local-contract test-svdrp-contract test-native-probe test-native-timer-delete test-artwork-reference test-image-dimensions test-epg-artwork-contract test-epg-metadata-contract test-epg-type-snapshot-contract test-recording-identity test-recording-metadata-contract check-build clean +.PHONY: all install install-lib check check-contract check-capabilities-contract check-capability-discovery-contract check-counter-continuity-contract check-status-events-contract check-status-snapshot-contract check-local-contract-contract check-svdrp-contract check-recording-metadata-contract check-version test-lifecycle test-capabilities test-capability-discovery test-counter-continuity test-status-events test-status-snapshot test-local-contract test-svdrp-contract test-native-probe test-native-timer-create test-native-timer-delete test-artwork-reference test-image-dimensions test-epg-artwork-contract test-epg-metadata-contract test-epg-type-snapshot-contract test-recording-identity test-recording-metadata-contract check-build clean all: $(SOFILE) @@ -170,6 +171,14 @@ test-native-probe: $(NATIVE_PROBE_TEST) rm -f $(NATIVE_PROBE_TEST) +test-native-timer-create: + $(CXX) -std=c++17 -Wall -Wextra -pedantic -I. \ + suitebridge_native_timer_create.cpp \ + tests/test_suitebridge_native_timer_create.cpp \ + -o $(NATIVE_TIMER_CREATE_TEST) + $(NATIVE_TIMER_CREATE_TEST) + rm -f $(NATIVE_TIMER_CREATE_TEST) + test-native-timer-delete: $(CXX) -std=c++17 -Wall -Wextra -pedantic -I. \ suitebridge_native_timer_delete.cpp \ @@ -243,7 +252,7 @@ check-build: $(SOFILE) test -s $(SOFILE) readelf -h $(SOFILE) >/dev/null -check: check-contract check-capabilities-contract check-capability-discovery-contract check-counter-continuity-contract check-status-events-contract check-status-snapshot-contract check-local-contract-contract check-svdrp-contract check-recording-metadata-contract check-version test-lifecycle test-capabilities test-capability-discovery test-counter-continuity test-status-events test-status-snapshot test-local-contract test-svdrp-contract test-native-probe test-native-timer-delete test-artwork-reference test-image-dimensions test-epg-artwork-contract test-epg-metadata-contract test-epg-type-snapshot-contract test-recording-identity test-recording-metadata-contract check-build +check: check-contract check-capabilities-contract check-capability-discovery-contract check-counter-continuity-contract check-status-events-contract check-status-snapshot-contract check-local-contract-contract check-svdrp-contract check-recording-metadata-contract check-version test-lifecycle test-capabilities test-capability-discovery test-counter-continuity test-status-events test-status-snapshot test-local-contract test-svdrp-contract test-native-probe test-native-timer-create test-native-timer-delete test-artwork-reference test-image-dimensions test-epg-artwork-contract test-epg-metadata-contract test-epg-type-snapshot-contract test-recording-identity test-recording-metadata-contract check-build clean: - rm -f $(OBJS) $(DEPFILE) $(SOFILE) $(LIFECYCLE_TEST) $(CAPABILITIES_TEST) $(CAPABILITY_DISCOVERY_TEST) $(COUNTER_CONTINUITY_TEST) $(STATUS_EVENTS_TEST) $(STATUS_SNAPSHOT_TEST) $(LOCAL_CONTRACT_TEST) $(SVDRP_CONTRACT_TEST) $(NATIVE_PROBE_TEST) $(NATIVE_TIMER_DELETE_TEST) $(ARTWORK_REFERENCE_TEST) $(IMAGE_DIMENSIONS_TEST) $(EPG_ARTWORK_CONTRACT_TEST) $(EPG_METADATA_CONTRACT_TEST) $(EPG_TYPE_SNAPSHOT_CONTRACT_TEST) $(RECORDING_IDENTITY_TEST) $(RECORDING_METADATA_CONTRACT_TEST) *.tgz core* *~ ._* + rm -f $(OBJS) $(DEPFILE) $(SOFILE) $(LIFECYCLE_TEST) $(CAPABILITIES_TEST) $(CAPABILITY_DISCOVERY_TEST) $(COUNTER_CONTINUITY_TEST) $(STATUS_EVENTS_TEST) $(STATUS_SNAPSHOT_TEST) $(LOCAL_CONTRACT_TEST) $(SVDRP_CONTRACT_TEST) $(NATIVE_PROBE_TEST) $(NATIVE_TIMER_CREATE_TEST) $(NATIVE_TIMER_DELETE_TEST) $(ARTWORK_REFERENCE_TEST) $(IMAGE_DIMENSIONS_TEST) $(EPG_ARTWORK_CONTRACT_TEST) $(EPG_METADATA_CONTRACT_TEST) $(EPG_TYPE_SNAPSHOT_CONTRACT_TEST) $(RECORDING_IDENTITY_TEST) $(RECORDING_METADATA_CONTRACT_TEST) *.tgz core* *~ ._* diff --git a/vdr-plugin-suite-bridge/suitebridge.cpp b/vdr-plugin-suite-bridge/suitebridge.cpp index 97d24bf36..dcf734660 100644 --- a/vdr-plugin-suite-bridge/suitebridge.cpp +++ b/vdr-plugin-suite-bridge/suitebridge.cpp @@ -7,6 +7,7 @@ cPluginSuiteBridge::cPluginSuiteBridge() : nativeProbe_(GenerateSuiteBridgePluginInstanceEpoch()), + nativeTimerCreate_(nativeProbe_.PluginInstanceEpoch()), nativeTimerDelete_( nativeProbe_.PluginInstanceEpoch(), &nativeTimerDeleteVdrMutation_) diff --git a/vdr-plugin-suite-bridge/suitebridge.h b/vdr-plugin-suite-bridge/suitebridge.h index 690cd0256..41c283acb 100644 --- a/vdr-plugin-suite-bridge/suitebridge.h +++ b/vdr-plugin-suite-bridge/suitebridge.h @@ -3,6 +3,7 @@ #include "suitebridge_lifecycle.h" #include "suitebridge_native_probe.h" +#include "suitebridge_native_timer_create.h" #include "suitebridge_native_timer_delete.h" #include "suitebridge_native_timer_delete_vdr.h" #include "suitebridge_status_monitor.h" @@ -32,6 +33,7 @@ class cPluginSuiteBridge final : public cPlugin { SuiteBridgeLifecycle lifecycle_; SuiteBridgeStatusMonitor statusMonitor_; SuiteBridgeNativeProbeService nativeProbe_; + SuiteBridgeNativeTimerCreateService nativeTimerCreate_; SuiteBridgeNativeTimerDeleteVdrMutationCallback nativeTimerDeleteVdrMutation_; SuiteBridgeNativeTimerDeleteService nativeTimerDelete_; }; diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_create.cpp b/vdr-plugin-suite-bridge/suitebridge_native_timer_create.cpp new file mode 100644 index 000000000..b404eaeea --- /dev/null +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_create.cpp @@ -0,0 +1,567 @@ +#include "suitebridge_native_timer_create.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr const char *NativeOperation = "vdr.timer.create"; +constexpr const char *AuthorityDomain = "vdr.timer"; +constexpr const char *ProviderId = "suitebridge:local"; +constexpr const char *ProviderKind = "suitebridge"; +constexpr const char *CapabilityProtocol = "vdr-suite-ntcreate-cap/1"; +constexpr const char *ResultProtocol = "vdr-suite-ntcreate-result/1"; +constexpr std::uint64_t NativeOperationSchema = 1; +constexpr std::uint64_t ProviderGeneration = 1; +constexpr std::uint64_t CapabilityRevision = 1; +constexpr std::size_t NativeFingerprintTokenLength = 71; +constexpr int SuccessReplyCode = 900; +constexpr int MalformedReplyCode = 501; +constexpr int StaleReplyCode = 555; +constexpr int DisabledReplyCode = 556; +constexpr int AcceptedUnverifiedReplyCode = 557; +constexpr int OutcomeUnknownReplyCode = 558; +constexpr int ReplayConflictReplyCode = 559; +constexpr int ReplayLedgerFullReplyCode = 560; + +bool safeToken(const std::string &value, std::size_t maximum = 512) +{ + return !value.empty() && value.size() <= maximum && + std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isalnum(character) != 0 || character == '-' || + character == '_' || character == '.' || character == ':'; + }); +} + +bool safeFingerprintToken(const std::string &value) +{ + if (value.size() != NativeFingerprintTokenLength || + value.compare(0, 7, "sha256:") != 0) { + return false; + } + return std::all_of( + value.begin() + 7, + value.end(), + [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f'); + }); +} + +bool unsignedValue(const std::string &value, std::uint64_t &parsed) +{ + if (value.empty() || value.size() > 19) return false; + parsed = 0; + for (unsigned char character : value) { + if (character < '0' || character > '9') return false; + const unsigned digit = static_cast(character - '0'); + if (parsed > + (static_cast(std::numeric_limits::max()) - digit) / 10U) { + return false; + } + parsed = parsed * 10U + digit; + } + return parsed > 0; +} + +std::vector split(const char *option) +{ + std::vector values; + if (option == nullptr) return values; + const std::string input(option); + std::size_t position = 0; + while (position < input.size()) { + while (position < input.size() && input[position] == ' ') ++position; + if (position == input.size()) break; + const std::size_t end = input.find(' ', position); + values.push_back(input.substr( + position, end == std::string::npos ? std::string::npos : end - position)); + if (values.size() > 48) return {}; + if (end == std::string::npos) break; + position = end + 1; + } + return values; +} + +bool booleanValue(const std::string &value, bool &parsed) +{ + if (value == "0") { + parsed = false; + return true; + } + if (value == "1") { + parsed = true; + return true; + } + return false; +} + +bool hexValue(const std::string &value, std::string &decoded) +{ + decoded.clear(); + if (value == "-") return true; + if (value.empty() || value.size() > 2048 || value.size() % 2 != 0) return false; + decoded.reserve(value.size() / 2); + for (std::size_t index = 0; index < value.size(); index += 2) { + const auto nibble = [](unsigned char character, unsigned &result) { + if (character >= '0' && character <= '9') { + result = static_cast(character - '0'); + return true; + } + if (character >= 'a' && character <= 'f') { + result = 10U + static_cast(character - 'a'); + return true; + } + return false; + }; + unsigned high = 0; + unsigned low = 0; + if (!nibble(value[index], high) || !nibble(value[index + 1], low)) return false; + const unsigned char decodedCharacter = + static_cast((high << 4U) | low); + if (decodedCharacter == 0 || decodedCharacter == '\r' || + decodedCharacter == '\n') { + return false; + } + decoded.push_back(static_cast(decodedCharacter)); + } + return true; +} + +bool parseExecute( + const char *option, + SuiteBridgeNativeTimerCreateRequest &request) +{ + const std::vector values = split(option); + if (values.size() != 42 || values[0] != "EXEC" || + values[1] != "vdr-suite-native/1" || + values[2] != NativeOperation || values[3] != "1") { + return false; + } + + request.commandId = values[4]; + request.requestFingerprint = values[5]; + request.operationId = values[6]; + request.operationRevision = values[7]; + request.timerAssignmentId = values[8]; + request.expectedAssignmentRevision = values[9]; + request.expectedIntentRevision = values[10]; + request.nativeTimerBindingId = values[12]; + request.jobId = values[14]; + request.attemptId = values[15]; + request.backendId = values[17]; + request.agentId = values[18]; + request.agentInstanceId = values[19]; + request.authorityDomain = values[22]; + request.providerId = values[23]; + request.providerKind = values[24]; + request.providerInstanceEpoch = values[26]; + request.requiredCapability = values[29]; + + return unsignedValue(values[11], request.assignmentEpoch) && + hexValue(values[13], request.expectedSpecificationFingerprint) && + unsignedValue(values[16], request.claimEpoch) && + unsignedValue(values[20], request.backendGeneration) && + unsignedValue(values[21], request.controlPlaneClaimedAt) && + unsignedValue(values[25], request.ownershipGeneration) && + unsignedValue(values[27], request.providerGeneration) && + unsignedValue(values[28], request.capabilityRevision) && + unsignedValue(values[30], request.localStartingPersistedAt) && + hexValue(values[31], request.channelId) && + hexValue(values[32], request.title) && + hexValue(values[33], request.directory) && + hexValue(values[34], request.day) && + hexValue(values[35], request.weekdays) && + hexValue(values[36], request.startTime) && + hexValue(values[37], request.endTime) && + unsignedValue(values[38], request.priority) && + unsignedValue(values[39], request.lifetime) && + booleanValue(values[40], request.enabled) && + booleanValue(values[41], request.vps) && + safeToken(request.commandId, 192) && + safeToken(request.requestFingerprint) && + safeToken(request.operationId, 192) && + safeToken(request.operationRevision, 192) && + safeToken(request.timerAssignmentId, 192) && + safeToken(request.expectedAssignmentRevision, 192) && + safeToken(request.expectedIntentRevision, 192) && + safeToken(request.nativeTimerBindingId, 192) && + safeFingerprintToken(request.expectedSpecificationFingerprint) && + !request.channelId.empty() && + request.title.size() <= 1024 && request.directory.size() <= 1024 && + request.day.size() <= 64 && request.weekdays.size() <= 64 && + request.startTime.size() <= 16 && request.endTime.size() <= 16 && + safeToken(request.jobId, 192) && safeToken(request.attemptId, 192) && + safeToken(request.backendId, 192) && safeToken(request.agentId, 192) && + safeToken(request.agentInstanceId, 192) && + safeToken(request.authorityDomain, 192) && + safeToken(request.providerId, 192) && safeToken(request.providerKind, 192) && + safeToken(request.providerInstanceEpoch, 192) && + safeToken(request.requiredCapability, 192); +} + +void appendCanonical(std::ostringstream &output, const std::string &value) +{ + output << value.size() << ':' << value << '|'; +} + +void appendCanonical(std::ostringstream &output, std::uint64_t value) +{ + output << value << '|'; +} + +std::string canonicalRequest(const SuiteBridgeNativeTimerCreateRequest &request) +{ + std::ostringstream canonical; + appendCanonical(canonical, request.commandId); + appendCanonical(canonical, request.requestFingerprint); + appendCanonical(canonical, request.operationId); + appendCanonical(canonical, request.operationRevision); + appendCanonical(canonical, request.timerAssignmentId); + appendCanonical(canonical, request.expectedAssignmentRevision); + appendCanonical(canonical, request.expectedIntentRevision); + appendCanonical(canonical, request.assignmentEpoch); + appendCanonical(canonical, request.nativeTimerBindingId); + appendCanonical(canonical, request.expectedSpecificationFingerprint); + appendCanonical(canonical, request.jobId); + appendCanonical(canonical, request.attemptId); + appendCanonical(canonical, request.claimEpoch); + appendCanonical(canonical, request.backendId); + appendCanonical(canonical, request.agentId); + appendCanonical(canonical, request.agentInstanceId); + appendCanonical(canonical, request.backendGeneration); + appendCanonical(canonical, request.controlPlaneClaimedAt); + appendCanonical(canonical, request.authorityDomain); + appendCanonical(canonical, request.providerId); + appendCanonical(canonical, request.providerKind); + appendCanonical(canonical, request.ownershipGeneration); + appendCanonical(canonical, request.providerInstanceEpoch); + appendCanonical(canonical, request.providerGeneration); + appendCanonical(canonical, request.capabilityRevision); + appendCanonical(canonical, request.requiredCapability); + appendCanonical(canonical, request.localStartingPersistedAt); + appendCanonical(canonical, request.channelId); + appendCanonical(canonical, request.title); + appendCanonical(canonical, request.directory); + appendCanonical(canonical, request.day); + appendCanonical(canonical, request.weekdays); + appendCanonical(canonical, request.startTime); + appendCanonical(canonical, request.endTime); + appendCanonical(canonical, request.priority); + appendCanonical(canonical, request.lifetime); + appendCanonical(canonical, request.enabled ? 1U : 0U); + appendCanonical(canonical, request.vps ? 1U : 0U); + return canonical.str(); +} + +SuiteBridgeCommandResult genericRejection(int replyCode, const char *reason) +{ + SuiteBridgeCommandResult result; + result.handled = true; + result.replyCode = replyCode; + result.payload = std::string("vdr-suite-ntcreate-rejected/1 ") + reason; + return result; +} + +SuiteBridgeCommandResult typedResult( + int replyCode, + const SuiteBridgeNativeTimerCreateRequest &request, + const std::string &pluginInstanceEpoch, + const char *disposition, + const char *reason, + const std::string &evidenceReference) +{ + SuiteBridgeCommandResult result; + result.handled = true; + result.replyCode = replyCode; + std::ostringstream payload; + payload << ResultProtocol << ' ' << request.commandId << ' ' + << request.requestFingerprint << ' ' << NativeOperation << ' ' + << NativeOperationSchema << ' ' << pluginInstanceEpoch << ' ' + << ProviderGeneration << ' ' << CapabilityRevision << ' ' + << disposition << ' ' << reason << ' ' << evidenceReference; + result.payload = payload.str(); + return result; +} + +SuiteBridgeCommandResult typedRejection( + int replyCode, + const SuiteBridgeNativeTimerCreateRequest &request, + const std::string &pluginInstanceEpoch, + const char *reason, + const std::string &evidenceReference) +{ + return typedResult( + replyCode, + request, + pluginInstanceEpoch, + "rejected_without_effect", + reason, + evidenceReference); +} + +SuiteBridgeCommandResult mutationReply( + const SuiteBridgeNativeTimerCreateRequest &request, + const std::string &pluginInstanceEpoch, + const SuiteBridgeNativeTimerCreateMutationResult &mutationResult) +{ + switch (mutationResult.disposition) { + case SuiteBridgeNativeTimerCreateMutationDisposition::AppliedUnverified: + return typedResult( + AcceptedUnverifiedReplyCode, + request, + pluginInstanceEpoch, + "accepted_unverified", + "callback_applied", + mutationResult.evidenceReference); + case SuiteBridgeNativeTimerCreateMutationDisposition::RejectedWithoutEffect: + return typedResult( + DisabledReplyCode, + request, + pluginInstanceEpoch, + "rejected_without_effect", + "callback_rejected", + mutationResult.evidenceReference); + case SuiteBridgeNativeTimerCreateMutationDisposition::OutcomeUnknown: + return typedResult( + OutcomeUnknownReplyCode, + request, + pluginInstanceEpoch, + "outcome_unknown", + "callback_unknown", + mutationResult.evidenceReference); + } + return typedResult( + OutcomeUnknownReplyCode, + request, + pluginInstanceEpoch, + "outcome_unknown", + "callback_invalid", + "ntcreate:callback-invalid:" + request.commandId); +} + +SuiteBridgeNativeTimerCreateMutationResult normalizedCallbackResult( + const SuiteBridgeNativeTimerCreateRequest &request, + SuiteBridgeNativeTimerCreateMutationResult result) +{ + if (!safeToken(result.evidenceReference)) { + result.disposition = + SuiteBridgeNativeTimerCreateMutationDisposition::OutcomeUnknown; + result.evidenceReference = "ntcreate:callback-invalid:" + request.commandId; + } + return result; +} +} // namespace + +SuiteBridgeNativeTimerCreateService::SuiteBridgeNativeTimerCreateService( + std::string pluginInstanceEpoch, + ISuiteBridgeNativeTimerCreateMutationCallback *mutationCallback, + std::size_t maximumReplayEntries) + : pluginInstanceEpoch_(std::move(pluginInstanceEpoch)), + mutationCallback_(mutationCallback), + maximumReplayEntries_(maximumReplayEntries) +{ + if (!safeToken(pluginInstanceEpoch_, 192)) pluginInstanceEpoch_ = "pie_invalid"; +} + +bool SuiteBridgeNativeTimerCreateService::ExecutionConfigured() const noexcept +{ + return mutationCallback_ != nullptr; +} + +SuiteBridgeCommandResult SuiteBridgeNativeTimerCreateService::Handle( + const char *command, + const char *option) +{ + if (command == nullptr || strcasecmp(command, "NTCREATE") != 0) return {}; + const std::vector values = split(option); + if (values.empty()) return genericRejection(MalformedReplyCode, "malformed"); + if (values.front() == "CAP") return capability(option); + if (values.front() == "EXEC") return execute(option); + return genericRejection(MalformedReplyCode, "unsupported"); +} + +SuiteBridgeCommandResult SuiteBridgeNativeTimerCreateService::capability( + const char *option) const +{ + const std::vector values = split(option); + if (values.size() != 2 || values[0] != "CAP" || values[1] != "1") { + return genericRejection(MalformedReplyCode, "capability-schema-unsupported"); + } + + const char *state = ExecutionConfigured() ? "enabled" : "disabled"; + SuiteBridgeCommandResult result; + result.handled = true; + result.replyCode = SuccessReplyCode; + std::ostringstream payload; + payload << CapabilityProtocol << ' ' << NativeOperation << ' ' + << NativeOperationSchema << " timer-create " << state << ' ' + << ProviderKind << ' ' << pluginInstanceEpoch_ << ' ' + << ProviderGeneration << ' ' << CapabilityRevision << ' ' << state; + result.payload = payload.str(); + return result; +} + +SuiteBridgeCommandResult SuiteBridgeNativeTimerCreateService::execute( + const char *option) +{ + SuiteBridgeNativeTimerCreateRequest request; + if (!parseExecute(option, request)) { + return genericRejection(MalformedReplyCode, "execute-malformed"); + } + + const bool currentFence = + request.authorityDomain == AuthorityDomain && + request.providerId == ProviderId && request.providerKind == ProviderKind && + request.providerInstanceEpoch == pluginInstanceEpoch_ && + request.providerGeneration == ProviderGeneration && + request.capabilityRevision == CapabilityRevision && + request.requiredCapability == NativeOperation && + request.localStartingPersistedAt >= request.controlPlaneClaimedAt; + + if (!currentFence) { + return typedRejection( + StaleReplyCode, + request, + pluginInstanceEpoch_, + "stale", + "ntcreate:stale:" + request.commandId); + } + + if (!ExecutionConfigured()) { + return typedRejection( + DisabledReplyCode, + request, + pluginInstanceEpoch_, + "disabled", + "ntcreate:disabled:" + request.commandId); + } + + bool replayed = false; + bool inProgress = false; + bool conflict = false; + bool ledgerFull = false; + const std::string canonical = canonicalRequest(request); + const SuiteBridgeNativeTimerCreateMutationResult result = executeReserved( + request, + canonical, + replayed, + inProgress, + conflict, + ledgerFull); + + if (conflict) { + return typedRejection( + ReplayConflictReplyCode, + request, + pluginInstanceEpoch_, + "replay_conflict", + "ntcreate:replay-conflict:" + request.commandId); + } + if (ledgerFull) { + return typedRejection( + ReplayLedgerFullReplyCode, + request, + pluginInstanceEpoch_, + "ledger_full", + "ntcreate:ledger-full:" + request.commandId); + } + if (inProgress) { + return typedResult( + OutcomeUnknownReplyCode, + request, + pluginInstanceEpoch_, + "outcome_unknown", + "in_progress", + "ntcreate:in-progress:" + request.commandId); + } + + (void)replayed; + return mutationReply(request, pluginInstanceEpoch_, result); +} + +SuiteBridgeNativeTimerCreateMutationResult +SuiteBridgeNativeTimerCreateService::executeReserved( + const SuiteBridgeNativeTimerCreateRequest &request, + const std::string &canonical, + bool &replayed, + bool &inProgress, + bool &conflict, + bool &ledgerFull) +{ + replayed = false; + inProgress = false; + conflict = false; + ledgerFull = false; + + { + std::lock_guard lock(replayMutex_); + + const auto commandOwner = operationByCommandId_.find(request.commandId); + if (commandOwner != operationByCommandId_.end() && + commandOwner->second != request.operationId) { + conflict = true; + return {}; + } + + const auto existing = replayByOperationId_.find(request.operationId); + if (existing != replayByOperationId_.end()) { + const ReplayEntry &entry = existing->second; + if (entry.commandId != request.commandId || + entry.requestFingerprint != request.requestFingerprint || + entry.canonicalRequest != canonical) { + conflict = true; + return {}; + } + if (!entry.terminal) { + inProgress = true; + return {}; + } + replayed = true; + return entry.result; + } + + if (replayByOperationId_.size() >= maximumReplayEntries_) { + ledgerFull = true; + return {}; + } + + ReplayEntry entry; + entry.commandId = request.commandId; + entry.requestFingerprint = request.requestFingerprint; + entry.canonicalRequest = canonical; + replayByOperationId_.emplace(request.operationId, std::move(entry)); + operationByCommandId_.emplace(request.commandId, request.operationId); + } + + SuiteBridgeNativeTimerCreateMutationResult callbackResult; + try { + callbackResult = normalizedCallbackResult( + request, + mutationCallback_->CreateTimer(request)); + } catch (...) { + callbackResult.disposition = + SuiteBridgeNativeTimerCreateMutationDisposition::OutcomeUnknown; + callbackResult.evidenceReference = + "ntcreate:callback-exception:" + request.commandId; + } + + { + std::lock_guard lock(replayMutex_); + const auto existing = replayByOperationId_.find(request.operationId); + if (existing != replayByOperationId_.end() && + existing->second.commandId == request.commandId && + existing->second.requestFingerprint == request.requestFingerprint && + existing->second.canonicalRequest == canonical) { + existing->second.result = callbackResult; + existing->second.terminal = true; + } + } + + return callbackResult; +} diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_create.h b/vdr-plugin-suite-bridge/suitebridge_native_timer_create.h new file mode 100644 index 000000000..1e9ec8b36 --- /dev/null +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_create.h @@ -0,0 +1,113 @@ +#ifndef VDR_SUITE_BRIDGE_NATIVE_TIMER_CREATE_H +#define VDR_SUITE_BRIDGE_NATIVE_TIMER_CREATE_H + +#include "suitebridge_command_result.h" + +#include +#include +#include +#include +#include + +struct SuiteBridgeNativeTimerCreateRequest final { + std::string commandId; + std::string requestFingerprint; + std::string operationId; + std::string operationRevision; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch = 0; + std::string nativeTimerBindingId; + std::string expectedSpecificationFingerprint; + std::string jobId; + std::string attemptId; + std::uint64_t claimEpoch = 0; + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; + std::uint64_t controlPlaneClaimedAt = 0; + std::string authorityDomain; + std::string providerId; + std::string providerKind; + std::uint64_t ownershipGeneration = 0; + std::string providerInstanceEpoch; + std::uint64_t providerGeneration = 0; + std::uint64_t capabilityRevision = 0; + std::string requiredCapability; + std::uint64_t localStartingPersistedAt = 0; + std::string channelId; + std::string title; + std::string directory; + std::string day; + std::string weekdays; + std::string startTime; + std::string endTime; + std::uint64_t priority = 0; + std::uint64_t lifetime = 0; + bool enabled = false; + bool vps = false; +}; + +enum class SuiteBridgeNativeTimerCreateMutationDisposition { + AppliedUnverified, + RejectedWithoutEffect, + OutcomeUnknown, +}; + +struct SuiteBridgeNativeTimerCreateMutationResult final { + SuiteBridgeNativeTimerCreateMutationDisposition disposition = + SuiteBridgeNativeTimerCreateMutationDisposition::OutcomeUnknown; + std::string evidenceReference; +}; + +class ISuiteBridgeNativeTimerCreateMutationCallback { +public: + virtual ~ISuiteBridgeNativeTimerCreateMutationCallback() = default; + + virtual SuiteBridgeNativeTimerCreateMutationResult CreateTimer( + const SuiteBridgeNativeTimerCreateRequest &request) = 0; +}; + +class SuiteBridgeNativeTimerCreateService final { +public: + explicit SuiteBridgeNativeTimerCreateService( + std::string pluginInstanceEpoch, + ISuiteBridgeNativeTimerCreateMutationCallback *mutationCallback = nullptr, + std::size_t maximumReplayEntries = 4096); + + SuiteBridgeCommandResult Handle( + const char *command, + const char *option); + + bool ExecutionConfigured() const noexcept; + +private: + struct ReplayEntry final { + std::string commandId; + std::string requestFingerprint; + std::string canonicalRequest; + bool terminal = false; + SuiteBridgeNativeTimerCreateMutationResult result; + }; + + SuiteBridgeCommandResult capability(const char *option) const; + SuiteBridgeCommandResult execute(const char *option); + SuiteBridgeNativeTimerCreateMutationResult executeReserved( + const SuiteBridgeNativeTimerCreateRequest &request, + const std::string &canonicalRequest, + bool &replayed, + bool &inProgress, + bool &conflict, + bool &ledgerFull); + + std::string pluginInstanceEpoch_; + ISuiteBridgeNativeTimerCreateMutationCallback *mutationCallback_ = nullptr; + std::size_t maximumReplayEntries_ = 4096; + std::mutex replayMutex_; + std::unordered_map replayByOperationId_; + std::unordered_map operationByCommandId_; +}; + +#endif diff --git a/vdr-plugin-suite-bridge/suitebridge_svdrp.cpp b/vdr-plugin-suite-bridge/suitebridge_svdrp.cpp index 9da5d7f6e..a69aec1c1 100644 --- a/vdr-plugin-suite-bridge/suitebridge_svdrp.cpp +++ b/vdr-plugin-suite-bridge/suitebridge_svdrp.cpp @@ -60,6 +60,12 @@ cString cPluginSuiteBridge::SVDRPCommand( return ReturnResult(nativeProbe, ReplyCode); } + const SuiteBridgeCommandResult nativeTimerCreate = + nativeTimerCreate_.Handle(Command, Option); + if (nativeTimerCreate.handled) { + return ReturnResult(nativeTimerCreate, ReplyCode); + } + const SuiteBridgeCommandResult nativeTimerDelete = nativeTimerDelete_.Handle(Command, Option); if (nativeTimerDelete.handled) { diff --git a/vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_create.cpp b/vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_create.cpp new file mode 100644 index 000000000..a7f98bf7e --- /dev/null +++ b/vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_create.cpp @@ -0,0 +1,289 @@ +#include "suitebridge_native_timer_create.h" + +#include +#include +#include +#include + +namespace { +std::string fingerprintToken(char digit = 'a') +{ + return "sha256:" + std::string(64, digit); +} + +std::string hexToken(const std::string &value) +{ + if (value.empty()) return "-"; + static constexpr char digits[] = "0123456789abcdef"; + std::string encoded; + encoded.reserve(value.size() * 2); + for (unsigned char character : value) { + encoded.push_back(digits[character >> 4U]); + encoded.push_back(digits[character & 0x0fU]); + } + return encoded; +} + +std::string request( + const std::string &commandId, + const std::string &fingerprint, + const std::string &operationId, + const std::string &pluginEpoch = "pie_1", + const std::string &expectedSpecificationFingerprint = fingerprintToken(), + const std::string &title = "Tagesschau 20 Uhr") +{ + return + "EXEC vdr-suite-native/1 vdr.timer.create 1 " + commandId + " " + + fingerprint + " " + operationId + + " opr_1 tas_1 ar_1 ir_1 11 ntb_1 " + + hexToken(expectedSpecificationFingerprint) + + " job_1 att_1 3 " + "default agt_1 agi_1 7 100 vdr.timer suitebridge:local suitebridge " + "9 " + pluginEpoch + " 1 1 vdr.timer.create 101 " + + hexToken("S19.2E-1-1101-28106") + " " + + hexToken(title) + " " + hexToken("TV News") + " " + + hexToken("2026-08-17") + " " + hexToken("---W---") + " " + + hexToken("2000") + " " + hexToken("2030") + " 50 99 1 0"; +} + +class CountingCallback final : + public ISuiteBridgeNativeTimerCreateMutationCallback { +public: + SuiteBridgeNativeTimerCreateMutationResult result{ + SuiteBridgeNativeTimerCreateMutationDisposition::AppliedUnverified, + "ntcreate:callback-applied"}; + std::size_t calls = 0; + bool throwOnCall = false; + bool reenter = false; + SuiteBridgeNativeTimerCreateService *service = nullptr; + std::string reentrantRequest; + SuiteBridgeCommandResult reentrantReply; + std::string lastExpectedSpecificationFingerprint; + std::string lastTitle; + + SuiteBridgeNativeTimerCreateMutationResult CreateTimer( + const SuiteBridgeNativeTimerCreateRequest &request) override + { + ++calls; + lastExpectedSpecificationFingerprint = + request.expectedSpecificationFingerprint; + lastTitle = request.title; + if (reenter) { + assert(service != nullptr); + reentrantReply = service->Handle("NTCREATE", reentrantRequest.c_str()); + } + if (throwOnCall) throw std::runtime_error("synthetic callback failure"); + return result; + } +}; +} // namespace + +int main() +{ + const std::string baseRequest = request("cmd_1", "fp_1", "op_1"); + + { + SuiteBridgeNativeTimerCreateService service("pie_1"); + assert(!service.ExecutionConfigured()); + + const SuiteBridgeCommandResult reply = service.Handle("NTCREATE", "CAP 1"); + assert(reply.handled); + assert(reply.replyCode == 900); + assert(reply.payload == + "vdr-suite-ntcreate-cap/1 vdr.timer.create 1 timer-create disabled " + "suitebridge pie_1 1 1 disabled"); + } + + { + SuiteBridgeNativeTimerCreateService service("pie_1"); + const SuiteBridgeCommandResult reply = + service.Handle("NTCREATE", baseRequest.c_str()); + assert(reply.handled); + assert(reply.replyCode == 556); + assert(reply.payload == + "vdr-suite-ntcreate-result/1 cmd_1 fp_1 vdr.timer.create 1 pie_1 1 1 " + "rejected_without_effect disabled ntcreate:disabled:cmd_1"); + } + + { + SuiteBridgeNativeTimerCreateService service("pie_1"); + const std::string stale = request("cmd_1", "fp_1", "op_1", "pie_0"); + const SuiteBridgeCommandResult reply = service.Handle("NTCREATE", stale.c_str()); + assert(reply.handled); + assert(reply.replyCode == 555); + assert(reply.payload == + "vdr-suite-ntcreate-result/1 cmd_1 fp_1 vdr.timer.create 1 pie_1 1 1 " + "rejected_without_effect stale ntcreate:stale:cmd_1"); + } + + { + SuiteBridgeNativeTimerCreateService service("pie_1"); + const SuiteBridgeCommandResult reply = service.Handle( + "NTCREATE", "EXEC vdr-suite-native/1 vdr.timer.create 1 broken"); + assert(reply.handled); + assert(reply.replyCode == 501); + assert(reply.payload == "vdr-suite-ntcreate-rejected/1 execute-malformed"); + } + + { + CountingCallback callback; + SuiteBridgeNativeTimerCreateService service("pie_1", &callback); + assert(service.ExecutionConfigured()); + + const SuiteBridgeCommandResult capability = service.Handle("NTCREATE", "CAP 1"); + assert(capability.handled); + assert(capability.replyCode == 900); + assert(capability.payload == + "vdr-suite-ntcreate-cap/1 vdr.timer.create 1 timer-create enabled " + "suitebridge pie_1 1 1 enabled"); + + const SuiteBridgeCommandResult first = + service.Handle("NTCREATE", baseRequest.c_str()); + assert(first.handled); + assert(first.replyCode == 557); + assert(first.payload == + "vdr-suite-ntcreate-result/1 cmd_1 fp_1 vdr.timer.create 1 pie_1 1 1 " + "accepted_unverified callback_applied ntcreate:callback-applied"); + assert(callback.calls == 1); + assert(callback.lastExpectedSpecificationFingerprint == fingerprintToken()); + assert(callback.lastTitle == "Tagesschau 20 Uhr"); + + const SuiteBridgeCommandResult replay = + service.Handle("NTCREATE", baseRequest.c_str()); + assert(replay.replyCode == first.replyCode); + assert(replay.payload == first.payload); + assert(callback.calls == 1); + + const std::string conflictingNativeTimerFingerprint = + request("cmd_1", "fp_1", "op_1", "pie_1", fingerprintToken('b')); + const SuiteBridgeCommandResult nativeTimerFingerprintConflict = + service.Handle("NTCREATE", conflictingNativeTimerFingerprint.c_str()); + assert(nativeTimerFingerprintConflict.replyCode == 559); + assert(nativeTimerFingerprintConflict.payload == + "vdr-suite-ntcreate-result/1 cmd_1 fp_1 vdr.timer.create 1 pie_1 1 1 " + "rejected_without_effect replay_conflict ntcreate:replay-conflict:cmd_1"); + assert(callback.calls == 1); + + const std::string conflictingFingerprint = request("cmd_1", "fp_2", "op_1"); + const SuiteBridgeCommandResult conflict = + service.Handle("NTCREATE", conflictingFingerprint.c_str()); + assert(conflict.replyCode == 559); + assert(conflict.payload == + "vdr-suite-ntcreate-result/1 cmd_1 fp_2 vdr.timer.create 1 pie_1 1 1 " + "rejected_without_effect replay_conflict ntcreate:replay-conflict:cmd_1"); + assert(callback.calls == 1); + + const std::string conflictingOperation = request("cmd_1", "fp_3", "op_2"); + const SuiteBridgeCommandResult commandConflict = + service.Handle("NTCREATE", conflictingOperation.c_str()); + assert(commandConflict.replyCode == 559); + assert(callback.calls == 1); + } + + { + SuiteBridgeNativeTimerCreateService service("pie_1"); + const std::string malformedDigest = + "sha256:" + std::string(63, 'a') + "g"; + const std::string invalidFingerprint = + request("cmd_i", "fp_i", "op_i", "pie_1", malformedDigest); + const SuiteBridgeCommandResult reply = + service.Handle("NTCREATE", invalidFingerprint.c_str()); + assert(reply.handled); + assert(reply.replyCode == 501); + assert(reply.payload == "vdr-suite-ntcreate-rejected/1 execute-malformed"); + } + + { + CountingCallback callback; + SuiteBridgeNativeTimerCreateService service("pie_1", &callback); + const std::string reentrant = request("cmd_r", "fp_r", "op_r"); + callback.reenter = true; + callback.service = &service; + callback.reentrantRequest = reentrant; + + const SuiteBridgeCommandResult first = + service.Handle("NTCREATE", reentrant.c_str()); + assert(callback.calls == 1); + assert(callback.reentrantReply.handled); + assert(callback.reentrantReply.replyCode == 558); + assert(callback.reentrantReply.payload == + "vdr-suite-ntcreate-result/1 cmd_r fp_r vdr.timer.create 1 pie_1 1 1 " + "outcome_unknown in_progress ntcreate:in-progress:cmd_r"); + assert(first.replyCode == 557); + + callback.reenter = false; + const SuiteBridgeCommandResult replay = + service.Handle("NTCREATE", reentrant.c_str()); + assert(replay.replyCode == first.replyCode); + assert(replay.payload == first.payload); + assert(callback.calls == 1); + } + + { + CountingCallback callback; + callback.result = { + SuiteBridgeNativeTimerCreateMutationDisposition::OutcomeUnknown, + "ntcreate:callback-unknown"}; + SuiteBridgeNativeTimerCreateService service("pie_1", &callback); + const std::string unknownRequest = request("cmd_u", "fp_u", "op_u"); + + const SuiteBridgeCommandResult first = + service.Handle("NTCREATE", unknownRequest.c_str()); + assert(first.replyCode == 558); + assert(first.payload == + "vdr-suite-ntcreate-result/1 cmd_u fp_u vdr.timer.create 1 pie_1 1 1 " + "outcome_unknown callback_unknown ntcreate:callback-unknown"); + const SuiteBridgeCommandResult replay = + service.Handle("NTCREATE", unknownRequest.c_str()); + assert(replay.replyCode == first.replyCode); + assert(replay.payload == first.payload); + assert(callback.calls == 1); + } + + { + CountingCallback callback; + callback.throwOnCall = true; + SuiteBridgeNativeTimerCreateService service("pie_1", &callback); + const std::string throwingRequest = request("cmd_x", "fp_x", "op_x"); + + const SuiteBridgeCommandResult first = + service.Handle("NTCREATE", throwingRequest.c_str()); + assert(first.replyCode == 558); + assert(first.payload == + "vdr-suite-ntcreate-result/1 cmd_x fp_x vdr.timer.create 1 pie_1 1 1 " + "outcome_unknown callback_unknown ntcreate:callback-exception:cmd_x"); + const SuiteBridgeCommandResult replay = + service.Handle("NTCREATE", throwingRequest.c_str()); + assert(replay.replyCode == first.replyCode); + assert(replay.payload == first.payload); + assert(callback.calls == 1); + } + + { + CountingCallback callback; + SuiteBridgeNativeTimerCreateService service("pie_1", &callback, 1); + const std::string firstRequest = request("cmd_a", "fp_a", "op_a"); + const std::string secondRequest = request("cmd_b", "fp_b", "op_b"); + + const SuiteBridgeCommandResult first = + service.Handle("NTCREATE", firstRequest.c_str()); + assert(first.replyCode == 557); + assert(callback.calls == 1); + + const SuiteBridgeCommandResult full = + service.Handle("NTCREATE", secondRequest.c_str()); + assert(full.replyCode == 560); + assert(full.payload == + "vdr-suite-ntcreate-result/1 cmd_b fp_b vdr.timer.create 1 pie_1 1 1 " + "rejected_without_effect ledger_full ntcreate:ledger-full:cmd_b"); + assert(callback.calls == 1); + } + + { + SuiteBridgeNativeTimerCreateService service("pie_1"); + const SuiteBridgeCommandResult reply = service.Handle("SNAP", nullptr); + assert(!reply.handled); + } + + return 0; +} From 3f6e05566afd297fde0f6c509af932a08f07fd07 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 22:54:20 +0200 Subject: [PATCH 44/85] Add SuiteBridge native Timer CREATE mutation callback --- Makefile | 1 + ...ridge-native-timer-create-real-mutation.md | 13 ++ ...native-timer-create-real-mutation-tests.mk | 11 ++ ...dge_native_timer_create_command_service.py | 4 +- ...ridge_native_timer_create_real_mutation.py | 66 +++++++ ...itebridge_native_timer_create_transport.py | 6 +- vdr-plugin-suite-bridge/Makefile | 2 +- vdr-plugin-suite-bridge/suitebridge.cpp | 6 +- vdr-plugin-suite-bridge/suitebridge.h | 2 + .../suitebridge_native_timer_create_vdr.cpp | 163 ++++++++++++++++++ .../suitebridge_native_timer_create_vdr.h | 13 ++ 11 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 docs/development/phase-64-suitebridge-native-timer-create-real-mutation.md create mode 100644 mk/phase64-suitebridge-native-timer-create-real-mutation-tests.mk create mode 100755 tools/check_phase64_suitebridge_native_timer_create_real_mutation.py create mode 100644 vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp create mode 100644 vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.h diff --git a/Makefile b/Makefile index 60c70f51f..d36e2c57c 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,7 @@ include mk/phase64-native-timer-create-local-state-tests.mk include mk/phase64-native-timer-create-executor-tests.mk include mk/phase64-suitebridge-native-timer-create-transport-tests.mk include mk/phase64-suitebridge-native-timer-create-command-service-tests.mk +include mk/phase64-suitebridge-native-timer-create-real-mutation-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk diff --git a/docs/development/phase-64-suitebridge-native-timer-create-real-mutation.md b/docs/development/phase-64-suitebridge-native-timer-create-real-mutation.md new file mode 100644 index 000000000..fe9671cbf --- /dev/null +++ b/docs/development/phase-64-suitebridge-native-timer-create-real-mutation.md @@ -0,0 +1,13 @@ +# Phase 64: SuiteBridge native Timer CREATE mutation + +This successor wires the private NTCREATE command service to one dedicated VDR callback. +The callback parses a validated backend-neutral specification into a local cTimer, obtains +a bounded Timer write lock, and performs exactly one cTimers::Add mutation. + +Callback success is only AppliedUnverified. It does not prove correlation, persistence, +binding, or assignment completion. Those remain gated by authoritative native-Timer +inventory readback and exact managed specification verification. + +NTCREATE remains absent from public SVDRP help. Agent construction, packaged command +advertisement, and Timer-write advertisement remain closed until bounded real yaVDR +acceptance succeeds. diff --git a/mk/phase64-suitebridge-native-timer-create-real-mutation-tests.mk b/mk/phase64-suitebridge-native-timer-create-real-mutation-tests.mk new file mode 100644 index 000000000..8c383ebe7 --- /dev/null +++ b/mk/phase64-suitebridge-native-timer-create-real-mutation-tests.mk @@ -0,0 +1,11 @@ +.PHONY: test-phase64-suitebridge-native-timer-create-real-mutation-architecture test-phase64-suitebridge-native-timer-create-real-mutation + +test-phase64-suitebridge-native-timer-create-real-mutation-architecture: + python3 tools/check_phase64_suitebridge_native_timer_create_real_mutation.py + +test-phase64-suitebridge-native-timer-create-real-mutation: \ + test-phase64-suitebridge-native-timer-create-command-service \ + test-phase64-suitebridge-native-timer-create-real-mutation-architecture + +test-fast: test-phase64-suitebridge-native-timer-create-real-mutation +test-architecture: test-phase64-suitebridge-native-timer-create-real-mutation-architecture diff --git a/tools/check_phase64_suitebridge_native_timer_create_command_service.py b/tools/check_phase64_suitebridge_native_timer_create_command_service.py index a3c0ff4fd..9b882dd29 100644 --- a/tools/check_phase64_suitebridge_native_timer_create_command_service.py +++ b/tools/check_phase64_suitebridge_native_timer_create_command_service.py @@ -63,8 +63,8 @@ def forbid(text: str, needle: str, label: str) -> None: require(plugin_makefile, "test-native-timer-create", "CREATE plugin unit test") -if (ROOT / "vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp").exists(): - raise SystemExit("CREATE VDR mutation successor landed too early") +require(plugin, "SuiteBridgeNativeTimerCreateVdrMutationCallback", + "CREATE VDR mutation successor owner") availability = client[ client.find("CommandAvailability availableCommands"): diff --git a/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py new file mode 100755 index 000000000..f3c9da7c7 --- /dev/null +++ b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def read(path: str) -> str: + target = ROOT / path + if not target.is_file(): + raise SystemExit(f"missing CREATE real-mutation file: {path}") + return target.read_text(encoding="utf-8") + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f"missing {label}: {needle}") + +def forbid(text: str, needle: str, label: str) -> None: + if needle in text: + raise SystemExit(f"forbidden {label}: {needle}") + +source = read("vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp") +header = read("vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.h") +plugin = read("vdr-plugin-suite-bridge/suitebridge.h") +main = read("vdr-plugin-suite-bridge/suitebridge.cpp") +svdrp = read("vdr-plugin-suite-bridge/suitebridge_svdrp.cpp") +client = read("core/agent/src/BackendAgentCommandClient.cpp") +agent_client = read("core/agent/src/BackendAgentClient.cpp") +packaged = read("packaging/systemd/backend-agent.conf") + +for needle in ( + "ISuiteBridgeNativeTimerCreateMutationCallback", "CreateTimer(", +): + require(header, needle, "typed CREATE callback") +for needle in ( + "#include ", + "cTimers::GetTimersWrite(stateKey, TimerWriteLockTimeoutMs)", + "timer->Parse(definition.str().c_str())", + "timers->SetExplicitModify()", "timers->Add(timer.get())", + "timer.release()", "timers->SetModified()", "stateKey.Remove();", + "AppliedUnverified", 'evidence("created-unverified"', +): + require(source, needle, "bounded CREATE VDR mutation") +if source.count("timers->Add(timer.get())") != 1: + raise SystemExit("CREATE callback must contain exactly one native Add") +for token in ('"NEWT"', '"MODT"', "RESTfulAPI", "restfulapi", + "system(", "popen(", "curl "): + forbid(source, token, "fallback CREATE mutation") +require(plugin, "SuiteBridgeNativeTimerCreateVdrMutationCallback", + "plugin callback owner") +require(main, "&nativeTimerCreateVdrMutation_", "production callback wiring") +require(svdrp, "nativeTimerCreate_.Handle(Command, Option)", + "private NTCREATE dispatch") +help_section = svdrp.split("SVDRPHelpPages", 1)[1].split("SVDRPCommand", 1)[0] +forbid(help_section, "NTCREATE", "public CREATE Help advertisement") +available = client[ + client.find("CommandAvailability availableCommands"): + client.find("bool reconcileBackendAgentCommandState") +] +require(available, "kBackendAgentNativeTimerCreateCommandType", + "CREATE advertisement fence") +require(available, "continue;", "CREATE advertisement suppression") +forbid(agent_client, "SuiteBridgeNativeTimerCreateTransport", + "production CREATE adapter construction") +forbid(agent_client, "nativeTimerCreateTransport", + "production CREATE transport injection") +forbid(packaged, "vdr.timer.create", "packaged CREATE advertisement") +print("Phase 64 SuiteBridge native Timer CREATE real-mutation guard passed") diff --git a/tools/check_phase64_suitebridge_native_timer_create_transport.py b/tools/check_phase64_suitebridge_native_timer_create_transport.py index d10f6e335..f9a0298b8 100755 --- a/tools/check_phase64_suitebridge_native_timer_create_transport.py +++ b/tools/check_phase64_suitebridge_native_timer_create_transport.py @@ -315,11 +315,11 @@ def forbid(text: str, needle: str, label: str) -> None: "missing CREATE command-service successor: " + plugin_path ) -if ( +if not ( ROOT / "vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp" -).exists(): - raise SystemExit("CREATE VDR mutation boundary landed too early") +).is_file(): + raise SystemExit("missing CREATE VDR mutation successor") plugin_svdrp = read( "vdr-plugin-suite-bridge/suitebridge_svdrp.cpp" diff --git a/vdr-plugin-suite-bridge/Makefile b/vdr-plugin-suite-bridge/Makefile index 2a7060a30..4f508b0c3 100644 --- a/vdr-plugin-suite-bridge/Makefile +++ b/vdr-plugin-suite-bridge/Makefile @@ -20,7 +20,7 @@ SOFILE = libvdr-$(PLUGIN).so DEFINES += -DPLUGIN_NAME_I18N='"$(PLUGIN)"' -OBJS = $(PLUGIN).o suitebridge_svdrp.o suitebridge_native_probe.o suitebridge_native_timer_create.o suitebridge_native_timer_delete.o suitebridge_native_timer_delete_vdr.o suitebridge_epg_command_handler.o suitebridge_epg_type_snapshot_command.o suitebridge_recording_metadata_command.o suitebridge_lifecycle.o suitebridge_capabilities.o suitebridge_capability_discovery.o suitebridge_counter_continuity.o suitebridge_status_snapshot.o suitebridge_local_contract.o suitebridge_svdrp_contract.o suitebridge_status_events.o suitebridge_status_monitor.o suitebridge_artwork_reference.o suitebridge_image_dimensions.o suitebridge_tvscraper_adapter.o suitebridge_tvscraper_type_adapter.o suitebridge_epg_artwork_contract.o suitebridge_epg_metadata_contract.o suitebridge_epg_type_snapshot_contract.o suitebridge_recording_identity.o suitebridge_recording_metadata.o suitebridge_recording_metadata_contract.o suitebridge_tvscraper_recording_adapter.o +OBJS = $(PLUGIN).o suitebridge_svdrp.o suitebridge_native_probe.o suitebridge_native_timer_create.o suitebridge_native_timer_create_vdr.o suitebridge_native_timer_delete.o suitebridge_native_timer_delete_vdr.o suitebridge_epg_command_handler.o suitebridge_epg_type_snapshot_command.o suitebridge_recording_metadata_command.o suitebridge_lifecycle.o suitebridge_capabilities.o suitebridge_capability_discovery.o suitebridge_counter_continuity.o suitebridge_status_snapshot.o suitebridge_local_contract.o suitebridge_svdrp_contract.o suitebridge_status_events.o suitebridge_status_monitor.o suitebridge_artwork_reference.o suitebridge_image_dimensions.o suitebridge_tvscraper_adapter.o suitebridge_tvscraper_type_adapter.o suitebridge_epg_artwork_contract.o suitebridge_epg_metadata_contract.o suitebridge_epg_type_snapshot_contract.o suitebridge_recording_identity.o suitebridge_recording_metadata.o suitebridge_recording_metadata_contract.o suitebridge_tvscraper_recording_adapter.o DEPFILE = .dependencies LIFECYCLE_TEST = /tmp/test_suitebridge_lifecycle CAPABILITIES_TEST = /tmp/test_suitebridge_capabilities diff --git a/vdr-plugin-suite-bridge/suitebridge.cpp b/vdr-plugin-suite-bridge/suitebridge.cpp index dcf734660..109279f1e 100644 --- a/vdr-plugin-suite-bridge/suitebridge.cpp +++ b/vdr-plugin-suite-bridge/suitebridge.cpp @@ -7,7 +7,9 @@ cPluginSuiteBridge::cPluginSuiteBridge() : nativeProbe_(GenerateSuiteBridgePluginInstanceEpoch()), - nativeTimerCreate_(nativeProbe_.PluginInstanceEpoch()), + nativeTimerCreate_( + nativeProbe_.PluginInstanceEpoch(), + &nativeTimerCreateVdrMutation_), nativeTimerDelete_( nativeProbe_.PluginInstanceEpoch(), &nativeTimerDeleteVdrMutation_) @@ -50,6 +52,8 @@ bool cPluginSuiteBridge::Initialize(void) isyslog( "suitebridge: native-operation=vdr.native.probe schema=1 side-effect=none mutations=disabled provider=suitebridge"); + isyslog( + "suitebridge: native-operation=vdr.timer.create schema=1 side-effect=timer-create mutations=enabled execution=enabled provider=suitebridge acceptance=required"); isyslog( "suitebridge: native-operation=vdr.timer.delete schema=1 side-effect=timer-delete mutations=enabled execution=enabled provider=suitebridge acceptance=required"); return true; diff --git a/vdr-plugin-suite-bridge/suitebridge.h b/vdr-plugin-suite-bridge/suitebridge.h index 41c283acb..16fed7e36 100644 --- a/vdr-plugin-suite-bridge/suitebridge.h +++ b/vdr-plugin-suite-bridge/suitebridge.h @@ -4,6 +4,7 @@ #include "suitebridge_lifecycle.h" #include "suitebridge_native_probe.h" #include "suitebridge_native_timer_create.h" +#include "suitebridge_native_timer_create_vdr.h" #include "suitebridge_native_timer_delete.h" #include "suitebridge_native_timer_delete_vdr.h" #include "suitebridge_status_monitor.h" @@ -33,6 +34,7 @@ class cPluginSuiteBridge final : public cPlugin { SuiteBridgeLifecycle lifecycle_; SuiteBridgeStatusMonitor statusMonitor_; SuiteBridgeNativeProbeService nativeProbe_; + SuiteBridgeNativeTimerCreateVdrMutationCallback nativeTimerCreateVdrMutation_; SuiteBridgeNativeTimerCreateService nativeTimerCreate_; SuiteBridgeNativeTimerDeleteVdrMutationCallback nativeTimerDeleteVdrMutation_; SuiteBridgeNativeTimerDeleteService nativeTimerDelete_; diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp b/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp new file mode 100644 index 000000000..5ec7ab8aa --- /dev/null +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp @@ -0,0 +1,163 @@ +#include "suitebridge_native_timer_create_vdr.h" + +#include + +#include +#include +#include +#include +#include + +namespace { +constexpr int TimerWriteLockTimeoutMs = 1000; + +SuiteBridgeNativeTimerCreateMutationResult result( + SuiteBridgeNativeTimerCreateMutationDisposition disposition, + const std::string &evidence) +{ + return {disposition, evidence}; +} + +std::string evidence(const char *reason, const std::string &commandId) +{ + return std::string("ntcreate:vdr:") + reason + ":" + commandId; +} + +bool digits(const std::string &value) +{ + return !value.empty() && + std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isdigit(character) != 0; + }); +} + +bool validTime(const std::string &value) +{ + if (value.size() != 4 || !digits(value)) return false; + const int hour = (value[0] - '0') * 10 + value[1] - '0'; + const int minute = (value[2] - '0') * 10 + value[3] - '0'; + return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59; +} + +bool validDay(const std::string &value) +{ + if (value.size() != 10 || value[4] != '-' || value[7] != '-') return false; + for (std::size_t index = 0; index < value.size(); ++index) + if (index != 4 && index != 7 && + std::isdigit(static_cast(value[index])) == 0) + return false; + return true; +} + +bool validWeekdays(const std::string &value) +{ + static constexpr char Names[7] = {'M', 'T', 'W', 'T', 'F', 'S', 'S'}; + if (value.size() != 7) return false; + for (std::size_t index = 0; index < value.size(); ++index) + if (value[index] != '-' && value[index] != Names[index]) return false; + return true; +} + +bool safeChannelId(const std::string &value) +{ + return !value.empty() && value.size() <= 192 && + std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isalnum(character) != 0 || character == '.' || + character == '-' || character == '_'; + }); +} + +std::string timerDay( + const SuiteBridgeNativeTimerCreateRequest &request, + bool &valid) +{ + valid = validWeekdays(request.weekdays); + if (!valid) return {}; + const bool repeating = + request.weekdays.find_first_not_of('-') != std::string::npos; + if (repeating) { + if (!request.day.empty() && !validDay(request.day)) { + valid = false; + return {}; + } + return request.weekdays + + (request.day.empty() ? std::string() : "@" + request.day); + } + valid = validDay(request.day); + return valid ? request.day : std::string(); +} + +std::string timerFile( + const SuiteBridgeNativeTimerCreateRequest &request, + bool &valid) +{ + valid = !request.title.empty() && + request.title.find('~') == std::string::npos && + request.title.find('|') == std::string::npos && + request.directory.find('|') == std::string::npos; + if (!valid) return {}; + std::string value = request.directory.empty() + ? request.title + : request.directory + "~" + request.title; + std::replace(value.begin(), value.end(), ':', '|'); + return value; +} +} // namespace + +SuiteBridgeNativeTimerCreateMutationResult +SuiteBridgeNativeTimerCreateVdrMutationCallback::CreateTimer( + const SuiteBridgeNativeTimerCreateRequest &request) +{ + bool valid = false; + const std::string day = timerDay(request, valid); + if (!valid || !safeChannelId(request.channelId) || + !validTime(request.startTime) || !validTime(request.endTime) || + request.priority > 99 || request.lifetime > 99) + return result( + SuiteBridgeNativeTimerCreateMutationDisposition::RejectedWithoutEffect, + evidence("invalid-specification", request.commandId)); + + const std::string file = timerFile(request, valid); + if (!valid) + return result( + SuiteBridgeNativeTimerCreateMutationDisposition::RejectedWithoutEffect, + evidence("invalid-file", request.commandId)); + + const unsigned flags = + (request.enabled ? tfActive : tfNone) | + (request.vps ? tfVps : tfNone); + std::ostringstream definition; + definition << flags << ':' << request.channelId << ':' << day << ':' + << request.startTime << ':' << request.endTime << ':' + << request.priority << ':' << request.lifetime << ':' + << file << ':'; + + std::unique_ptr timer(new cTimer()); + if (!timer->Parse(definition.str().c_str())) + return result( + SuiteBridgeNativeTimerCreateMutationDisposition::RejectedWithoutEffect, + evidence("parse-rejected", request.commandId)); + + try { + cStateKey stateKey; + cTimers *timers = + cTimers::GetTimersWrite(stateKey, TimerWriteLockTimeoutMs); + if (timers == nullptr) + return result( + SuiteBridgeNativeTimerCreateMutationDisposition::RejectedWithoutEffect, + evidence("lock-unavailable", request.commandId)); + + timers->SetExplicitModify(); + timers->Add(timer.get()); + timer.release(); + timers->SetModified(); + stateKey.Remove(); + return result( + SuiteBridgeNativeTimerCreateMutationDisposition::AppliedUnverified, + evidence("created-unverified", request.commandId)); + } catch (...) { + return result( + SuiteBridgeNativeTimerCreateMutationDisposition::OutcomeUnknown, + evidence("exception", request.commandId)); + } +} diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.h b/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.h new file mode 100644 index 000000000..5049a0803 --- /dev/null +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.h @@ -0,0 +1,13 @@ +#ifndef VDR_SUITE_BRIDGE_NATIVE_TIMER_CREATE_VDR_H +#define VDR_SUITE_BRIDGE_NATIVE_TIMER_CREATE_VDR_H + +#include "suitebridge_native_timer_create.h" + +class SuiteBridgeNativeTimerCreateVdrMutationCallback final : + public ISuiteBridgeNativeTimerCreateMutationCallback { +public: + SuiteBridgeNativeTimerCreateMutationResult CreateTimer( + const SuiteBridgeNativeTimerCreateRequest &request) override; +}; + +#endif From 44a9ed1627204fdd13fcc114f8f46bcf75b969e1 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:03:20 +0200 Subject: [PATCH 45/85] Wire native Timer transports into the Backend Agent --- Makefile | 1 + apps/agent/main.cpp | 23 +++++ core/agent/include/BackendAgentClient.h | 12 +++ core/agent/src/BackendAgentClient.cpp | 32 +++++- .../agent/tests/test_backend_agent_client.cpp | 20 ++++ ...bridge-native-timer-command-path-wiring.md | 18 ++++ mk/backend-agent-runtime.mk | 2 + ...-native-timer-command-path-wiring-tests.mk | 14 +++ packaging/systemd/backend-agent.conf | 5 + ...bridge_native_timer_command_path_wiring.py | 99 +++++++++++++++++++ ...dge_native_timer_create_command_service.py | 9 +- ...ridge_native_timer_create_real_mutation.py | 9 +- ...itebridge_native_timer_create_transport.py | 13 +-- ..._native_timer_delete_disabled_transport.py | 5 +- ...ridge_native_timer_delete_real_mutation.py | 5 +- ...ive_timer_delete_replay_ledger_callback.py | 4 +- 16 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md create mode 100644 mk/phase64-suitebridge-native-timer-command-path-wiring-tests.mk create mode 100755 tools/check_phase64_suitebridge_native_timer_command_path_wiring.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_create_command_service.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py diff --git a/Makefile b/Makefile index d36e2c57c..e153122f8 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ include mk/phase64-native-timer-create-executor-tests.mk include mk/phase64-suitebridge-native-timer-create-transport-tests.mk include mk/phase64-suitebridge-native-timer-create-command-service-tests.mk include mk/phase64-suitebridge-native-timer-create-real-mutation-tests.mk +include mk/phase64-suitebridge-native-timer-command-path-wiring-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk diff --git a/apps/agent/main.cpp b/apps/agent/main.cpp index 5e569d5aa..7d88f7f43 100644 --- a/apps/agent/main.cpp +++ b/apps/agent/main.cpp @@ -1,5 +1,7 @@ #include "BackendAgentClient.h" #include "BackendAgentCommandClient.h" +#include "SuiteBridgeNativeTimerCreateTransport.h" +#include "SuiteBridgeNativeTimerDeleteTransport.h" #include "SuiteBridgeSvdrpTransport.h" #include @@ -120,6 +122,27 @@ int main(int argc, char** argv) return 78; } + std::unique_ptr + nativeTimerCreateTransport; + std::unique_ptr + nativeTimerDeleteTransport; + if (!config.suiteBridgeHost.empty()) + { + vdrsuite::agent::SuiteBridgeSvdrpTransportConfig timerTransportConfig; + timerTransportConfig.host = config.suiteBridgeHost; + timerTransportConfig.port = config.suiteBridgePort; + nativeTimerCreateTransport = std::make_unique< + vdrsuite::agent::SuiteBridgeNativeTimerCreateTransport>( + timerTransportConfig); + nativeTimerDeleteTransport = std::make_unique< + vdrsuite::agent::SuiteBridgeNativeTimerDeleteTransport>( + timerTransportConfig); + config.nativeTimerCreateTransport = + nativeTimerCreateTransport.get(); + config.nativeTimerDeleteTransport = + nativeTimerDeleteTransport.get(); + } + std::unique_ptr nativeTransport; if (nativeProbe) { diff --git a/core/agent/include/BackendAgentClient.h b/core/agent/include/BackendAgentClient.h index 24d65ded1..594e006b3 100644 --- a/core/agent/include/BackendAgentClient.h +++ b/core/agent/include/BackendAgentClient.h @@ -6,6 +6,12 @@ #include #include +namespace vdrsuite::agent +{ +class IBackendAgentNativeTimerCreateTransport; +class IBackendAgentNativeTimerDeleteTransport; +} + struct BackendAgentClientConfig { std::string controlPlaneUrl; @@ -19,6 +25,12 @@ struct BackendAgentClientConfig std::vector adapters; std::vector observationDomains = {"backend-health"}; std::vector commandTypes; + std::string suiteBridgeHost; + int suiteBridgePort = 0; + vdrsuite::agent::IBackendAgentNativeTimerCreateTransport* + nativeTimerCreateTransport = nullptr; + vdrsuite::agent::IBackendAgentNativeTimerDeleteTransport* + nativeTimerDeleteTransport = nullptr; int heartbeatIntervalSeconds = 30; int reconnectInitialSeconds = 1; int reconnectMaximumSeconds = 30; diff --git a/core/agent/src/BackendAgentClient.cpp b/core/agent/src/BackendAgentClient.cpp index a877694d3..e38e36ac3 100644 --- a/core/agent/src/BackendAgentClient.cpp +++ b/core/agent/src/BackendAgentClient.cpp @@ -81,6 +81,11 @@ bool safeSoftwareVersion(const std::string& value) }); } +bool loopbackHost(const std::string& host) +{ + return host == "127.0.0.1" || host == "::1" || host == "localhost"; +} + bool validCapabilities( const std::vector& adapters, const std::vector& domains) @@ -673,6 +678,7 @@ bool BackendAgentClientRuntime::loadConfig( "CONTROL_PLANE_URL", "BACKEND_ID", "IDENTITY_PATH", "ENROLLMENT_PATH", "CA_CERTIFICATE_PATH", "CHANNELS_CONF_PATH", "COMMAND_STATE_PATH", "SOFTWARE_VERSION", "ADAPTERS", "OBSERVATION_DOMAINS", "COMMAND_TYPES", + "SUITEBRIDGE_HOST", "SUITEBRIDGE_PORT", "HEARTBEAT_INTERVAL_SECONDS", "RECONNECT_INITIAL_SECONDS", "RECONNECT_MAXIMUM_SECONDS", "CONNECT_TIMEOUT_MILLISECONDS", "REQUEST_TIMEOUT_MILLISECONDS"}; @@ -698,6 +704,24 @@ bool BackendAgentClientRuntime::loadConfig( if (!values["OBSERVATION_DOMAINS"].empty()) config.observationDomains = splitCsv(values["OBSERVATION_DOMAINS"]); config.commandTypes = splitCsv(values["COMMAND_TYPES"]); + config.suiteBridgeHost = values["SUITEBRIDGE_HOST"]; + config.suiteBridgePort = 0; + config.nativeTimerCreateTransport = nullptr; + config.nativeTimerDeleteTransport = nullptr; + if (!values["SUITEBRIDGE_PORT"].empty() && + !strictInt(values["SUITEBRIDGE_PORT"], 1, 65535, + config.suiteBridgePort)) + { + reasonCode = "invalid_suitebridge_configuration"; + return false; + } + if ((!config.suiteBridgeHost.empty() || config.suiteBridgePort != 0) && + (!loopbackHost(config.suiteBridgeHost) || + config.suiteBridgePort == 0)) + { + reasonCode = "invalid_suitebridge_configuration"; + return false; + } if ((!values["HEARTBEAT_INTERVAL_SECONDS"].empty() && !strictInt(values["HEARTBEAT_INTERVAL_SECONDS"], 10, 60, config.heartbeatIntervalSeconds)) || @@ -1883,7 +1907,13 @@ bool BackendAgentClientRuntime::heartbeat(std::string& reasonCode) reasonCode = "agent_not_synchronized"; return false; } - BackendAgentCommandClientConfig commandConfig{config_.commandStatePath, config_.commandTypes}; + BackendAgentCommandClientConfig commandConfig; + commandConfig.statePath = config_.commandStatePath; + commandConfig.commandTypes = config_.commandTypes; + commandConfig.nativeTimerCreateTransport = + config_.nativeTimerCreateTransport; + commandConfig.nativeTimerDeleteTransport = + config_.nativeTimerDeleteTransport; BackendAgentCommandClientContext commandContext{state_.agentId, state_.credentialSecret, state_.backendId, agentInstanceId_, state_.backendGeneration}; if (!reconcileBackendAgentCommandState(commandConfig, commandContext, transport_, reasonCode)) { diff --git a/core/agent/tests/test_backend_agent_client.cpp b/core/agent/tests/test_backend_agent_client.cpp index 3196c1673..e2f947a39 100644 --- a/core/agent/tests/test_backend_agent_client.cpp +++ b/core/agent/tests/test_backend_agent_client.cpp @@ -131,6 +131,8 @@ void test_protected_url_and_configuration() << "ENROLLMENT_PATH=" << root << "/enrollment\n" << "ADAPTERS=\n" << "OBSERVATION_DOMAINS=backend-health\n" + << "SUITEBRIDGE_HOST=127.0.0.1\n" + << "SUITEBRIDGE_PORT=6419\n" << "HEARTBEAT_INTERVAL_SECONDS=30\n"; output.close(); BackendAgentClientConfig config; @@ -139,6 +141,10 @@ void test_protected_url_and_configuration() assert(reason == "configuration_loaded"); assert(config.backendId == "default"); assert(config.observationDomains.size() == 1); + assert(config.suiteBridgeHost == "127.0.0.1"); + assert(config.suiteBridgePort == 6419); + assert(config.nativeTimerCreateTransport == nullptr); + assert(config.nativeTimerDeleteTransport == nullptr); std::ofstream invalid(path); invalid << "CONTROL_PLANE_URL=https://control-plane.example.test\n" @@ -150,6 +156,20 @@ void test_protected_url_and_configuration() invalid.close(); assert(!BackendAgentClientRuntime::loadConfig(path, config, reason)); assert(reason == "invalid_channel_source_configuration"); + + std::ofstream remoteSuiteBridge(path); + remoteSuiteBridge + << "CONTROL_PLANE_URL=https://control-plane.example.test\n" + << "BACKEND_ID=default\n" + << "IDENTITY_PATH=" << root << "/identity\n" + << "ENROLLMENT_PATH=" << root << "/enrollment\n" + << "ADAPTERS=\n" + << "OBSERVATION_DOMAINS=backend-health\n" + << "SUITEBRIDGE_HOST=192.0.2.1\n" + << "SUITEBRIDGE_PORT=6419\n"; + remoteSuiteBridge.close(); + assert(!BackendAgentClientRuntime::loadConfig(path, config, reason)); + assert(reason == "invalid_suitebridge_configuration"); removeTree(root); } diff --git a/docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md b/docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md new file mode 100644 index 000000000..a67c99271 --- /dev/null +++ b/docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md @@ -0,0 +1,18 @@ +# Phase 64 SuiteBridge native Timer command-path wiring + +This slice installs the already fenced native Timer CREATE and DELETE adapters +in the normal Backend Agent process. The packaged process constructs both +adapters against the explicit loopback SuiteBridge endpoint and injects them +into the durable command client. A claimed Control Plane command can therefore +flow through the Agent executor to the private SuiteBridge command service +without an alternate transport or manual handoff. + +The transport endpoint is restricted to loopback and both host and port must be +configured together. Adapter construction performs no mutation and opens no +connection by itself. + +The shipped Timer advertisement remains closed: the packaged +`COMMAND_TYPES` value is empty and the command client still suppresses +`vdr.timer.create` and `vdr.timer.delete` from its availability request. +Opening that advertisement is a separate acceptance-gated action after the +exact candidate has passed the bounded real yaVDR test. diff --git a/mk/backend-agent-runtime.mk b/mk/backend-agent-runtime.mk index 50fa8dd03..131a3494e 100644 --- a/mk/backend-agent-runtime.mk +++ b/mk/backend-agent-runtime.mk @@ -6,6 +6,8 @@ backend-agent: $(AGENT_CHANNEL_DOMAIN_SRC) \ $(AGENT_COMMAND_DOMAIN_SRC) \ $(AGENT_SVDRP_TRANSPORT_STANDALONE_SRC) \ + $(AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC) \ + $(AGENT_NATIVE_TIMER_DELETE_TRANSPORT_SRC) \ apps/agent/main.cpp \ $(LDFLAGS) \ -o $(BUILD_DIR)/vdr-suite-backend-agent diff --git a/mk/phase64-suitebridge-native-timer-command-path-wiring-tests.mk b/mk/phase64-suitebridge-native-timer-command-path-wiring-tests.mk new file mode 100644 index 000000000..1c3a65c5c --- /dev/null +++ b/mk/phase64-suitebridge-native-timer-command-path-wiring-tests.mk @@ -0,0 +1,14 @@ +.PHONY: test-phase64-suitebridge-native-timer-command-path-wiring-architecture test-phase64-suitebridge-native-timer-command-path-wiring + +test-phase64-suitebridge-native-timer-command-path-wiring-architecture: + python3 tools/check_phase64_suitebridge_native_timer_command_path_wiring.py + +test-phase64-suitebridge-native-timer-command-path-wiring: \ + test-phase64-suitebridge-native-timer-command-path-wiring-architecture \ + test-backend-agent-client \ + test-phase64-suitebridge-native-timer-create-transport \ + test-phase64-suitebridge-native-timer-delete-disabled-transport + $(MAKE) backend-agent + +test-fast: test-phase64-suitebridge-native-timer-command-path-wiring +test-architecture: test-phase64-suitebridge-native-timer-command-path-wiring-architecture diff --git a/packaging/systemd/backend-agent.conf b/packaging/systemd/backend-agent.conf index 122b7eebf..fdd8dc7a0 100644 --- a/packaging/systemd/backend-agent.conf +++ b/packaging/systemd/backend-agent.conf @@ -13,6 +13,11 @@ ADAPTERS= OBSERVATION_DOMAINS=backend-health CHANNELS_CONF_PATH=/var/lib/vdr/channels.conf COMMAND_STATE_PATH=/var/lib/vdr-suite/backend-agent/commands.state + +# Native Timer transports are constructed against loopback SuiteBridge, but +# command advertisement remains closed until the Phase-64 acceptance gate. +SUITEBRIDGE_HOST=127.0.0.1 +SUITEBRIDGE_PORT=6419 COMMAND_TYPES= HEARTBEAT_INTERVAL_SECONDS=30 diff --git a/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py new file mode 100755 index 000000000..a9aa41bcc --- /dev/null +++ b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def read(path: str) -> str: + target = ROOT / path + if not target.is_file(): + raise SystemExit(f"missing native Timer command-path wiring file: {path}") + return target.read_text(encoding="utf-8") + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f"missing {label}: {needle}") + +def forbid(text: str, needle: str, label: str) -> None: + if needle in text: + raise SystemExit(f"forbidden {label}: {needle}") + +makefile = read("Makefile") +header = read("core/agent/include/BackendAgentClient.h") +client = read("core/agent/src/BackendAgentClient.cpp") +command_client = read("core/agent/src/BackendAgentCommandClient.cpp") +main = read("apps/agent/main.cpp") +runtime_mk = read("mk/backend-agent-runtime.mk") +packaged = read("packaging/systemd/backend-agent.conf") +test = read("core/agent/tests/test_backend_agent_client.cpp") +doc = read("docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md") + +include = "include mk/phase64-suitebridge-native-timer-command-path-wiring-tests.mk" +require(makefile, include, "command-path wiring Make include") +if makefile.count(include) != 1: + raise SystemExit("command-path wiring Make include must occur exactly once") + +for needle, label in ( + ("suiteBridgeHost", "loopback SuiteBridge host configuration"), + ("suiteBridgePort", "SuiteBridge port configuration"), + ("nativeTimerCreateTransport = nullptr", "default-closed CREATE injection"), + ("nativeTimerDeleteTransport = nullptr", "default-closed DELETE injection"), +): + require(header, needle, label) + +for needle, label in ( + ('"SUITEBRIDGE_HOST"', "SuiteBridge host parser"), + ('"SUITEBRIDGE_PORT"', "SuiteBridge port parser"), + ("loopbackHost(config.suiteBridgeHost)", "loopback-only SuiteBridge fence"), + ("config_.nativeTimerCreateTransport", "CREATE CommandClient injection"), + ("config_.nativeTimerDeleteTransport", "DELETE CommandClient injection"), +): + require(client, needle, label) + +for needle, label in ( + ('#include "SuiteBridgeNativeTimerCreateTransport.h"', "CREATE adapter include"), + ('#include "SuiteBridgeNativeTimerDeleteTransport.h"', "DELETE adapter include"), + ("SuiteBridgeNativeTimerCreateTransport", "CREATE adapter construction"), + ("SuiteBridgeNativeTimerDeleteTransport", "DELETE adapter construction"), + ("config.nativeTimerCreateTransport =", "CREATE runtime injection"), + ("config.nativeTimerDeleteTransport =", "DELETE runtime injection"), +): + require(main, needle, label) + +for needle, label in ( + ("$(AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC)", "CREATE adapter binary link"), + ("$(AGENT_NATIVE_TIMER_DELETE_TRANSPORT_SRC)", "DELETE adapter binary link"), +): + require(runtime_mk, needle, label) + +for needle, label in ( + ("SUITEBRIDGE_HOST=127.0.0.1", "packaged loopback host"), + ("SUITEBRIDGE_PORT=6419", "packaged SuiteBridge port"), + ("COMMAND_TYPES=", "closed packaged command advertisement"), +): + require(packaged, needle, label) +for token in ("vdr.timer.create", "vdr.timer.delete"): + forbid(packaged, token, "premature packaged Timer command advertisement") + +availability = command_client[ + command_client.find("CommandAvailability availableCommands"): + command_client.find("bool reconcileBackendAgentCommandState") +] +for needle in ( + "kBackendAgentNativeTimerCreateCommandType", + "kBackendAgentNativeTimerDeleteCommandType", + "continue;", +): + require(availability, needle, "closed Timer advertisement fence") + +for needle in ( + "SUITEBRIDGE_HOST=127.0.0.1", + "SUITEBRIDGE_PORT=6419", + 'config.suiteBridgeHost == "127.0.0.1"', + "config.suiteBridgePort == 6419", + "invalid_suitebridge_configuration", +): + require(test, needle, "SuiteBridge configuration regression") + +require(doc, "advertisement remains closed", "closed advertisement documentation") +require(doc, "Control Plane", "productive command path documentation") +print("Phase 64 SuiteBridge native Timer command-path wiring guard passed") diff --git a/tools/check_phase64_suitebridge_native_timer_create_command_service.py b/tools/check_phase64_suitebridge_native_timer_create_command_service.py old mode 100644 new mode 100755 index 9b882dd29..e289f99c0 --- a/tools/check_phase64_suitebridge_native_timer_create_command_service.py +++ b/tools/check_phase64_suitebridge_native_timer_create_command_service.py @@ -26,6 +26,7 @@ def forbid(text: str, needle: str, label: str) -> None: plugin_makefile = read("vdr-plugin-suite-bridge/Makefile") client = read("core/agent/src/BackendAgentCommandClient.cpp") agent_client = read("core/agent/src/BackendAgentClient.cpp") +agent_main = read("apps/agent/main.cpp") for needle in ( "timerAssignmentId", "expectedAssignmentRevision", @@ -73,9 +74,9 @@ def forbid(text: str, needle: str, label: str) -> None: require(availability, "kBackendAgentNativeTimerCreateCommandType", "CREATE advertisement fence") require(availability, "continue;", "CREATE advertisement suppression") -forbid(agent_client, "SuiteBridgeNativeTimerCreateTransport", - "production CREATE adapter construction") -forbid(agent_client, "nativeTimerCreateTransport", - "production CREATE transport injection") +require(agent_main, "SuiteBridgeNativeTimerCreateTransport", + "production CREATE adapter construction successor") +require(agent_client, "config_.nativeTimerCreateTransport", + "production CREATE transport injection successor") print("Phase 64 SuiteBridge NTCREATE command service architecture guard passed") diff --git a/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py index f3c9da7c7..2ffef970b 100755 --- a/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py +++ b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py @@ -24,6 +24,7 @@ def forbid(text: str, needle: str, label: str) -> None: svdrp = read("vdr-plugin-suite-bridge/suitebridge_svdrp.cpp") client = read("core/agent/src/BackendAgentCommandClient.cpp") agent_client = read("core/agent/src/BackendAgentClient.cpp") +agent_main = read("apps/agent/main.cpp") packaged = read("packaging/systemd/backend-agent.conf") for needle in ( @@ -58,9 +59,9 @@ def forbid(text: str, needle: str, label: str) -> None: require(available, "kBackendAgentNativeTimerCreateCommandType", "CREATE advertisement fence") require(available, "continue;", "CREATE advertisement suppression") -forbid(agent_client, "SuiteBridgeNativeTimerCreateTransport", - "production CREATE adapter construction") -forbid(agent_client, "nativeTimerCreateTransport", - "production CREATE transport injection") +require(agent_main, "SuiteBridgeNativeTimerCreateTransport", + "production CREATE adapter construction successor") +require(agent_client, "config_.nativeTimerCreateTransport", + "production CREATE transport injection successor") forbid(packaged, "vdr.timer.create", "packaged CREATE advertisement") print("Phase 64 SuiteBridge native Timer CREATE real-mutation guard passed") diff --git a/tools/check_phase64_suitebridge_native_timer_create_transport.py b/tools/check_phase64_suitebridge_native_timer_create_transport.py index f9a0298b8..49e5cb064 100755 --- a/tools/check_phase64_suitebridge_native_timer_create_transport.py +++ b/tools/check_phase64_suitebridge_native_timer_create_transport.py @@ -45,6 +45,7 @@ def forbid(text: str, needle: str, label: str) -> None: agent_client = read( "core/agent/src/BackendAgentClient.cpp" ) +agent_main = read("apps/agent/main.cpp") packaged = read( "packaging/systemd/backend-agent.conf" ) @@ -281,16 +282,16 @@ def forbid(text: str, needle: str, label: str) -> None: "CREATE advertisement suppression", ) -forbid( - agent_client, +require( + agent_main, "SuiteBridgeNativeTimerCreateTransport", - "production CREATE adapter construction", + "production CREATE adapter construction successor", ) -forbid( +require( agent_client, - "nativeTimerCreateTransport", - "production CREATE transport injection", + "config_.nativeTimerCreateTransport", + "production CREATE transport injection successor", ) forbid( diff --git a/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py b/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py index 78a2ac255..a83feae7d 100755 --- a/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py +++ b/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py @@ -36,6 +36,7 @@ def forbid(text: str, needle: str, label: str) -> None: plugin_makefile = read("vdr-plugin-suite-bridge/Makefile") command_client = read("core/agent/src/BackendAgentCommandClient.cpp") agent_client = read("core/agent/src/BackendAgentClient.cpp") +agent_main = read("apps/agent/main.cpp") packaged_config = read("packaging/systemd/backend-agent.conf") mk = read("mk/phase64-suitebridge-native-timer-delete-disabled-transport-tests.mk") doc = read("docs/development/phase-64-suitebridge-native-timer-delete-disabled-transport.md") @@ -116,8 +117,8 @@ def forbid(text: str, needle: str, label: str) -> None: require(plugin_makefile, "suitebridge_native_timer_delete_vdr.o", "plugin native VDR mutation object build") require(plugin_makefile, "test-native-timer-delete", "plugin Timer-delete service unit target") -forbid(agent_client, "SuiteBridgeNativeTimerDeleteTransport", "production Timer-delete adapter construction") -forbid(agent_client, "nativeTimerDeleteTransport", "production Timer-delete transport injection") +require(agent_main, "SuiteBridgeNativeTimerDeleteTransport", "production Timer-delete adapter construction successor") +require(agent_client, "config_.nativeTimerDeleteTransport", "production Timer-delete transport injection successor") forbid(agent_client, "vdr.timer.delete", "production Timer-delete Agent configuration") forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete configuration") available = command_client.split("CommandAvailability availableCommands(", 1)[1].split("\n}\n}\n\nbool reconcileBackendAgentCommandState(", 1)[0] diff --git a/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py b/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py old mode 100644 new mode 100755 index f48280891..1ddf37776 --- a/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py +++ b/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py @@ -33,6 +33,7 @@ def forbid(text: str, needle: str, label: str) -> None: transport_source = read("core/agent/src/SuiteBridgeSvdrpNativeTimerDeleteTransport.cpp") transport_test = read("core/agent/tests/test_suite_bridge_svdrp_native_timer_delete_transport.cpp") agent_client = read("core/agent/src/BackendAgentClient.cpp") +agent_main = read("apps/agent/main.cpp") command_client = read("core/agent/src/BackendAgentCommandClient.cpp") packaged_config = read("packaging/systemd/backend-agent.conf") @@ -152,8 +153,8 @@ def forbid(text: str, needle: str, label: str) -> None: require(transport_test, needle, label) # The real callback gate does not silently open the Agent/shipped command path. -forbid(agent_client, "SuiteBridgeNativeTimerDeleteTransport", "installed Agent Timer-delete transport construction") -forbid(agent_client, "nativeTimerDeleteTransport", "installed Agent Timer-delete transport injection") +require(agent_main, "SuiteBridgeNativeTimerDeleteTransport", "installed Agent Timer-delete transport construction successor") +require(agent_client, "config_.nativeTimerDeleteTransport", "installed Agent Timer-delete transport injection successor") forbid(agent_client, "vdr.timer.delete", "installed Agent Timer-delete configuration") forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete advertisement") available = command_client.split("CommandAvailability availableCommands(", 1)[1].split( diff --git a/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py b/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py old mode 100644 new mode 100755 index e1dad4341..418ad3074 --- a/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py +++ b/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py @@ -28,6 +28,7 @@ def forbid(text: str, needle: str, label: str) -> None: plugin_main_header = read("vdr-plugin-suite-bridge/suitebridge.h") plugin_main = read("vdr-plugin-suite-bridge/suitebridge.cpp") agent_client = read("core/agent/src/BackendAgentClient.cpp") +agent_main = read("apps/agent/main.cpp") packaged_config = read("packaging/systemd/backend-agent.conf") mk = read("mk/phase64-suitebridge-native-timer-delete-replay-ledger-callback-tests.mk") @@ -97,7 +98,8 @@ def forbid(text: str, needle: str, label: str) -> None: "&nativeTimerDeleteVdrMutation_", "real-mutation successor callback wiring", ) -forbid(agent_client, "SuiteBridgeNativeTimerDeleteTransport", "installed Agent Timer-delete transport wiring") +require(agent_main, "SuiteBridgeNativeTimerDeleteTransport", "installed Agent Timer-delete transport wiring successor") +require(agent_client, "config_.nativeTimerDeleteTransport", "installed Agent Timer-delete transport injection successor") forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete advertisement") for needle, label in ( From 512a30d2c5198aae36280fb181133b743b4667bb Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:06:04 +0200 Subject: [PATCH 46/85] Preserve managed correlation on native Timer CREATE --- ...ridge-native-timer-create-real-mutation.md | 23 +++++++----- ...ridge_native_timer_create_real_mutation.py | 5 +++ .../suitebridge_native_timer_create_vdr.cpp | 36 ++++++++++++++++++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/docs/development/phase-64-suitebridge-native-timer-create-real-mutation.md b/docs/development/phase-64-suitebridge-native-timer-create-real-mutation.md index fe9671cbf..00f1ad403 100644 --- a/docs/development/phase-64-suitebridge-native-timer-create-real-mutation.md +++ b/docs/development/phase-64-suitebridge-native-timer-create-real-mutation.md @@ -1,13 +1,18 @@ # Phase 64: SuiteBridge native Timer CREATE mutation -This successor wires the private NTCREATE command service to one dedicated VDR callback. -The callback parses a validated backend-neutral specification into a local cTimer, obtains -a bounded Timer write lock, and performs exactly one cTimers::Add mutation. +This successor wires the private NTCREATE command service to one dedicated VDR +callback. The callback parses a validated backend-neutral specification into a +local `cTimer`, attaches the stable assignment/binding correlation marker, +obtains a bounded Timer write lock, and performs exactly one `cTimers::Add` +mutation. -Callback success is only AppliedUnverified. It does not prove correlation, persistence, -binding, or assignment completion. Those remain gated by authoritative native-Timer -inventory readback and exact managed specification verification. +Callback success is only `AppliedUnverified`. It does not prove persistence, +binding, or assignment completion. The correlation marker makes the created +Timer identifiable in the next complete inventory; authoritative readback must +still match the marker and exact expected specification before persisting the +`NativeTimerBinding` or completing the assignment. -NTCREATE remains absent from public SVDRP help. Agent construction, packaged command -advertisement, and Timer-write advertisement remain closed until bounded real yaVDR -acceptance succeeds. +NTCREATE remains absent from public SVDRP help. The normal Backend Agent now +constructs the private transport against loopback SuiteBridge, while packaged +command advertisement and Timer-write advertisement remain closed until the +bounded real yaVDR acceptance succeeds. diff --git a/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py index 2ffef970b..178089b83 100755 --- a/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py +++ b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py @@ -38,7 +38,12 @@ def forbid(text: str, needle: str, label: str) -> None: "timers->SetExplicitModify()", "timers->Add(timer.get())", "timer.release()", "timers->SetModified()", "stateKey.Remove();", "AppliedUnverified", 'evidence("created-unverified"', + '"Add(timer.get())") != 1: raise SystemExit("CREATE callback must contain exactly one native Add") diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp b/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp index 5ec7ab8aa..9a4023c9e 100644 --- a/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_create_vdr.cpp @@ -87,6 +87,34 @@ std::string timerDay( return valid ? request.day : std::string(); } +char upperHex(unsigned value) +{ + return static_cast(value < 10U ? '0' + value : 'A' + value - 10U); +} + +std::string hexIdentity(const std::string &value) +{ + if (value.empty() || value.size() > 160) return {}; + std::string encoded; + encoded.reserve(value.size() * 2U); + for (unsigned char character : value) { + encoded.push_back(upperHex((character >> 4U) & 0x0fU)); + encoded.push_back(upperHex(character & 0x0fU)); + } + return encoded; +} + +std::string managedCorrelation( + const SuiteBridgeNativeTimerCreateRequest &request) +{ + const std::string assignment = hexIdentity(request.timerAssignmentId); + const std::string binding = hexIdentity(request.nativeTimerBindingId); + if (assignment.empty() || binding.empty()) return {}; + return std::string( + ""; +} + std::string timerFile( const SuiteBridgeNativeTimerCreateRequest &request, bool &valid) @@ -123,6 +151,12 @@ SuiteBridgeNativeTimerCreateVdrMutationCallback::CreateTimer( SuiteBridgeNativeTimerCreateMutationDisposition::RejectedWithoutEffect, evidence("invalid-file", request.commandId)); + const std::string aux = managedCorrelation(request); + if (aux.empty()) + return result( + SuiteBridgeNativeTimerCreateMutationDisposition::RejectedWithoutEffect, + evidence("invalid-correlation", request.commandId)); + const unsigned flags = (request.enabled ? tfActive : tfNone) | (request.vps ? tfVps : tfNone); @@ -130,7 +164,7 @@ SuiteBridgeNativeTimerCreateVdrMutationCallback::CreateTimer( definition << flags << ':' << request.channelId << ':' << day << ':' << request.startTime << ':' << request.endTime << ':' << request.priority << ':' << request.lifetime << ':' - << file << ':'; + << file << ':' << aux; std::unique_ptr timer(new cTimer()); if (!timer->Parse(definition.str().c_str())) From ebc7f8dab5d46ef7d593eebc591f048b8b6813c2 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:11:26 +0200 Subject: [PATCH 47/85] Add managed native Timer UPDATE and TOGGLE operations --- Makefile | 1 + .../NativeTimerModifyOperationPayload.h | 45 +++ ...veTimerModifyOperationPreparationService.h | 103 +++++++ ...veTimerModifyReadbackVerificationService.h | 66 +++++ .../src/NativeTimerModifyOperationPayload.cpp | 262 ++++++++++++++++ ...TimerModifyOperationPreparationService.cpp | 279 ++++++++++++++++++ ...TimerModifyReadbackVerificationService.cpp | 139 +++++++++ .../test_native_timer_modify_operation.cpp | 261 ++++++++++++++++ .../phase-64-native-timer-modify-operation.md | 18 ++ ...e64-native-timer-modify-operation-tests.mk | 30 ++ ...k_phase64_native_timer_modify_operation.py | 76 +++++ 11 files changed, 1280 insertions(+) create mode 100644 core/timers/include/NativeTimerModifyOperationPayload.h create mode 100644 core/timers/include/NativeTimerModifyOperationPreparationService.h create mode 100644 core/timers/include/NativeTimerModifyReadbackVerificationService.h create mode 100644 core/timers/src/NativeTimerModifyOperationPayload.cpp create mode 100644 core/timers/src/NativeTimerModifyOperationPreparationService.cpp create mode 100644 core/timers/src/NativeTimerModifyReadbackVerificationService.cpp create mode 100644 core/timers/tests/test_native_timer_modify_operation.cpp create mode 100644 docs/development/phase-64-native-timer-modify-operation.md create mode 100644 mk/phase64-native-timer-modify-operation-tests.mk create mode 100755 tools/check_phase64_native_timer_modify_operation.py diff --git a/Makefile b/Makefile index e153122f8..471cb46a2 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,7 @@ include mk/phase64-native-timer-absence-readback-expectation-tests.mk include mk/phase64-native-timer-absence-readback-verification-tests.mk include mk/phase64-mutation-operation-repository-tests.mk include mk/phase64-native-timer-create-operation-preparation-tests.mk +include mk/phase64-native-timer-modify-operation-tests.mk include mk/phase64-native-timer-delete-operation-completion-tests.mk include mk/phase64-native-timer-delete-operation-preparation-tests.mk include mk/phase64-native-timer-delete-dispatch-tests.mk diff --git a/core/timers/include/NativeTimerModifyOperationPayload.h b/core/timers/include/NativeTimerModifyOperationPayload.h new file mode 100644 index 000000000..e3f3bc414 --- /dev/null +++ b/core/timers/include/NativeTimerModifyOperationPayload.h @@ -0,0 +1,45 @@ +#pragma once + +#include "NativeTimerSpecification.h" + +#include +#include + +namespace vdrsuite::timers +{ + +enum class NativeTimerModifyKind +{ + update, + toggle, +}; + +const char* nativeTimerModifyKindName(NativeTimerModifyKind kind); + +struct NativeTimerModifyOperationPayload +{ + NativeTimerModifyKind kind = NativeTimerModifyKind::update; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch = 0; + std::string nativeTimerBindingId; + std::string expectedBindingRevision; + std::string backendId; + std::uint64_t backendGeneration = 0; + std::string backendNativeTimerId; + std::string expectedCurrentFingerprint; + NativeTimerSpecification expectedSpecification; +}; + +bool nativeTimerModifyOperationPayloadValid( + const NativeTimerModifyOperationPayload& payload); +std::string serializeNativeTimerModifyOperationPayload( + const NativeTimerModifyOperationPayload& payload); +bool parseNativeTimerModifyOperationPayload( + const std::string& serialized, + NativeTimerModifyOperationPayload& payload); +std::string nativeTimerModifyOperationPayloadFingerprint( + const NativeTimerModifyOperationPayload& payload); + +} diff --git a/core/timers/include/NativeTimerModifyOperationPreparationService.h b/core/timers/include/NativeTimerModifyOperationPreparationService.h new file mode 100644 index 000000000..868cd482a --- /dev/null +++ b/core/timers/include/NativeTimerModifyOperationPreparationService.h @@ -0,0 +1,103 @@ +#pragma once + +#include "MutationOperation.h" +#include "NativeTimerModifyOperationPayload.h" + +#include +#include + +namespace vdrsuite::operations +{ +class MutationOperationRepository; +} + +namespace vdrsuite::timers +{ + +class NativeTimerBindingRepository; +class TimerAssignmentRepository; +class TimerIntentRepository; + +enum class NativeTimerModifyOperationPreparationStatus +{ + prepared, + alreadyPrepared, + intentNotFound, + assignmentNotFound, + bindingNotFound, + intentRevisionConflict, + intentStateConflict, + assignmentRevisionConflict, + assignmentEpochConflict, + assignmentStateConflict, + bindingRevisionConflict, + identityConflict, + ownershipConflict, + generationConflict, + currentFingerprintConflict, + bindingStateConflict, + toggleShapeConflict, + operationStateConflict, + idempotencyConflict, + operationConflict, + repositoryError, + invalid, +}; + +struct NativeTimerModifyOperationPreparationRequest +{ + std::string operationId; + std::string idempotencyKey; + std::string actorId; + std::string requestFingerprint; + NativeTimerModifyKind kind = NativeTimerModifyKind::update; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t expectedAssignmentEpoch = 0; + std::string nativeTimerBindingId; + std::string expectedBindingRevision; + std::string expectedBackendId; + std::uint64_t expectedBackendGeneration = 0; + std::string backendNativeTimerId; + std::string expectedCurrentFingerprint; + NativeTimerSpecification expectedSpecification; + std::int64_t requestedAt = 0; + std::int64_t deadline = 0; +}; + +struct NativeTimerModifyOperationPreparationResult +{ + NativeTimerModifyOperationPreparationStatus status = + NativeTimerModifyOperationPreparationStatus::repositoryError; + vdrsuite::operations::MutationOperation operation; + NativeTimerModifyOperationPayload payload; + + bool ok() const + { + return status == NativeTimerModifyOperationPreparationStatus::prepared + || status == + NativeTimerModifyOperationPreparationStatus::alreadyPrepared; + } +}; + +class NativeTimerModifyOperationPreparationService +{ +public: + NativeTimerModifyOperationPreparationService( + TimerIntentRepository& intentRepository, + TimerAssignmentRepository& assignmentRepository, + NativeTimerBindingRepository& bindingRepository, + vdrsuite::operations::MutationOperationRepository& operationRepository); + + NativeTimerModifyOperationPreparationResult prepare( + const NativeTimerModifyOperationPreparationRequest& request); + +private: + TimerIntentRepository& intentRepository_; + TimerAssignmentRepository& assignmentRepository_; + NativeTimerBindingRepository& bindingRepository_; + vdrsuite::operations::MutationOperationRepository& operationRepository_; +}; + +} diff --git a/core/timers/include/NativeTimerModifyReadbackVerificationService.h b/core/timers/include/NativeTimerModifyReadbackVerificationService.h new file mode 100644 index 000000000..96107d881 --- /dev/null +++ b/core/timers/include/NativeTimerModifyReadbackVerificationService.h @@ -0,0 +1,66 @@ +#pragma once + +#include "NativeTimerModifyOperationPayload.h" +#include "NativeTimerObservation.h" + +namespace vdrsuite::timers +{ + +class NativeTimerBindingRepository; + +enum class NativeTimerModifyReadbackVerificationStatus +{ + verified, + alreadyVerified, + bindingNotFound, + bindingRevisionConflict, + identityConflict, + ownershipConflict, + generationConflict, + staleObservation, + predecessorFingerprintConflict, + reconciliationRequired, + repositoryConflict, + repositoryError, + invalid, +}; + +struct NativeTimerModifyReadbackExpectation +{ + std::string operationId; + NativeTimerModifyOperationPayload payload; + std::int64_t readbackNotBefore = 0; +}; + +bool nativeTimerModifyReadbackExpectationValid( + const NativeTimerModifyReadbackExpectation& expectation); + +struct NativeTimerModifyReadbackVerificationResult +{ + NativeTimerModifyReadbackVerificationStatus status = + NativeTimerModifyReadbackVerificationStatus::repositoryError; + NativeTimerBinding binding; + + bool ok() const + { + return status == NativeTimerModifyReadbackVerificationStatus::verified + || status == + NativeTimerModifyReadbackVerificationStatus::alreadyVerified; + } +}; + +class NativeTimerModifyReadbackVerificationService +{ +public: + explicit NativeTimerModifyReadbackVerificationService( + NativeTimerBindingRepository& repository); + + NativeTimerModifyReadbackVerificationResult verify( + const NativeTimerModifyReadbackExpectation& expectation, + const NativeTimerObservation& observation); + +private: + NativeTimerBindingRepository& repository_; +}; + +} diff --git a/core/timers/src/NativeTimerModifyOperationPayload.cpp b/core/timers/src/NativeTimerModifyOperationPayload.cpp new file mode 100644 index 000000000..9ca26173b --- /dev/null +++ b/core/timers/src/NativeTimerModifyOperationPayload.cpp @@ -0,0 +1,262 @@ +#include "NativeTimerModifyOperationPayload.h" + +#include +#include +#include +#include +#include +#include + +namespace vdrsuite::timers +{ +namespace +{ +constexpr std::size_t MaxIdentity = 160; +constexpr std::size_t MaxFingerprint = 4096; +constexpr const char* PayloadPrefix = + "native-timer-modify-operation-payload/1|"; +constexpr const char* FingerprintPrefix = + "native-timer-modify-operation-payload-fingerprint/1|"; + +bool identity(const std::string& value) +{ + return !value.empty() && value.size() <= MaxIdentity; +} + +std::string normalizedHhmm(const std::string& value) +{ + return std::string(4 - value.size(), '0') + value; +} + +void append(std::string& output, const std::string& value) +{ + output += std::to_string(value.size()); + output += ':'; + output += value; + output += '|'; +} + +void append(std::string& output, std::uint64_t value) +{ + append(output, std::to_string(value)); +} + +void append(std::string& output, std::int32_t value) +{ + append(output, std::to_string(value)); +} + +void append(std::string& output, bool value) +{ + append(output, std::string(value ? "1" : "0")); +} + +std::string canonical(const NativeTimerModifyOperationPayload& payload) +{ + if (!nativeTimerModifyOperationPayloadValid(payload)) return {}; + std::string output; + append(output, nativeTimerModifyKindName(payload.kind)); + append(output, payload.timerAssignmentId); + append(output, payload.expectedAssignmentRevision); + append(output, payload.expectedIntentRevision); + append(output, payload.assignmentEpoch); + append(output, payload.nativeTimerBindingId); + append(output, payload.expectedBindingRevision); + append(output, payload.backendId); + append(output, payload.backendGeneration); + append(output, payload.backendNativeTimerId); + append(output, payload.expectedCurrentFingerprint); + const auto& specification = payload.expectedSpecification; + append(output, specification.channelId); + append(output, specification.title); + append(output, specification.directory); + append(output, specification.day); + append(output, specification.weekdays); + append(output, normalizedHhmm(specification.startTime)); + append(output, normalizedHhmm(specification.endTime)); + append(output, specification.priority); + append(output, specification.lifetime); + append(output, specification.enabled); + append(output, specification.vps); + return output; +} + +bool read( + const std::string& input, + std::size_t& position, + std::string& value) +{ + if (position >= input.size()) return false; + std::size_t length = 0; + bool digitSeen = false; + while (position < input.size() && input[position] != ':') + { + const char character = input[position++]; + if (character < '0' || character > '9') return false; + digitSeen = true; + const std::size_t digit = + static_cast(character - '0'); + if (length > + (std::numeric_limits::max() - digit) / 10) + return false; + length = length * 10 + digit; + } + if (!digitSeen || position >= input.size() || + input[position] != ':') return false; + ++position; + if (length > input.size() - position) return false; + value = input.substr(position, length); + position += length; + if (position >= input.size() || input[position] != '|') return false; + ++position; + return true; +} + +bool unsignedValue(const std::string& token, std::uint64_t& value) +{ + if (token.empty()) return false; + value = 0; + for (char character : token) + { + if (character < '0' || character > '9') return false; + const std::uint64_t digit = + static_cast(character - '0'); + if (value > + (std::numeric_limits::max() - digit) / 10) + return false; + value = value * 10 + digit; + } + return value > 0; +} + +bool smallInt(const std::string& token, std::int32_t& value) +{ + if (token.empty() || token.size() > 2) return false; + int parsed = 0; + for (char character : token) + { + if (character < '0' || character > '9') return false; + parsed = parsed * 10 + character - '0'; + } + if (parsed > 99) return false; + value = parsed; + return true; +} + +bool booleanValue(const std::string& token, bool& value) +{ + if (token == "0") { value = false; return true; } + if (token == "1") { value = true; return true; } + return false; +} + +bool kindValue(const std::string& token, NativeTimerModifyKind& kind) +{ + if (token == "update") + { + kind = NativeTimerModifyKind::update; + return true; + } + if (token == "toggle") + { + kind = NativeTimerModifyKind::toggle; + return true; + } + return false; +} +} + +const char* nativeTimerModifyKindName(NativeTimerModifyKind kind) +{ + switch (kind) + { + case NativeTimerModifyKind::update: return "update"; + case NativeTimerModifyKind::toggle: return "toggle"; + } + return "invalid"; +} + +bool nativeTimerModifyOperationPayloadValid( + const NativeTimerModifyOperationPayload& payload) +{ + return std::string(nativeTimerModifyKindName(payload.kind)) != "invalid" + && identity(payload.timerAssignmentId) + && identity(payload.expectedAssignmentRevision) + && identity(payload.expectedIntentRevision) + && payload.assignmentEpoch > 0 + && identity(payload.nativeTimerBindingId) + && identity(payload.expectedBindingRevision) + && identity(payload.backendId) + && payload.backendGeneration > 0 + && identity(payload.backendNativeTimerId) + && !payload.expectedCurrentFingerprint.empty() + && payload.expectedCurrentFingerprint.size() <= MaxFingerprint + && nativeTimerSpecificationValid(payload.expectedSpecification); +} + +std::string serializeNativeTimerModifyOperationPayload( + const NativeTimerModifyOperationPayload& payload) +{ + const std::string body = canonical(payload); + return body.empty() ? std::string() : std::string(PayloadPrefix) + body; +} + +bool parseNativeTimerModifyOperationPayload( + const std::string& serialized, + NativeTimerModifyOperationPayload& payload) +{ + const std::string prefix(PayloadPrefix); + if (serialized.compare(0, prefix.size(), prefix) != 0) return false; + std::size_t position = prefix.size(); + std::vector fields; + while (position < serialized.size()) + { + std::string field; + if (!read(serialized, position, field)) return false; + fields.push_back(std::move(field)); + if (fields.size() > 22) return false; + } + if (fields.size() != 22) return false; + + NativeTimerModifyOperationPayload parsed; + if (!kindValue(fields[0], parsed.kind)) return false; + parsed.timerAssignmentId = fields[1]; + parsed.expectedAssignmentRevision = fields[2]; + parsed.expectedIntentRevision = fields[3]; + if (!unsignedValue(fields[4], parsed.assignmentEpoch)) return false; + parsed.nativeTimerBindingId = fields[5]; + parsed.expectedBindingRevision = fields[6]; + parsed.backendId = fields[7]; + if (!unsignedValue(fields[8], parsed.backendGeneration)) return false; + parsed.backendNativeTimerId = fields[9]; + parsed.expectedCurrentFingerprint = fields[10]; + auto& specification = parsed.expectedSpecification; + specification.channelId = fields[11]; + specification.title = fields[12]; + specification.directory = fields[13]; + specification.day = fields[14]; + specification.weekdays = fields[15]; + specification.startTime = fields[16]; + specification.endTime = fields[17]; + if (!smallInt(fields[18], specification.priority) + || !smallInt(fields[19], specification.lifetime) + || !booleanValue(fields[20], specification.enabled) + || !booleanValue(fields[21], specification.vps)) + return false; + if (!nativeTimerModifyOperationPayloadValid(parsed) + || serializeNativeTimerModifyOperationPayload(parsed) != serialized) + return false; + payload = std::move(parsed); + return true; +} + +std::string nativeTimerModifyOperationPayloadFingerprint( + const NativeTimerModifyOperationPayload& payload) +{ + const std::string body = canonical(payload); + return body.empty() + ? std::string() + : std::string(FingerprintPrefix) + body; +} + +} diff --git a/core/timers/src/NativeTimerModifyOperationPreparationService.cpp b/core/timers/src/NativeTimerModifyOperationPreparationService.cpp new file mode 100644 index 000000000..e2281b84c --- /dev/null +++ b/core/timers/src/NativeTimerModifyOperationPreparationService.cpp @@ -0,0 +1,279 @@ +#include "NativeTimerModifyOperationPreparationService.h" + +#include "MutationOperationRepository.h" +#include "NativeTimerBindingRepository.h" +#include "TimerAssignmentRepository.h" +#include "TimerIntentRepository.h" + +#include + +namespace vdrsuite::timers +{ +namespace +{ +constexpr std::size_t MaxIdentity = 160; +constexpr std::size_t MaxFingerprint = 4096; + +bool identity(const std::string& value) +{ + return !value.empty() && value.size() <= MaxIdentity; +} + +bool requestValid(const NativeTimerModifyOperationPreparationRequest& request) +{ + return identity(request.operationId) + && identity(request.idempotencyKey) + && identity(request.actorId) + && !request.requestFingerprint.empty() + && request.requestFingerprint.size() <= 512 + && identity(request.timerAssignmentId) + && identity(request.expectedAssignmentRevision) + && identity(request.expectedIntentRevision) + && request.expectedAssignmentEpoch > 0 + && identity(request.nativeTimerBindingId) + && identity(request.expectedBindingRevision) + && identity(request.expectedBackendId) + && request.expectedBackendGeneration > 0 + && identity(request.backendNativeTimerId) + && !request.expectedCurrentFingerprint.empty() + && request.expectedCurrentFingerprint.size() <= MaxFingerprint + && nativeTimerSpecificationValid(request.expectedSpecification) + && request.requestedAt > 0 + && (request.deadline == 0 || + request.deadline >= request.requestedAt); +} + +NativeTimerSpecification specificationFor( + const NativeTimerObservedState& state) +{ + NativeTimerSpecification specification; + specification.channelId = state.channelId; + specification.title = state.title; + specification.directory = state.directory; + specification.day = state.day; + specification.weekdays = state.weekdays; + specification.startTime = state.startTime; + specification.endTime = state.endTime; + specification.priority = state.priority; + specification.lifetime = state.lifetime; + specification.enabled = state.enabled; + specification.vps = state.vps; + return specification; +} + +bool toggleShapeValid( + const NativeTimerSpecification& current, + const NativeTimerSpecification& expected) +{ + NativeTimerSpecification toggled = current; + toggled.enabled = expected.enabled; + return nativeTimerSpecificationFingerprint(toggled) + == nativeTimerSpecificationFingerprint(expected) + && current.enabled != expected.enabled; +} + +NativeTimerModifyOperationPreparationResult result( + NativeTimerModifyOperationPreparationStatus status, + const vdrsuite::operations::MutationOperation& operation = {}, + const NativeTimerModifyOperationPayload& payload = {}) +{ + NativeTimerModifyOperationPreparationResult value; + value.status = status; + value.operation = operation; + value.payload = payload; + return value; +} +} + +NativeTimerModifyOperationPreparationService:: +NativeTimerModifyOperationPreparationService( + TimerIntentRepository& intentRepository, + TimerAssignmentRepository& assignmentRepository, + NativeTimerBindingRepository& bindingRepository, + vdrsuite::operations::MutationOperationRepository& operationRepository) + : intentRepository_(intentRepository), + assignmentRepository_(assignmentRepository), + bindingRepository_(bindingRepository), + operationRepository_(operationRepository) +{ +} + +NativeTimerModifyOperationPreparationResult +NativeTimerModifyOperationPreparationService::prepare( + const NativeTimerModifyOperationPreparationRequest& request) +{ + using namespace vdrsuite::operations; + if (!requestValid(request)) + return result(NativeTimerModifyOperationPreparationStatus::invalid); + + const auto assignmentResult = + assignmentRepository_.findById(request.timerAssignmentId); + if (assignmentResult.status == TimerAssignmentRepositoryStatus::notFound) + return result( + NativeTimerModifyOperationPreparationStatus::assignmentNotFound); + if (!assignmentResult.ok()) + return result(NativeTimerModifyOperationPreparationStatus::repositoryError); + const TimerAssignment& assignment = assignmentResult.assignment; + + const auto intentResult = + intentRepository_.findById(assignment.timerIntentId); + if (intentResult.status == TimerIntentRepositoryStatus::notFound) + return result(NativeTimerModifyOperationPreparationStatus::intentNotFound); + if (!intentResult.ok()) + return result(NativeTimerModifyOperationPreparationStatus::repositoryError); + const TimerIntent& intent = intentResult.intent; + + const auto bindingResult = + bindingRepository_.findById(request.nativeTimerBindingId); + if (bindingResult.status == NativeTimerBindingRepositoryStatus::notFound) + return result(NativeTimerModifyOperationPreparationStatus::bindingNotFound); + if (!bindingResult.ok()) + return result(NativeTimerModifyOperationPreparationStatus::repositoryError); + const NativeTimerBinding& binding = bindingResult.binding; + + if (intent.intentRevision != request.expectedIntentRevision + || assignment.intentRevision != request.expectedIntentRevision) + return result( + NativeTimerModifyOperationPreparationStatus::intentRevisionConflict); + if (intent.state != TimerIntentState::active + && !(request.kind == NativeTimerModifyKind::toggle + && intent.state == TimerIntentState::paused)) + return result( + NativeTimerModifyOperationPreparationStatus::intentStateConflict); + if (assignment.assignmentRevision != request.expectedAssignmentRevision) + return result( + NativeTimerModifyOperationPreparationStatus:: + assignmentRevisionConflict); + if (assignment.assignmentEpoch != request.expectedAssignmentEpoch) + return result( + NativeTimerModifyOperationPreparationStatus::assignmentEpochConflict); + if (assignment.state != TimerAssignmentState::bound) + return result( + NativeTimerModifyOperationPreparationStatus::assignmentStateConflict); + if (binding.bindingRevision != request.expectedBindingRevision) + return result( + NativeTimerModifyOperationPreparationStatus::bindingRevisionConflict); + if (assignment.nativeTimerBindingId != binding.nativeTimerBindingId + || binding.timerAssignmentId != assignment.timerAssignmentId + || assignment.backendId != binding.backendId + || request.backendNativeTimerId != binding.backendNativeTimerId) + return result( + NativeTimerModifyOperationPreparationStatus::identityConflict); + if (binding.ownership != NativeTimerBindingOwnership::managed + && binding.ownership != NativeTimerBindingOwnership::adopted) + return result( + NativeTimerModifyOperationPreparationStatus::ownershipConflict); + if (assignment.backendId != request.expectedBackendId + || binding.backendId != request.expectedBackendId) + return result( + NativeTimerModifyOperationPreparationStatus::identityConflict); + if (assignment.backendGeneration != request.expectedBackendGeneration + || binding.backendGeneration != request.expectedBackendGeneration) + return result( + NativeTimerModifyOperationPreparationStatus::generationConflict); + if (binding.observedFingerprint != request.expectedCurrentFingerprint) + return result( + NativeTimerModifyOperationPreparationStatus:: + currentFingerprintConflict); + if (binding.missingSince != 0 + || binding.driftState != NativeTimerBindingDriftState::none + || binding.observedState.recording + || binding.observedState.pending) + return result( + NativeTimerModifyOperationPreparationStatus::bindingStateConflict); + + if (request.kind == NativeTimerModifyKind::toggle + && !toggleShapeValid( + specificationFor(binding.observedState), + request.expectedSpecification)) + return result( + NativeTimerModifyOperationPreparationStatus::toggleShapeConflict); + + NativeTimerModifyOperationPayload payload; + payload.kind = request.kind; + payload.timerAssignmentId = assignment.timerAssignmentId; + payload.expectedAssignmentRevision = assignment.assignmentRevision; + payload.expectedIntentRevision = intent.intentRevision; + payload.assignmentEpoch = assignment.assignmentEpoch; + payload.nativeTimerBindingId = binding.nativeTimerBindingId; + payload.expectedBindingRevision = binding.bindingRevision; + payload.backendId = binding.backendId; + payload.backendGeneration = binding.backendGeneration; + payload.backendNativeTimerId = binding.backendNativeTimerId; + payload.expectedCurrentFingerprint = binding.observedFingerprint; + payload.expectedSpecification = request.expectedSpecification; + const std::string serialized = + serializeNativeTimerModifyOperationPayload(payload); + const std::string fingerprint = + nativeTimerModifyOperationPayloadFingerprint(payload); + if (serialized.empty() || fingerprint.empty()) + return result(NativeTimerModifyOperationPreparationStatus::invalid); + + MutationOperation operation; + operation.operationId = request.operationId; + operation.idempotencyKey = request.idempotencyKey; + operation.actorId = request.actorId; + operation.backendId = binding.backendId; + operation.backendGeneration = binding.backendGeneration; + operation.resourceType = "NativeTimerBinding"; + operation.resourceId = binding.nativeTimerBindingId; + operation.expectedRevision = binding.bindingRevision; + operation.expectedResourceFingerprint = binding.observedFingerprint; + operation.actionFamily = request.kind == NativeTimerModifyKind::update + ? "timer.update" : "timer.toggle"; + operation.requestFingerprint = request.requestFingerprint; + operation.requestedAt = request.requestedAt; + operation.deadline = request.deadline; + operation.verificationPolicy = + MutationOperationVerificationPolicy::readbackRequired; + operation.state = MutationOperationState::accepted; + operation.updatedAt = request.requestedAt; + + MutationOperationPayload durable; + durable.operationId = request.operationId; + durable.payloadType = "native.timer.modify"; + durable.payloadVersion = 1; + durable.payload = serialized; + durable.payloadFingerprint = fingerprint; + + const auto reserved = + operationRepository_.reserveWithPayload(operation, durable); + switch (reserved.status) + { + case MutationOperationRepositoryStatus::ok: + return result( + NativeTimerModifyOperationPreparationStatus::prepared, + reserved.operation, + payload); + case MutationOperationRepositoryStatus::idempotentReplay: + return result( + reserved.operation.state == MutationOperationState::accepted + ? NativeTimerModifyOperationPreparationStatus::alreadyPrepared + : NativeTimerModifyOperationPreparationStatus:: + operationStateConflict, + reserved.operation, + payload); + case MutationOperationRepositoryStatus::idempotencyConflict: + return result( + NativeTimerModifyOperationPreparationStatus::idempotencyConflict, + reserved.operation, + payload); + case MutationOperationRepositoryStatus::operationConflict: + return result( + NativeTimerModifyOperationPreparationStatus::operationConflict, + reserved.operation, + payload); + case MutationOperationRepositoryStatus::invalid: + return result( + NativeTimerModifyOperationPreparationStatus::invalid, + reserved.operation, + payload); + default: + return result( + NativeTimerModifyOperationPreparationStatus::repositoryError, + reserved.operation, + payload); + } +} + +} diff --git a/core/timers/src/NativeTimerModifyReadbackVerificationService.cpp b/core/timers/src/NativeTimerModifyReadbackVerificationService.cpp new file mode 100644 index 000000000..d3b6d2da4 --- /dev/null +++ b/core/timers/src/NativeTimerModifyReadbackVerificationService.cpp @@ -0,0 +1,139 @@ +#include "NativeTimerModifyReadbackVerificationService.h" + +#include "NativeTimerBindingRepository.h" +#include "NativeTimerSpecification.h" + +namespace vdrsuite::timers +{ +namespace +{ +NativeTimerModifyReadbackVerificationResult result( + NativeTimerModifyReadbackVerificationStatus status, + const NativeTimerBinding& binding = {}) +{ + NativeTimerModifyReadbackVerificationResult value; + value.status = status; + value.binding = binding; + return value; +} + +bool owned(NativeTimerBindingOwnership ownership) +{ + return ownership == NativeTimerBindingOwnership::managed + || ownership == NativeTimerBindingOwnership::adopted; +} + +bool alreadyVerified( + const NativeTimerBinding& binding, + const NativeTimerModifyReadbackExpectation& expectation, + const NativeTimerObservation& observation) +{ + return binding.lastVerifiedOperationId == expectation.operationId + && binding.backendGeneration == observation.backendGeneration + && binding.observedFingerprint == observation.observedFingerprint + && binding.lastObservedAt >= expectation.readbackNotBefore + && binding.missingSince == 0 + && binding.driftState == NativeTimerBindingDriftState::none; +} +} + +bool nativeTimerModifyReadbackExpectationValid( + const NativeTimerModifyReadbackExpectation& expectation) +{ + return !expectation.operationId.empty() + && expectation.operationId.size() <= 160 + && nativeTimerModifyOperationPayloadValid(expectation.payload) + && expectation.readbackNotBefore > 0; +} + +NativeTimerModifyReadbackVerificationService:: +NativeTimerModifyReadbackVerificationService( + NativeTimerBindingRepository& repository) + : repository_(repository) +{ +} + +NativeTimerModifyReadbackVerificationResult +NativeTimerModifyReadbackVerificationService::verify( + const NativeTimerModifyReadbackExpectation& expectation, + const NativeTimerObservation& observation) +{ + if (!nativeTimerModifyReadbackExpectationValid(expectation) + || !nativeTimerObservationValid(observation)) + return result(NativeTimerModifyReadbackVerificationStatus::invalid); + + const auto& payload = expectation.payload; + const auto found = repository_.findById(payload.nativeTimerBindingId); + if (found.status == NativeTimerBindingRepositoryStatus::notFound) + return result( + NativeTimerModifyReadbackVerificationStatus::bindingNotFound); + if (!found.ok()) + return result( + NativeTimerModifyReadbackVerificationStatus::repositoryError); + const NativeTimerBinding& current = found.binding; + + if (current.timerAssignmentId != payload.timerAssignmentId + || current.backendId != payload.backendId + || current.backendNativeTimerId != payload.backendNativeTimerId + || observation.backendId != payload.backendId + || observation.backendNativeTimerId != payload.backendNativeTimerId) + return result( + NativeTimerModifyReadbackVerificationStatus::identityConflict, + current); + if (!owned(current.ownership)) + return result( + NativeTimerModifyReadbackVerificationStatus::ownershipConflict, + current); + if (current.backendGeneration > payload.backendGeneration + || observation.backendGeneration != payload.backendGeneration) + return result( + NativeTimerModifyReadbackVerificationStatus::generationConflict, + current); + if (observation.observedAt < expectation.readbackNotBefore + || observation.observedAt < current.lastObservedAt) + return result( + NativeTimerModifyReadbackVerificationStatus::staleObservation, + current); + if (!nativeTimerObservationMatchesSpecification( + payload.expectedSpecification, + observation.observedState)) + return result( + NativeTimerModifyReadbackVerificationStatus::reconciliationRequired, + current); + if (alreadyVerified(current, expectation, observation)) + return result( + NativeTimerModifyReadbackVerificationStatus::alreadyVerified, + current); + if (current.bindingRevision != payload.expectedBindingRevision) + return result( + NativeTimerModifyReadbackVerificationStatus:: + bindingRevisionConflict, + current); + if (current.observedFingerprint != payload.expectedCurrentFingerprint) + return result( + NativeTimerModifyReadbackVerificationStatus:: + predecessorFingerprintConflict, + current); + + NativeTimerBinding next = current; + next.backendGeneration = observation.backendGeneration; + next.observedState = observation.observedState; + next.observedFingerprint = observation.observedFingerprint; + next.lastObservedAt = observation.observedAt; + next.lastVerifiedOperationId = expectation.operationId; + next.missingSince = 0; + next.driftState = NativeTimerBindingDriftState::none; + const auto updated = repository_.update(next, current.bindingRevision); + if (updated.status == NativeTimerBindingRepositoryStatus::conflict) + return result( + NativeTimerModifyReadbackVerificationStatus::repositoryConflict, + updated.binding); + if (!updated.ok()) + return result( + NativeTimerModifyReadbackVerificationStatus::repositoryError); + return result( + NativeTimerModifyReadbackVerificationStatus::verified, + updated.binding); +} + +} diff --git a/core/timers/tests/test_native_timer_modify_operation.cpp b/core/timers/tests/test_native_timer_modify_operation.cpp new file mode 100644 index 000000000..9584836ca --- /dev/null +++ b/core/timers/tests/test_native_timer_modify_operation.cpp @@ -0,0 +1,261 @@ +#include "Database.h" +#include "MutationOperationRepository.h" +#include "NativeTimerBindingRepository.h" +#include "NativeTimerModifyOperationPreparationService.h" +#include "NativeTimerModifyReadbackVerificationService.h" +#include "TimerAssignmentRepository.h" +#include "TimerIntentRepository.h" + +#include +#include + +using namespace vdrsuite::operations; +using namespace vdrsuite::timers; + +namespace +{ +NativeTimerSpecification specification(bool enabled = true) +{ + NativeTimerSpecification value; + value.channelId = "S19.2E-1-1019-10301"; + value.title = "Managed Timer"; + value.directory = "VDR-Suite"; + value.day = "2026-08-18"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1015"; + value.priority = 50; + value.lifetime = 99; + value.enabled = enabled; + value.vps = false; + return value; +} + +NativeTimerObservedState observed(bool enabled = true) +{ + const auto spec = specification(enabled); + NativeTimerObservedState value; + value.channelId = spec.channelId; + value.title = spec.title; + value.directory = spec.directory; + value.day = spec.day; + value.weekdays = spec.weekdays; + value.startTime = spec.startTime; + value.endTime = spec.endTime; + value.flags = enabled ? 1 : 0; + value.priority = spec.priority; + value.lifetime = spec.lifetime; + value.enabled = enabled; + value.vps = spec.vps; + return value; +} + +TimerIntent activeIntent(TimerIntentRepository& repository) +{ + TimerIntent intent; + intent.timerIntentId = "intent:modify:1"; + intent.state = TimerIntentState::draft; + intent.createdByActorId = "actor:owner"; + intent.spec.intentType = TimerIntentType::manualWindow; + intent.spec.ownerActorId = "actor:owner"; + intent.spec.channelRequirement.canonicalChannelId = "channel:ard"; + intent.spec.schedule.startAt = 1000; + intent.spec.schedule.stopAt = 2000; + intent.spec.schedule.timezone = "Europe/Berlin"; + intent.createdAt = 100; + intent.updatedAt = 100; + intent.expiresAt = 3000; + const auto created = repository.create(intent); + assert(created.ok()); + TimerIntent active = created.intent; + active.state = TimerIntentState::active; + active.updatedAt = 101; + const auto updated = repository.update(active, created.intent.intentRevision); + assert(updated.ok()); + return updated.intent; +} + +TimerAssignment boundAssignment( + TimerAssignmentRepository& repository, + const TimerIntent& intent) +{ + TimerAssignment assignment; + assignment.timerAssignmentId = "assignment:modify:1"; + assignment.timerIntentId = intent.timerIntentId; + assignment.intentRevision = intent.intentRevision; + assignment.assignmentEpoch = 1; + assignment.backendId = "backend:1"; + assignment.backendGeneration = 7; + assignment.state = TimerAssignmentState::bound; + assignment.role = TimerAssignmentRole::primary; + assignment.channelBinding.canonicalChannelId = "channel:ard"; + assignment.channelBinding.backendChannelId = specification().channelId; + assignment.channelBinding.mappingSource = "map"; + assignment.channelBinding.mappingRevision = "map:1"; + assignment.capabilityRevision = "cap:1"; + assignment.backendHealthRevision = "health:1"; + assignment.decisionPolicyVersion = "policy:1"; + assignment.decisionEvidence.reasons = {"selected"}; + assignment.nativeTimerBindingId = "binding:modify:1"; + assignment.createdAt = 200; + assignment.updatedAt = 200; + const auto created = repository.create(assignment); + assert(created.ok()); + return created.assignment; +} + +NativeTimerBinding bindingFor( + NativeTimerBindingRepository& repository, + const TimerAssignment& assignment) +{ + NativeTimerBinding binding; + binding.nativeTimerBindingId = assignment.nativeTimerBindingId; + binding.backendId = assignment.backendId; + binding.backendGeneration = assignment.backendGeneration; + binding.backendNativeTimerId = "42"; + binding.timerAssignmentId = assignment.timerAssignmentId; + binding.ownership = NativeTimerBindingOwnership::managed; + binding.observedState = observed(true); + binding.observedFingerprint = + nativeTimerObservedStateFingerprint(binding.observedState); + binding.lastObservedAt = 250; + binding.lastVerifiedOperationId = "operation:create:1"; + const auto created = repository.create(binding); + assert(created.ok()); + return created.binding; +} + +NativeTimerModifyOperationPreparationRequest requestFor( + const TimerIntent& intent, + const TimerAssignment& assignment, + const NativeTimerBinding& binding, + NativeTimerModifyKind kind) +{ + NativeTimerModifyOperationPreparationRequest request; + request.operationId = kind == NativeTimerModifyKind::update + ? "operation:update:1" : "operation:toggle:1"; + request.idempotencyKey = kind == NativeTimerModifyKind::update + ? "idempotency:update:1" : "idempotency:toggle:1"; + request.actorId = "actor:owner"; + request.requestFingerprint = "request:modify:1"; + request.kind = kind; + request.timerAssignmentId = assignment.timerAssignmentId; + request.expectedAssignmentRevision = assignment.assignmentRevision; + request.expectedIntentRevision = intent.intentRevision; + request.expectedAssignmentEpoch = assignment.assignmentEpoch; + request.nativeTimerBindingId = binding.nativeTimerBindingId; + request.expectedBindingRevision = binding.bindingRevision; + request.expectedBackendId = binding.backendId; + request.expectedBackendGeneration = binding.backendGeneration; + request.backendNativeTimerId = binding.backendNativeTimerId; + request.expectedCurrentFingerprint = binding.observedFingerprint; + request.expectedSpecification = specification( + kind == NativeTimerModifyKind::toggle ? false : true); + if (kind == NativeTimerModifyKind::update) + request.expectedSpecification.title = "Updated Managed Timer"; + request.requestedAt = 300; + request.deadline = 900; + return request; +} + +NativeTimerObservation observationFor( + const NativeTimerBinding& binding, + const NativeTimerSpecification& expected) +{ + NativeTimerObservation observation; + observation.backendId = binding.backendId; + observation.backendGeneration = binding.backendGeneration; + observation.backendNativeTimerId = binding.backendNativeTimerId; + observation.observedAt = 350; + observation.observedState = observed(expected.enabled); + observation.observedState.title = expected.title; + observation.observedFingerprint = + nativeTimerObservedStateFingerprint(observation.observedState); + return observation; +} +} + +int main() +{ + NativeTimerModifyOperationPayload codec; + codec.kind = NativeTimerModifyKind::toggle; + codec.timerAssignmentId = "assignment:1"; + codec.expectedAssignmentRevision = "3"; + codec.expectedIntentRevision = "2"; + codec.assignmentEpoch = 1; + codec.nativeTimerBindingId = "binding:1"; + codec.expectedBindingRevision = "4"; + codec.backendId = "backend:1"; + codec.backendGeneration = 7; + codec.backendNativeTimerId = "42"; + codec.expectedCurrentFingerprint = "sha256:before"; + codec.expectedSpecification = specification(false); + const std::string serialized = + serializeNativeTimerModifyOperationPayload(codec); + assert(!serialized.empty()); + NativeTimerModifyOperationPayload parsed; + assert(parseNativeTimerModifyOperationPayload(serialized, parsed)); + assert(parsed.kind == NativeTimerModifyKind::toggle); + assert(parsed.expectedSpecification.startTime == "0930"); + assert(!parseNativeTimerModifyOperationPayload( + serialized.substr(0, serialized.size() - 1), parsed)); + + Database database; + assert(database.open(":memory:")); + TimerIntentRepository intents(database); + TimerAssignmentRepository assignments(database); + NativeTimerBindingRepository bindings(database); + MutationOperationRepository operations(database); + assert(intents.ensureSchema()); + assert(assignments.ensureSchema()); + assert(bindings.ensureSchema()); + assert(operations.ensureSchema()); + + const TimerIntent intent = activeIntent(intents); + const TimerAssignment assignment = boundAssignment(assignments, intent); + const NativeTimerBinding binding = bindingFor(bindings, assignment); + + NativeTimerModifyOperationPreparationService preparation( + intents, assignments, bindings, operations); + const auto updateRequest = requestFor( + intent, assignment, binding, NativeTimerModifyKind::update); + const auto prepared = preparation.prepare(updateRequest); + assert(prepared.status == + NativeTimerModifyOperationPreparationStatus::prepared); + assert(prepared.operation.actionFamily == "timer.update"); + assert(prepared.operation.expectedRevision == binding.bindingRevision); + assert(prepared.operation.expectedResourceFingerprint == + binding.observedFingerprint); + assert(preparation.prepare(updateRequest).status == + NativeTimerModifyOperationPreparationStatus::alreadyPrepared); + + auto stale = updateRequest; + stale.expectedCurrentFingerprint = "sha256:stale"; + assert(preparation.prepare(stale).status == + NativeTimerModifyOperationPreparationStatus::currentFingerprintConflict); + + auto invalidToggle = requestFor( + intent, assignment, binding, NativeTimerModifyKind::toggle); + invalidToggle.expectedSpecification.title = "Changed"; + assert(preparation.prepare(invalidToggle).status == + NativeTimerModifyOperationPreparationStatus::toggleShapeConflict); + + NativeTimerModifyReadbackExpectation expectation; + expectation.operationId = updateRequest.operationId; + expectation.payload = prepared.payload; + expectation.readbackNotBefore = 300; + const auto observation = observationFor( + binding, updateRequest.expectedSpecification); + NativeTimerModifyReadbackVerificationService verification(bindings); + const auto verified = verification.verify(expectation, observation); + assert(verified.status == + NativeTimerModifyReadbackVerificationStatus::verified); + assert(verified.binding.observedState.title == "Updated Managed Timer"); + assert(verified.binding.lastVerifiedOperationId == + updateRequest.operationId); + assert(verification.verify(expectation, observation).status == + NativeTimerModifyReadbackVerificationStatus::alreadyVerified); + + std::cout << "test_native_timer_modify_operation passed\n"; + return 0; +} diff --git a/docs/development/phase-64-native-timer-modify-operation.md b/docs/development/phase-64-native-timer-modify-operation.md new file mode 100644 index 000000000..21b22297b --- /dev/null +++ b/docs/development/phase-64-native-timer-modify-operation.md @@ -0,0 +1,18 @@ +# Phase 64 native Timer UPDATE/TOGGLE operation + +Managed UPDATE and TOGGLE share one durable modify payload while retaining +distinct action families. Preparation fences the current assignment, intent, +binding revision, backend generation, native identity and exact pre-mutation +fingerprint. UPDATE carries the complete desired backend-neutral specification. +TOGGLE is narrower: every specification field except `enabled` must remain +unchanged. + +A recording, pending, missing or drifted Timer is rejected before dispatch. +The operation is stored with readback-required verification. No blind retry is +authorized after dispatch; accepted-unverified and outcome-unknown execution +must both proceed to reconciliation. + +Completion requires an authoritative PRESENT observation at or after the +dispatch boundary. The observation must match the stable native identity and +the complete desired specification. Only then is the copied binding state and +last verified operation advanced under optimistic concurrency. diff --git a/mk/phase64-native-timer-modify-operation-tests.mk b/mk/phase64-native-timer-modify-operation-tests.mk new file mode 100644 index 000000000..aa97839de --- /dev/null +++ b/mk/phase64-native-timer-modify-operation-tests.mk @@ -0,0 +1,30 @@ +.PHONY: test-phase64-native-timer-modify-operation-architecture test-phase64-native-timer-modify-operation + +test-phase64-native-timer-modify-operation-architecture: + python3 tools/check_phase64_native_timer_modify_operation.py + +test-phase64-native-timer-modify-operation: test-phase64-native-timer-modify-operation-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/operations/include -Icore/timers/include \ + $(SQLITE_SRC) \ + core/operations/src/MutationOperation.cpp \ + core/operations/src/MutationOperationRepository.cpp \ + core/timers/src/TimerIntent.cpp \ + core/timers/src/TimerIntentRepository.cpp \ + core/timers/src/TimerAssignment.cpp \ + core/timers/src/TimerAssignmentRepository.cpp \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerBindingRepository.cpp \ + core/timers/src/NativeTimerBindingReadRepository.cpp \ + core/timers/src/NativeTimerBindingWriteRepository.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/src/NativeTimerObservation.cpp \ + core/timers/src/NativeTimerModifyOperationPayload.cpp \ + core/timers/src/NativeTimerModifyOperationPreparationService.cpp \ + core/timers/src/NativeTimerModifyReadbackVerificationService.cpp \ + core/timers/tests/test_native_timer_modify_operation.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_modify_operation + $(BUILD_DIR)/test_native_timer_modify_operation + +test-fast: test-phase64-native-timer-modify-operation +test-architecture: test-phase64-native-timer-modify-operation-architecture diff --git a/tools/check_phase64_native_timer_modify_operation.py b/tools/check_phase64_native_timer_modify_operation.py new file mode 100755 index 000000000..e124efa4b --- /dev/null +++ b/tools/check_phase64_native_timer_modify_operation.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def read(path: str) -> str: + target = ROOT / path + if not target.is_file(): + raise SystemExit(f"missing native Timer modify file: {path}") + return target.read_text(encoding="utf-8") + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f"missing {label}: {needle}") + +makefile = read("Makefile") +payload = read("core/timers/src/NativeTimerModifyOperationPayload.cpp") +preparation = read( + "core/timers/src/NativeTimerModifyOperationPreparationService.cpp" +) +verification = read( + "core/timers/src/NativeTimerModifyReadbackVerificationService.cpp" +) +test = read("core/timers/tests/test_native_timer_modify_operation.cpp") +doc = read("docs/development/phase-64-native-timer-modify-operation.md") + +include = "include mk/phase64-native-timer-modify-operation-tests.mk" +require(makefile, include, "modify operation Make include") +if makefile.count(include) != 1: + raise SystemExit("modify operation Make include must occur exactly once") + +for needle in ( + "native-timer-modify-operation-payload/1|", + "expectedCurrentFingerprint", + "expectedBindingRevision", + "NativeTimerModifyKind::update", + "NativeTimerModifyKind::toggle", +): + require(payload, needle, "durable UPDATE/TOGGLE payload") + +for needle in ( + "TimerAssignmentState::bound", + "NativeTimerBindingOwnership::managed", + "NativeTimerBindingOwnership::adopted", + "binding.observedFingerprint != request.expectedCurrentFingerprint", + "binding.observedState.recording", + "binding.observedState.pending", + "toggleShapeValid(", + 'operation.actionFamily = request.kind == NativeTimerModifyKind::update', + "MutationOperationVerificationPolicy::readbackRequired", + "reserveWithPayload(", +): + require(preparation, needle, "fenced UPDATE/TOGGLE preparation") + +for needle in ( + "observation.observedAt < expectation.readbackNotBefore", + "nativeTimerObservationMatchesSpecification(", + "current.bindingRevision != payload.expectedBindingRevision", + "current.observedFingerprint != payload.expectedCurrentFingerprint", + "next.lastVerifiedOperationId = expectation.operationId", + "repository_.update(next, current.bindingRevision)", +): + require(verification, needle, "authoritative PRESENT verification") + +for needle in ( + "NativeTimerModifyKind::update", + "NativeTimerModifyKind::toggle", + "currentFingerprintConflict", + "toggleShapeConflict", + "alreadyVerified", +): + require(test, needle, "UPDATE/TOGGLE regression") + +require(doc, "No blind retry", "no-blind-retry documentation") +require(doc, "authoritative PRESENT", "readback documentation") +print("Phase 64 native Timer UPDATE/TOGGLE operation guard passed") From 0c6d0c3c50c72a20fa4185736d65a01784ef25e4 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:13:38 +0200 Subject: [PATCH 48/85] Add restart-safe TimerIntent provisioning entry point --- Makefile | 1 + .../include/TimerIntentApplicationService.h | 74 +++++++ .../src/TimerIntentApplicationService.cpp | 201 ++++++++++++++++++ .../test_timer_intent_application_service.cpp | 120 +++++++++++ .../phase-64-timer-intent-application.md | 13 ++ mk/phase64-timer-intent-application-tests.mk | 28 +++ .../check_phase64_timer_intent_application.py | 44 ++++ 7 files changed, 481 insertions(+) create mode 100644 core/timers/include/TimerIntentApplicationService.h create mode 100644 core/timers/src/TimerIntentApplicationService.cpp create mode 100644 core/timers/tests/test_timer_intent_application_service.cpp create mode 100644 docs/development/phase-64-timer-intent-application.md create mode 100644 mk/phase64-timer-intent-application-tests.mk create mode 100755 tools/check_phase64_timer_intent_application.py diff --git a/Makefile b/Makefile index 471cb46a2..cee02f1bf 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,7 @@ include mk/phase64-suitebridge-native-timer-create-real-mutation-tests.mk include mk/phase64-suitebridge-native-timer-command-path-wiring-tests.mk include mk/phase63-local-provider-tests.mk include mk/phase64-timer-intent-tests.mk +include mk/phase64-timer-intent-application-tests.mk include mk/phase64-timer-assignment-fulfillment-tests.mk include mk/phase64-native-timer-specification-tests.mk include mk/phase64-vdr-timer-managed-correlation-tests.mk diff --git a/core/timers/include/TimerIntentApplicationService.h b/core/timers/include/TimerIntentApplicationService.h new file mode 100644 index 000000000..7ca2dc580 --- /dev/null +++ b/core/timers/include/TimerIntentApplicationService.h @@ -0,0 +1,74 @@ +#pragma once + +#include "TimerAssignmentFulfillmentService.h" +#include "TimerAssignmentSchedulingService.h" +#include "TimerIntent.h" + +#include +#include + +namespace vdrsuite::timers +{ + +class NativeTimerBindingRepository; +class TimerAssignmentRepository; +class TimerIntentRepository; + +enum class TimerIntentApplicationStatus +{ + provisioningStarted, + alreadyProvisioning, + alreadyBound, + noEligibleBackend, + intentConflict, + assignmentConflict, + schedulingConflict, + provisioningConflict, + repositoryError, + invalid, +}; + +struct TimerIntentApplicationRequest +{ + TimerIntent intent; + std::string timerAssignmentId; + std::vector candidates; + std::int64_t activatedAt = 0; + std::int64_t scheduledAt = 0; + std::int64_t provisioningAt = 0; +}; + +struct TimerIntentApplicationResult +{ + TimerIntentApplicationStatus status = + TimerIntentApplicationStatus::repositoryError; + TimerIntent intent; + TimerAssignment assignment; + + bool ok() const + { + return status == TimerIntentApplicationStatus::provisioningStarted + || status == TimerIntentApplicationStatus::alreadyProvisioning + || status == TimerIntentApplicationStatus::alreadyBound; + } +}; + +class TimerIntentApplicationService +{ +public: + TimerIntentApplicationService( + TimerIntentRepository& intentRepository, + TimerAssignmentRepository& assignmentRepository, + NativeTimerBindingRepository& bindingRepository); + + TimerIntentApplicationResult submitAndProvisionPrimary( + const TimerIntentApplicationRequest& request); + +private: + TimerIntentRepository& intentRepository_; + TimerAssignmentRepository& assignmentRepository_; + TimerAssignmentSchedulingService scheduling_; + TimerAssignmentFulfillmentService fulfillment_; +}; + +} diff --git a/core/timers/src/TimerIntentApplicationService.cpp b/core/timers/src/TimerIntentApplicationService.cpp new file mode 100644 index 000000000..4635d56c2 --- /dev/null +++ b/core/timers/src/TimerIntentApplicationService.cpp @@ -0,0 +1,201 @@ +#include "TimerIntentApplicationService.h" + +#include "NativeTimerBindingRepository.h" +#include "TimerAssignmentRepository.h" +#include "TimerIntentRepository.h" + +namespace vdrsuite::timers +{ +namespace +{ +TimerIntentApplicationResult result( + TimerIntentApplicationStatus status, + const TimerIntent& intent = {}, + const TimerAssignment& assignment = {}) +{ + TimerIntentApplicationResult value; + value.status = status; + value.intent = intent; + value.assignment = assignment; + return value; +} + +bool requestValid(const TimerIntentApplicationRequest& request) +{ + return timerIntentValid(request.intent) + && request.intent.state == TimerIntentState::draft + && request.intent.intentRevision.empty() + && !request.timerAssignmentId.empty() + && request.timerAssignmentId.size() <= 160 + && request.activatedAt > request.intent.updatedAt + && request.scheduledAt > request.activatedAt + && request.provisioningAt > request.scheduledAt; +} + +bool sameIntentRequest( + const TimerIntent& current, + const TimerIntent& requested) +{ + return current.timerIntentId == requested.timerIntentId + && current.createdByActorId == requested.createdByActorId + && current.createdAt == requested.createdAt + && current.expiresAt == requested.expiresAt + && timerIntentSemanticIdentity(current.spec) + == timerIntentSemanticIdentity(requested.spec); +} +} + +TimerIntentApplicationService::TimerIntentApplicationService( + TimerIntentRepository& intentRepository, + TimerAssignmentRepository& assignmentRepository, + NativeTimerBindingRepository& bindingRepository) + : intentRepository_(intentRepository), + assignmentRepository_(assignmentRepository), + scheduling_(intentRepository, assignmentRepository), + fulfillment_(assignmentRepository, bindingRepository) +{ +} + +TimerIntentApplicationResult +TimerIntentApplicationService::submitAndProvisionPrimary( + const TimerIntentApplicationRequest& request) +{ + if (!requestValid(request)) + return result(TimerIntentApplicationStatus::invalid); + + TimerIntent intent; + const auto created = intentRepository_.create(request.intent); + if (created.ok()) + { + intent = created.intent; + } + else if (created.status == TimerIntentRepositoryStatus::alreadyExists) + { + const auto found = + intentRepository_.findById(request.intent.timerIntentId); + if (!found.ok()) + return result(TimerIntentApplicationStatus::repositoryError); + if (!sameIntentRequest(found.intent, request.intent)) + return result( + TimerIntentApplicationStatus::intentConflict, + found.intent); + intent = found.intent; + } + else + { + return result( + created.status == TimerIntentRepositoryStatus::invalid + ? TimerIntentApplicationStatus::invalid + : TimerIntentApplicationStatus::repositoryError); + } + + if (intent.state == TimerIntentState::draft) + { + TimerIntent active = intent; + active.state = TimerIntentState::active; + active.updatedAt = request.activatedAt; + const auto activated = + intentRepository_.update(active, intent.intentRevision); + if (!activated.ok()) + return result( + activated.status == TimerIntentRepositoryStatus::conflict + ? TimerIntentApplicationStatus::intentConflict + : TimerIntentApplicationStatus::repositoryError, + activated.intent); + intent = activated.intent; + } + else if (intent.state != TimerIntentState::active) + { + return result(TimerIntentApplicationStatus::intentConflict, intent); + } + + TimerAssignment assignment; + const auto existingAssignment = + assignmentRepository_.findById(request.timerAssignmentId); + if (existingAssignment.ok()) + { + assignment = existingAssignment.assignment; + } + else if (existingAssignment.status == TimerAssignmentRepositoryStatus::notFound) + { + TimerAssignmentPrimarySchedulingRequest schedulingRequest; + schedulingRequest.timerAssignmentId = request.timerAssignmentId; + schedulingRequest.timerIntentId = intent.timerIntentId; + schedulingRequest.expectedIntentRevision = intent.intentRevision; + schedulingRequest.createdAt = request.scheduledAt; + schedulingRequest.candidates = request.candidates; + const auto scheduled = scheduling_.schedulePrimary(schedulingRequest); + + if (scheduled.status == + TimerAssignmentSchedulingStatus::activePrimaryExists + && scheduled.assignment.timerAssignmentId != + request.timerAssignmentId) + return result( + TimerIntentApplicationStatus::assignmentConflict, + intent, + scheduled.assignment); + if (!scheduled.ok()) + return result( + scheduled.status == TimerAssignmentSchedulingStatus::storageError + ? TimerIntentApplicationStatus::repositoryError + : TimerIntentApplicationStatus::schedulingConflict, + intent, + scheduled.assignment); + assignment = scheduled.assignment; + } + else + { + return result(TimerIntentApplicationStatus::repositoryError, intent); + } + + if (assignment.timerAssignmentId != request.timerAssignmentId + || assignment.timerIntentId != intent.timerIntentId + || assignment.intentRevision != intent.intentRevision + || assignment.role != TimerAssignmentRole::primary) + return result( + TimerIntentApplicationStatus::assignmentConflict, + intent, + assignment); + if (assignment.state == TimerAssignmentState::unassigned) + return result( + TimerIntentApplicationStatus::noEligibleBackend, + intent, + assignment); + if (assignment.state == TimerAssignmentState::bound) + return result( + TimerIntentApplicationStatus::alreadyBound, + intent, + assignment); + + const auto provisioned = fulfillment_.beginProvisioning( + assignment.timerAssignmentId, + assignment.assignmentRevision, + intent.intentRevision, + assignment.backendGeneration, + request.provisioningAt); + switch (provisioned.status) + { + case TimerAssignmentFulfillmentStatus::provisioningStarted: + return result( + TimerIntentApplicationStatus::provisioningStarted, + intent, + provisioned.assignment); + case TimerAssignmentFulfillmentStatus::alreadyProvisioning: + return result( + TimerIntentApplicationStatus::alreadyProvisioning, + intent, + provisioned.assignment); + case TimerAssignmentFulfillmentStatus::repositoryError: + return result( + TimerIntentApplicationStatus::repositoryError, + intent, + provisioned.assignment); + default: + return result( + TimerIntentApplicationStatus::provisioningConflict, + intent, + provisioned.assignment); + } +} + +} diff --git a/core/timers/tests/test_timer_intent_application_service.cpp b/core/timers/tests/test_timer_intent_application_service.cpp new file mode 100644 index 000000000..62c8b0798 --- /dev/null +++ b/core/timers/tests/test_timer_intent_application_service.cpp @@ -0,0 +1,120 @@ +#include "Database.h" +#include "NativeTimerBindingRepository.h" +#include "TimerAssignmentRepository.h" +#include "TimerIntentApplicationService.h" +#include "TimerIntentRepository.h" + +#include +#include + +using namespace vdrsuite::timers; + +namespace +{ +TimerIntent intent() +{ + TimerIntent value; + value.timerIntentId = "intent:application:1"; + value.state = TimerIntentState::draft; + value.createdByActorId = "actor:create"; + value.spec.intentType = TimerIntentType::manualWindow; + value.spec.ownerActorId = "actor:owner"; + value.spec.channelRequirement.canonicalChannelId = "channel:ard"; + value.spec.schedule.startAt = 1000; + value.spec.schedule.stopAt = 2000; + value.spec.schedule.timezone = "Europe/Berlin"; + value.spec.assignmentPolicy.preferredBackendIds = {"backend:alpha"}; + value.createdAt = 100; + value.updatedAt = 100; + value.expiresAt = 3000; + return value; +} + +TimerAssignmentPlanningBackendCandidate candidate() +{ + TimerAssignmentPlanningBackendCandidate value; + value.backendId = "backend:alpha"; + value.siteId = "site:home"; + value.currentBackendGeneration = 7; + value.state = TimerAssignmentPlanningBackendState::online; + value.writeAllowed = true; + value.executionAuthorityCurrent = true; + value.executionAuthorityFence = "authority:7"; + value.capability.backendGeneration = 7; + value.capability.revision = "capability:7"; + value.capability.current = true; + value.capability.timerCreate = true; + value.capability.timerReadback = true; + value.health.backendGeneration = 7; + value.health.revision = "health:7"; + value.health.current = true; + value.health.state = TimerAssignmentPlanningHealthState::healthy; + value.health.timerWritesAvailable = true; + value.channel.backendGeneration = 7; + value.channel.mappingRevision = "mapping:7"; + value.channel.mappingSource = "canonical-channel-map"; + value.channel.canonicalChannelId = "channel:ard"; + value.channel.backendChannelId = "S19.2E-1-1019-10301"; + value.channel.current = true; + value.conflict = TimerAssignmentPlanningConflictState::confirmedClear; + return value; +} + +TimerIntentApplicationRequest request() +{ + TimerIntentApplicationRequest value; + value.intent = intent(); + value.timerAssignmentId = "assignment:application:1"; + value.candidates = {candidate()}; + value.activatedAt = 101; + value.scheduledAt = 200; + value.provisioningAt = 201; + return value; +} +} + +int main() +{ + Database database; + assert(database.open(":memory:")); + TimerIntentRepository intents(database); + TimerAssignmentRepository assignments(database); + NativeTimerBindingRepository bindings(database); + assert(intents.ensureSchema()); + assert(assignments.ensureSchema()); + assert(bindings.ensureSchema()); + + TimerIntentApplicationService service(intents, assignments, bindings); + const auto first = service.submitAndProvisionPrimary(request()); + assert(first.status == TimerIntentApplicationStatus::provisioningStarted); + assert(first.intent.state == TimerIntentState::active); + assert(first.intent.intentRevision == "2"); + assert(first.assignment.state == TimerAssignmentState::provisioning); + assert(first.assignment.role == TimerAssignmentRole::primary); + assert(first.assignment.backendId == "backend:alpha"); + assert(first.assignment.backendGeneration == 7); + + // Stable identifiers allow restart-safe replay across every durable step. + TimerIntentApplicationService restarted(intents, assignments, bindings); + const auto replay = restarted.submitAndProvisionPrimary(request()); + assert(replay.status == TimerIntentApplicationStatus::alreadyProvisioning); + assert(replay.assignment.timerAssignmentId == + first.assignment.timerAssignmentId); + + auto changed = request(); + changed.intent.spec.schedule.stopAt = 2100; + assert(restarted.submitAndProvisionPrimary(changed).status == + TimerIntentApplicationStatus::intentConflict); + + auto unavailable = request(); + unavailable.intent.timerIntentId = "intent:application:2"; + unavailable.timerAssignmentId = "assignment:application:2"; + unavailable.candidates.front().state = + TimerAssignmentPlanningBackendState::offline; + const auto noBackend = restarted.submitAndProvisionPrimary(unavailable); + assert(noBackend.status == TimerIntentApplicationStatus::noEligibleBackend); + assert(noBackend.assignment.state == TimerAssignmentState::unassigned); + + std::cout << "test_timer_intent_application_service passed\n"; + return 0; +} diff --git a/docs/development/phase-64-timer-intent-application.md b/docs/development/phase-64-timer-intent-application.md new file mode 100644 index 000000000..6077d0149 --- /dev/null +++ b/docs/development/phase-64-timer-intent-application.md @@ -0,0 +1,13 @@ +# Phase 64 TimerIntent application entry point + +The minimal application entry point accepts one complete TimerIntent plus stable +intent and assignment identifiers. It durably creates and activates the intent, +runs deterministic primary scheduling against explicit capability, health, +authority, channel and conflict facts, then moves the selected assignment into +provisioning. + +Every step is restart-safe. Repeating the exact request resumes from the +persisted intent or assignment and returns the already-provisioning result. +Changed semantic intent content, a competing assignment identity, stale +revisions or an ineligible backend fail closed. No reassignment or failover is +introduced by this entry point. diff --git a/mk/phase64-timer-intent-application-tests.mk b/mk/phase64-timer-intent-application-tests.mk new file mode 100644 index 000000000..0f18eaf72 --- /dev/null +++ b/mk/phase64-timer-intent-application-tests.mk @@ -0,0 +1,28 @@ +.PHONY: test-phase64-timer-intent-application-architecture test-phase64-timer-intent-application + +test-phase64-timer-intent-application-architecture: + python3 tools/check_phase64_timer_intent_application.py + +test-phase64-timer-intent-application: test-phase64-timer-intent-application-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/timers/include \ + $(SQLITE_SRC) \ + core/timers/src/TimerIntent.cpp \ + core/timers/src/TimerIntentRepository.cpp \ + core/timers/src/TimerAssignment.cpp \ + core/timers/src/TimerAssignmentRepository.cpp \ + core/timers/src/TimerAssignmentSetRevisionRepository.cpp \ + core/timers/src/TimerAssignmentPlanner.cpp \ + core/timers/src/TimerAssignmentSchedulingService.cpp \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerBindingRepository.cpp \ + core/timers/src/NativeTimerBindingReadRepository.cpp \ + core/timers/src/NativeTimerBindingWriteRepository.cpp \ + core/timers/src/TimerAssignmentFulfillmentService.cpp \ + core/timers/src/TimerIntentApplicationService.cpp \ + core/timers/tests/test_timer_intent_application_service.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_timer_intent_application_service + $(BUILD_DIR)/test_timer_intent_application_service + +test-fast: test-phase64-timer-intent-application +test-architecture: test-phase64-timer-intent-application-architecture diff --git a/tools/check_phase64_timer_intent_application.py b/tools/check_phase64_timer_intent_application.py new file mode 100755 index 000000000..e6184a1d1 --- /dev/null +++ b/tools/check_phase64_timer_intent_application.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def read(path: str) -> str: + target = ROOT / path + if not target.is_file(): + raise SystemExit(f"missing TimerIntent application file: {path}") + return target.read_text(encoding="utf-8") + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f"missing {label}: {needle}") + +makefile = read("Makefile") +source = read("core/timers/src/TimerIntentApplicationService.cpp") +test = read("core/timers/tests/test_timer_intent_application_service.cpp") +doc = read("docs/development/phase-64-timer-intent-application.md") +include = "include mk/phase64-timer-intent-application-tests.mk" +require(makefile, include, "TimerIntent application Make include") +if makefile.count(include) != 1: + raise SystemExit("TimerIntent application Make include must occur once") +for needle in ( + "intentRepository_.create(request.intent)", + "timerIntentSemanticIdentity(current.spec)", + "TimerIntentState::active", + "assignmentRepository_.findById(request.timerAssignmentId)", + "scheduling_.schedulePrimary(schedulingRequest)", + "assignment.timerAssignmentId != request.timerAssignmentId", + "fulfillment_.beginProvisioning(", + "TimerAssignmentFulfillmentStatus::alreadyProvisioning", +): + require(source, needle, "restart-safe application entry point") +for needle in ( + "provisioningStarted", + "alreadyProvisioning", + "intentConflict", + "noEligibleBackend", +): + require(test, needle, "entry-point regression") +require(doc, "deterministic primary", "scheduling documentation") +require(doc, "restart-safe", "recovery documentation") +print("Phase 64 TimerIntent application guard passed") From 67a31e4718b002764fae8472bd461c284910f019 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:21:32 +0200 Subject: [PATCH 49/85] Add private native Timer UPDATE and TOGGLE mutation service --- Makefile | 1 + ...dge-native-timer-modify-command-service.md | 11 + ...tive-timer-modify-command-service-tests.mk | 5 + ...dge_native_timer_modify_command_service.py | 33 ++ vdr-plugin-suite-bridge/Makefile | 17 +- vdr-plugin-suite-bridge/suitebridge.cpp | 9 +- vdr-plugin-suite-bridge/suitebridge.h | 4 + .../suitebridge_native_timer_modify.cpp | 408 ++++++++++++++++++ .../suitebridge_native_timer_modify.h | 113 +++++ .../suitebridge_native_timer_modify_vdr.cpp | 318 ++++++++++++++ .../suitebridge_native_timer_modify_vdr.h | 13 + vdr-plugin-suite-bridge/suitebridge_svdrp.cpp | 6 + .../test_suitebridge_native_timer_modify.cpp | 54 +++ 13 files changed, 987 insertions(+), 5 deletions(-) create mode 100644 docs/phase64-suitebridge-native-timer-modify-command-service.md create mode 100644 mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk create mode 100755 tools/check_phase64_suitebridge_native_timer_modify_command_service.py create mode 100644 vdr-plugin-suite-bridge/suitebridge_native_timer_modify.cpp create mode 100644 vdr-plugin-suite-bridge/suitebridge_native_timer_modify.h create mode 100644 vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.cpp create mode 100644 vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.h create mode 100644 vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_modify.cpp diff --git a/Makefile b/Makefile index cee02f1bf..9b324a4e7 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,7 @@ include mk/phase64-native-timer-absence-readback-verification-tests.mk include mk/phase64-mutation-operation-repository-tests.mk include mk/phase64-native-timer-create-operation-preparation-tests.mk include mk/phase64-native-timer-modify-operation-tests.mk +include mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk include mk/phase64-native-timer-delete-operation-completion-tests.mk include mk/phase64-native-timer-delete-operation-preparation-tests.mk include mk/phase64-native-timer-delete-dispatch-tests.mk diff --git a/docs/phase64-suitebridge-native-timer-modify-command-service.md b/docs/phase64-suitebridge-native-timer-modify-command-service.md new file mode 100644 index 000000000..0690da3aa --- /dev/null +++ b/docs/phase64-suitebridge-native-timer-modify-command-service.md @@ -0,0 +1,11 @@ +# Phase 64: private SuiteBridge native Timer modify service + +The private `NTMOD` family implements replay-safe UPDATE and TOGGLE mutation +boundaries. It validates the complete command/provider fence, requires a durable +Agent `starting` timestamp, compares the live native Timer fingerprint under +the VDR Timer write lock, and refuses recording or pending Timers. + +A successful callback remains `accepted_unverified`; authoritative inventory +readback is still required before the control plane may persist the new binding +revision. The family is intentionally absent from public SVDRP help and Agent +command advertisement until the bundled yaVDR acceptance passes. diff --git a/mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk b/mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk new file mode 100644 index 000000000..b7b6f4e1a --- /dev/null +++ b/mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk @@ -0,0 +1,5 @@ +.PHONY: test-phase64-suitebridge-native-timer-modify-command-service + +test-phase64-suitebridge-native-timer-modify-command-service: + python3 tools/check_phase64_suitebridge_native_timer_modify_command_service.py + $(MAKE) -C vdr-plugin-suite-bridge test-native-timer-modify diff --git a/tools/check_phase64_suitebridge_native_timer_modify_command_service.py b/tools/check_phase64_suitebridge_native_timer_modify_command_service.py new file mode 100755 index 000000000..f2f5dc5e6 --- /dev/null +++ b/tools/check_phase64_suitebridge_native_timer_modify_command_service.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +def read(path): return (ROOT/path).read_text(encoding="utf-8") +for path in ( + "vdr-plugin-suite-bridge/suitebridge_native_timer_modify.h", + "vdr-plugin-suite-bridge/suitebridge_native_timer_modify.cpp", + "vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.h", + "vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.cpp", + "vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_modify.cpp", +): + if not (ROOT/path).is_file(): raise SystemExit("missing modify service: "+path) +source=read("vdr-plugin-suite-bridge/suitebridge_native_timer_modify.cpp") +for token in ("NTMOD","vdr.timer.update","vdr.timer.toggle","localStartingPersistedAt", + "replayByOperationId_","callbackResult"): + if token not in source: raise SystemExit("missing modify fence: "+token) +vdr=read("vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.cpp") +for token in ("GetTimersWrite","expectedNativeTimerFingerprint","Recording()", + "Pending()","SetExplicitModify","SetModified"): + if token not in vdr: raise SystemExit("missing VDR mutation guard: "+token) +plugin=read("vdr-plugin-suite-bridge/suitebridge_svdrp.cpp") +help_start=plugin.find("SVDRPHelpPages") +command_start=plugin.find("SVDRPCommand",help_start) +if help_start<0 or command_start<0: raise SystemExit("help boundary missing") +if "NTMOD" in plugin[help_start:command_start]: + raise SystemExit("public NTMOD help advertisement landed before acceptance") +if "nativeTimerModify_.Handle(Command, Option)" not in plugin: + raise SystemExit("private modify dispatch missing") +agent=read("core/agent/src/BackendAgentCommandClient.cpp") +for token in ("kBackendAgentNativeTimerCreateCommandType", + "kBackendAgentNativeTimerDeleteCommandType"): + if token not in agent: raise SystemExit("closed command advertisement guard missing") +print("phase64 SuiteBridge native timer modify command service guard passed") diff --git a/vdr-plugin-suite-bridge/Makefile b/vdr-plugin-suite-bridge/Makefile index 4f508b0c3..0a71246d5 100644 --- a/vdr-plugin-suite-bridge/Makefile +++ b/vdr-plugin-suite-bridge/Makefile @@ -20,7 +20,7 @@ SOFILE = libvdr-$(PLUGIN).so DEFINES += -DPLUGIN_NAME_I18N='"$(PLUGIN)"' -OBJS = $(PLUGIN).o suitebridge_svdrp.o suitebridge_native_probe.o suitebridge_native_timer_create.o suitebridge_native_timer_create_vdr.o suitebridge_native_timer_delete.o suitebridge_native_timer_delete_vdr.o suitebridge_epg_command_handler.o suitebridge_epg_type_snapshot_command.o suitebridge_recording_metadata_command.o suitebridge_lifecycle.o suitebridge_capabilities.o suitebridge_capability_discovery.o suitebridge_counter_continuity.o suitebridge_status_snapshot.o suitebridge_local_contract.o suitebridge_svdrp_contract.o suitebridge_status_events.o suitebridge_status_monitor.o suitebridge_artwork_reference.o suitebridge_image_dimensions.o suitebridge_tvscraper_adapter.o suitebridge_tvscraper_type_adapter.o suitebridge_epg_artwork_contract.o suitebridge_epg_metadata_contract.o suitebridge_epg_type_snapshot_contract.o suitebridge_recording_identity.o suitebridge_recording_metadata.o suitebridge_recording_metadata_contract.o suitebridge_tvscraper_recording_adapter.o +OBJS = $(PLUGIN).o suitebridge_svdrp.o suitebridge_native_probe.o suitebridge_native_timer_create.o suitebridge_native_timer_create_vdr.o suitebridge_native_timer_delete.o suitebridge_native_timer_delete_vdr.o suitebridge_native_timer_modify.o suitebridge_native_timer_modify_vdr.o suitebridge_epg_command_handler.o suitebridge_epg_type_snapshot_command.o suitebridge_recording_metadata_command.o suitebridge_lifecycle.o suitebridge_capabilities.o suitebridge_capability_discovery.o suitebridge_counter_continuity.o suitebridge_status_snapshot.o suitebridge_local_contract.o suitebridge_svdrp_contract.o suitebridge_status_events.o suitebridge_status_monitor.o suitebridge_artwork_reference.o suitebridge_image_dimensions.o suitebridge_tvscraper_adapter.o suitebridge_tvscraper_type_adapter.o suitebridge_epg_artwork_contract.o suitebridge_epg_metadata_contract.o suitebridge_epg_type_snapshot_contract.o suitebridge_recording_identity.o suitebridge_recording_metadata.o suitebridge_recording_metadata_contract.o suitebridge_tvscraper_recording_adapter.o DEPFILE = .dependencies LIFECYCLE_TEST = /tmp/test_suitebridge_lifecycle CAPABILITIES_TEST = /tmp/test_suitebridge_capabilities @@ -33,6 +33,7 @@ SVDRP_CONTRACT_TEST = /tmp/test_suitebridge_svdrp_contract NATIVE_PROBE_TEST = /tmp/test_suitebridge_native_probe NATIVE_TIMER_CREATE_TEST = /tmp/test_suitebridge_native_timer_create NATIVE_TIMER_DELETE_TEST = /tmp/test_suitebridge_native_timer_delete +NATIVE_TIMER_MODIFY_TEST = /tmp/test_suitebridge_native_timer_modify ARTWORK_REFERENCE_TEST = /tmp/test_suitebridge_artwork_reference IMAGE_DIMENSIONS_TEST = /tmp/test_suitebridge_image_dimensions EPG_ARTWORK_CONTRACT_TEST = /tmp/test_suitebridge_epg_artwork_contract @@ -41,7 +42,7 @@ EPG_TYPE_SNAPSHOT_CONTRACT_TEST = /tmp/test_suitebridge_epg_type_snapshot_contra RECORDING_IDENTITY_TEST = /tmp/test_suitebridge_recording_identity RECORDING_METADATA_CONTRACT_TEST = /tmp/test_suitebridge_recording_metadata_contract -.PHONY: all install install-lib check check-contract check-capabilities-contract check-capability-discovery-contract check-counter-continuity-contract check-status-events-contract check-status-snapshot-contract check-local-contract-contract check-svdrp-contract check-recording-metadata-contract check-version test-lifecycle test-capabilities test-capability-discovery test-counter-continuity test-status-events test-status-snapshot test-local-contract test-svdrp-contract test-native-probe test-native-timer-create test-native-timer-delete test-artwork-reference test-image-dimensions test-epg-artwork-contract test-epg-metadata-contract test-epg-type-snapshot-contract test-recording-identity test-recording-metadata-contract check-build clean +.PHONY: all install install-lib check check-contract check-capabilities-contract check-capability-discovery-contract check-counter-continuity-contract check-status-events-contract check-status-snapshot-contract check-local-contract-contract check-svdrp-contract check-recording-metadata-contract check-version test-lifecycle test-capabilities test-capability-discovery test-counter-continuity test-status-events test-status-snapshot test-local-contract test-svdrp-contract test-native-probe test-native-timer-create test-native-timer-delete test-native-timer-modify test-artwork-reference test-image-dimensions test-epg-artwork-contract test-epg-metadata-contract test-epg-type-snapshot-contract test-recording-identity test-recording-metadata-contract check-build clean all: $(SOFILE) @@ -187,6 +188,14 @@ test-native-timer-delete: $(NATIVE_TIMER_DELETE_TEST) rm -f $(NATIVE_TIMER_DELETE_TEST) +test-native-timer-modify: + $(CXX) -std=c++17 -Wall -Wextra -pedantic -I. \ + suitebridge_native_timer_modify.cpp \ + tests/test_suitebridge_native_timer_modify.cpp \ + -o $(NATIVE_TIMER_MODIFY_TEST) + $(NATIVE_TIMER_MODIFY_TEST) + rm -f $(NATIVE_TIMER_MODIFY_TEST) + test-artwork-reference: $(CXX) -std=c++17 -Wall -Wextra -pedantic -I. \ suitebridge_artwork_reference.cpp \ @@ -252,7 +261,7 @@ check-build: $(SOFILE) test -s $(SOFILE) readelf -h $(SOFILE) >/dev/null -check: check-contract check-capabilities-contract check-capability-discovery-contract check-counter-continuity-contract check-status-events-contract check-status-snapshot-contract check-local-contract-contract check-svdrp-contract check-recording-metadata-contract check-version test-lifecycle test-capabilities test-capability-discovery test-counter-continuity test-status-events test-status-snapshot test-local-contract test-svdrp-contract test-native-probe test-native-timer-create test-native-timer-delete test-artwork-reference test-image-dimensions test-epg-artwork-contract test-epg-metadata-contract test-epg-type-snapshot-contract test-recording-identity test-recording-metadata-contract check-build +check: check-contract check-capabilities-contract check-capability-discovery-contract check-counter-continuity-contract check-status-events-contract check-status-snapshot-contract check-local-contract-contract check-svdrp-contract check-recording-metadata-contract check-version test-lifecycle test-capabilities test-capability-discovery test-counter-continuity test-status-events test-status-snapshot test-local-contract test-svdrp-contract test-native-probe test-native-timer-create test-native-timer-delete test-native-timer-modify test-artwork-reference test-image-dimensions test-epg-artwork-contract test-epg-metadata-contract test-epg-type-snapshot-contract test-recording-identity test-recording-metadata-contract check-build clean: - rm -f $(OBJS) $(DEPFILE) $(SOFILE) $(LIFECYCLE_TEST) $(CAPABILITIES_TEST) $(CAPABILITY_DISCOVERY_TEST) $(COUNTER_CONTINUITY_TEST) $(STATUS_EVENTS_TEST) $(STATUS_SNAPSHOT_TEST) $(LOCAL_CONTRACT_TEST) $(SVDRP_CONTRACT_TEST) $(NATIVE_PROBE_TEST) $(NATIVE_TIMER_CREATE_TEST) $(NATIVE_TIMER_DELETE_TEST) $(ARTWORK_REFERENCE_TEST) $(IMAGE_DIMENSIONS_TEST) $(EPG_ARTWORK_CONTRACT_TEST) $(EPG_METADATA_CONTRACT_TEST) $(EPG_TYPE_SNAPSHOT_CONTRACT_TEST) $(RECORDING_IDENTITY_TEST) $(RECORDING_METADATA_CONTRACT_TEST) *.tgz core* *~ ._* + rm -f $(OBJS) $(DEPFILE) $(SOFILE) $(LIFECYCLE_TEST) $(CAPABILITIES_TEST) $(CAPABILITY_DISCOVERY_TEST) $(COUNTER_CONTINUITY_TEST) $(STATUS_EVENTS_TEST) $(STATUS_SNAPSHOT_TEST) $(LOCAL_CONTRACT_TEST) $(SVDRP_CONTRACT_TEST) $(NATIVE_PROBE_TEST) $(NATIVE_TIMER_CREATE_TEST) $(NATIVE_TIMER_DELETE_TEST) $(NATIVE_TIMER_MODIFY_TEST) $(ARTWORK_REFERENCE_TEST) $(IMAGE_DIMENSIONS_TEST) $(EPG_ARTWORK_CONTRACT_TEST) $(EPG_METADATA_CONTRACT_TEST) $(EPG_TYPE_SNAPSHOT_CONTRACT_TEST) $(RECORDING_IDENTITY_TEST) $(RECORDING_METADATA_CONTRACT_TEST) *.tgz core* *~ ._* diff --git a/vdr-plugin-suite-bridge/suitebridge.cpp b/vdr-plugin-suite-bridge/suitebridge.cpp index 109279f1e..b70359a41 100644 --- a/vdr-plugin-suite-bridge/suitebridge.cpp +++ b/vdr-plugin-suite-bridge/suitebridge.cpp @@ -12,7 +12,10 @@ cPluginSuiteBridge::cPluginSuiteBridge() &nativeTimerCreateVdrMutation_), nativeTimerDelete_( nativeProbe_.PluginInstanceEpoch(), - &nativeTimerDeleteVdrMutation_) + &nativeTimerDeleteVdrMutation_), + nativeTimerModify_( + nativeProbe_.PluginInstanceEpoch(), + &nativeTimerModifyVdrMutation_) { } @@ -56,6 +59,10 @@ bool cPluginSuiteBridge::Initialize(void) "suitebridge: native-operation=vdr.timer.create schema=1 side-effect=timer-create mutations=enabled execution=enabled provider=suitebridge acceptance=required"); isyslog( "suitebridge: native-operation=vdr.timer.delete schema=1 side-effect=timer-delete mutations=enabled execution=enabled provider=suitebridge acceptance=required"); + isyslog( + "suitebridge: native-operation=vdr.timer.update schema=1 side-effect=timer-update mutations=enabled execution=enabled provider=suitebridge acceptance=required"); + isyslog( + "suitebridge: native-operation=vdr.timer.toggle schema=1 side-effect=timer-toggle mutations=enabled execution=enabled provider=suitebridge acceptance=required"); return true; } diff --git a/vdr-plugin-suite-bridge/suitebridge.h b/vdr-plugin-suite-bridge/suitebridge.h index 16fed7e36..2e7ecc83a 100644 --- a/vdr-plugin-suite-bridge/suitebridge.h +++ b/vdr-plugin-suite-bridge/suitebridge.h @@ -7,6 +7,8 @@ #include "suitebridge_native_timer_create_vdr.h" #include "suitebridge_native_timer_delete.h" #include "suitebridge_native_timer_delete_vdr.h" +#include "suitebridge_native_timer_modify.h" +#include "suitebridge_native_timer_modify_vdr.h" #include "suitebridge_status_monitor.h" #include @@ -38,6 +40,8 @@ class cPluginSuiteBridge final : public cPlugin { SuiteBridgeNativeTimerCreateService nativeTimerCreate_; SuiteBridgeNativeTimerDeleteVdrMutationCallback nativeTimerDeleteVdrMutation_; SuiteBridgeNativeTimerDeleteService nativeTimerDelete_; + SuiteBridgeNativeTimerModifyVdrMutationCallback nativeTimerModifyVdrMutation_; + SuiteBridgeNativeTimerModifyService nativeTimerModify_; }; #endif diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_modify.cpp b/vdr-plugin-suite-bridge/suitebridge_native_timer_modify.cpp new file mode 100644 index 000000000..de7b26c4f --- /dev/null +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_modify.cpp @@ -0,0 +1,408 @@ +#include "suitebridge_native_timer_modify.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr const char *UpdateOperation = "vdr.timer.update"; +constexpr const char *ToggleOperation = "vdr.timer.toggle"; +constexpr const char *AuthorityDomain = "vdr.timer"; +constexpr const char *ProviderId = "suitebridge:local"; +constexpr const char *ProviderKind = "suitebridge"; +constexpr const char *CapabilityProtocol = "vdr-suite-ntmod-cap/1"; +constexpr const char *ResultProtocol = "vdr-suite-ntmod-result/1"; +constexpr std::uint64_t ProviderGeneration = 1; +constexpr std::uint64_t CapabilityRevision = 1; +constexpr std::size_t FingerprintLength = 71; +constexpr int SuccessReplyCode = 900; +constexpr int MalformedReplyCode = 501; +constexpr int StaleReplyCode = 555; +constexpr int DisabledReplyCode = 556; +constexpr int AcceptedUnverifiedReplyCode = 557; +constexpr int OutcomeUnknownReplyCode = 558; +constexpr int ReplayConflictReplyCode = 559; +constexpr int ReplayLedgerFullReplyCode = 560; + +const char *operation(SuiteBridgeNativeTimerModifyKind kind) +{ + return kind == SuiteBridgeNativeTimerModifyKind::Toggle + ? ToggleOperation : UpdateOperation; +} + +bool safeToken(const std::string &value, std::size_t maximum = 512) +{ + return !value.empty() && value.size() <= maximum && + std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isalnum(character) != 0 || character == '-' || + character == '_' || character == '.' || character == ':'; + }); +} + +bool safeFingerprint(const std::string &value) +{ + if (value.size() != FingerprintLength || + value.compare(0, 7, "sha256:") != 0) return false; + return std::all_of(value.begin() + 7, value.end(), [](unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }); +} + +bool unsignedValue(const std::string &value, std::uint64_t &parsed, bool positive = true) +{ + if (value.empty() || value.size() > 19) return false; + parsed = 0; + for (unsigned char character : value) { + if (character < '0' || character > '9') return false; + const unsigned digit = static_cast(character - '0'); + if (parsed > (std::numeric_limits::max() - digit) / 10U) + return false; + parsed = parsed * 10U + digit; + } + return !positive || parsed > 0; +} + +bool smallValue(const std::string &value, std::uint32_t &parsed) +{ + std::uint64_t candidate = 0; + if (!unsignedValue(value, candidate, false) || candidate > 99) return false; + parsed = static_cast(candidate); + return true; +} + +bool booleanValue(const std::string &value, bool &parsed) +{ + if (value == "0") { parsed = false; return true; } + if (value == "1") { parsed = true; return true; } + return false; +} + +int hexValue(unsigned char character) +{ + if (character >= '0' && character <= '9') return character - '0'; + if (character >= 'a' && character <= 'f') return character - 'a' + 10; + if (character >= 'A' && character <= 'F') return character - 'A' + 10; + return -1; +} + +bool decodeText(const std::string &encoded, std::string &value, std::size_t maximum) +{ + value.clear(); + if (encoded == "-") return true; + if (encoded.empty() || (encoded.size() % 2U) != 0 || + encoded.size() > maximum * 2U) return false; + value.reserve(encoded.size() / 2U); + for (std::size_t offset = 0; offset < encoded.size(); offset += 2U) { + const int high = hexValue(static_cast(encoded[offset])); + const int low = hexValue(static_cast(encoded[offset + 1U])); + if (high < 0 || low < 0) return false; + const char character = static_cast((high << 4) | low); + if (character == '\0' || character == '\r' || character == '\n') + return false; + value.push_back(character); + } + return true; +} + +std::vector split(const char *option) +{ + std::vector values; + if (option == nullptr) return values; + const std::string input(option); + std::size_t position = 0; + while (position < input.size()) { + while (position < input.size() && input[position] == ' ') ++position; + if (position == input.size()) break; + const std::size_t end = input.find(' ', position); + values.push_back(input.substr(position, + end == std::string::npos ? std::string::npos : end - position)); + if (values.size() > 48) return {}; + if (end == std::string::npos) break; + position = end + 1; + } + return values; +} + +bool validTime(const std::string &value) +{ + if (value.size() != 4) return false; + for (unsigned char c : value) if (!std::isdigit(c)) return false; + const int hour = (value[0]-'0')*10 + value[1]-'0'; + const int minute = (value[2]-'0')*10 + value[3]-'0'; + return hour <= 23 && minute <= 59; +} + +bool validWeekdays(const std::string &value) +{ + if (value.size() != 7) return false; + for (unsigned char c : value) + if (c != '-' && std::isalpha(c) == 0) return false; + return true; +} + +bool parseExecute(const char *option, SuiteBridgeNativeTimerModifyRequest &request) +{ + const std::vector v = split(option); + if (v.size() != 41 || v[0] != "EXEC" || + v[1] != "vdr-suite-native/1" || v[3] != "1") return false; + if (v[2] == UpdateOperation) request.kind = SuiteBridgeNativeTimerModifyKind::Update; + else if (v[2] == ToggleOperation) request.kind = SuiteBridgeNativeTimerModifyKind::Toggle; + else return false; + + request.commandId=v[4]; request.requestFingerprint=v[5]; + request.operationId=v[6]; request.operationRevision=v[7]; + request.nativeTimerBindingId=v[8]; request.expectedBindingRevision=v[9]; + request.expectedNativeTimerFingerprint=v[10]; request.timerAssignmentId=v[11]; + request.backendNativeTimerId=v[12]; + if (!decodeText(v[13], request.channelId, 192) || + !decodeText(v[14], request.title, 1024) || + !decodeText(v[15], request.directory, 1024) || + !decodeText(v[16], request.day, 192) || + !decodeText(v[17], request.weekdays, 16) || + !decodeText(v[18], request.startTime, 8) || + !decodeText(v[19], request.endTime, 8)) return false; + request.jobId=v[24]; request.attemptId=v[25]; + request.backendId=v[27]; request.agentId=v[28]; request.agentInstanceId=v[29]; + request.authorityDomain=v[32]; request.providerId=v[33]; request.providerKind=v[34]; + request.providerInstanceEpoch=v[36]; request.requiredCapability=v[39]; + + return smallValue(v[20], request.priority) && + smallValue(v[21], request.lifetime) && + booleanValue(v[22], request.enabled) && booleanValue(v[23], request.vps) && + unsignedValue(v[26], request.claimEpoch) && + unsignedValue(v[30], request.backendGeneration) && + unsignedValue(v[31], request.controlPlaneClaimedAt) && + unsignedValue(v[35], request.ownershipGeneration) && + unsignedValue(v[37], request.providerGeneration) && + unsignedValue(v[38], request.capabilityRevision) && + unsignedValue(v[40], request.localStartingPersistedAt) && + safeToken(request.commandId,192) && safeToken(request.requestFingerprint) && + safeToken(request.operationId,192) && safeToken(request.operationRevision,192) && + safeToken(request.nativeTimerBindingId,192) && + safeToken(request.expectedBindingRevision,192) && + safeFingerprint(request.expectedNativeTimerFingerprint) && + safeToken(request.timerAssignmentId,192) && + safeToken(request.backendNativeTimerId,192) && + !request.channelId.empty() && validWeekdays(request.weekdays) && + validTime(request.startTime) && validTime(request.endTime) && + safeToken(request.jobId,192) && safeToken(request.attemptId,192) && + safeToken(request.backendId,192) && safeToken(request.agentId,192) && + safeToken(request.agentInstanceId,192) && safeToken(request.authorityDomain,192) && + safeToken(request.providerId,192) && safeToken(request.providerKind,192) && + safeToken(request.providerInstanceEpoch,192) && + safeToken(request.requiredCapability,192); +} + +void append(std::ostringstream &out, const std::string &value) +{ + out << value.size() << ':' << value << '|'; +} +void append(std::ostringstream &out, std::uint64_t value) { out << value << '|'; } +std::string canonicalRequest(const SuiteBridgeNativeTimerModifyRequest &r) +{ + std::ostringstream c; + append(c, operation(r.kind)); append(c,r.commandId); append(c,r.requestFingerprint); + append(c,r.operationId); append(c,r.operationRevision); append(c,r.nativeTimerBindingId); + append(c,r.expectedBindingRevision); append(c,r.expectedNativeTimerFingerprint); + append(c,r.timerAssignmentId); append(c,r.backendNativeTimerId); append(c,r.channelId); + append(c,r.title); append(c,r.directory); append(c,r.day); append(c,r.weekdays); + append(c,r.startTime); append(c,r.endTime); append(c,r.priority); append(c,r.lifetime); + append(c,r.enabled ? 1U : 0U); append(c,r.vps ? 1U : 0U); append(c,r.jobId); + append(c,r.attemptId); append(c,r.claimEpoch); append(c,r.backendId); append(c,r.agentId); + append(c,r.agentInstanceId); append(c,r.backendGeneration); append(c,r.controlPlaneClaimedAt); + append(c,r.authorityDomain); append(c,r.providerId); append(c,r.providerKind); + append(c,r.ownershipGeneration); append(c,r.providerInstanceEpoch); + append(c,r.providerGeneration); append(c,r.capabilityRevision); + append(c,r.requiredCapability); append(c,r.localStartingPersistedAt); + return c.str(); +} + +SuiteBridgeCommandResult genericRejection(int code, const char *reason) +{ + return {true, code, std::string("vdr-suite-ntmod-rejected/1 ") + reason}; +} + +SuiteBridgeCommandResult typedResult( + int code, const SuiteBridgeNativeTimerModifyRequest &r, + const std::string &epoch, const char *disposition, + const char *reason, const std::string &evidence) +{ + SuiteBridgeCommandResult result; + result.handled=true; result.replyCode=code; + std::ostringstream payload; + payload << ResultProtocol << ' ' << r.commandId << ' ' << r.requestFingerprint + << ' ' << operation(r.kind) << " 1 " << epoch << ' ' + << ProviderGeneration << ' ' << CapabilityRevision << ' ' + << disposition << ' ' << reason << ' ' << evidence; + result.payload=payload.str(); + return result; +} + +SuiteBridgeCommandResult typedRejection( + int code, const SuiteBridgeNativeTimerModifyRequest &r, + const std::string &epoch, const char *reason, const std::string &evidence) +{ + return typedResult(code,r,epoch,"rejected_without_effect",reason,evidence); +} + +SuiteBridgeCommandResult mutationReply( + const SuiteBridgeNativeTimerModifyRequest &r, const std::string &epoch, + const SuiteBridgeNativeTimerModifyMutationResult &result) +{ + switch (result.disposition) { + case SuiteBridgeNativeTimerModifyMutationDisposition::AppliedUnverified: + return typedResult(AcceptedUnverifiedReplyCode,r,epoch, + "accepted_unverified","callback_applied",result.evidenceReference); + case SuiteBridgeNativeTimerModifyMutationDisposition::RejectedWithoutEffect: + return typedResult(DisabledReplyCode,r,epoch, + "rejected_without_effect","callback_rejected",result.evidenceReference); + case SuiteBridgeNativeTimerModifyMutationDisposition::OutcomeUnknown: + return typedResult(OutcomeUnknownReplyCode,r,epoch, + "outcome_unknown","callback_unknown",result.evidenceReference); + } + return typedResult(OutcomeUnknownReplyCode,r,epoch, + "outcome_unknown","callback_invalid","ntmod:callback-invalid:"+r.commandId); +} + +SuiteBridgeNativeTimerModifyMutationResult normalized( + const SuiteBridgeNativeTimerModifyRequest &r, + SuiteBridgeNativeTimerModifyMutationResult result) +{ + if (!safeToken(result.evidenceReference)) { + result.disposition=SuiteBridgeNativeTimerModifyMutationDisposition::OutcomeUnknown; + result.evidenceReference="ntmod:callback-invalid:"+r.commandId; + } + return result; +} +} // namespace + +SuiteBridgeNativeTimerModifyService::SuiteBridgeNativeTimerModifyService( + std::string pluginInstanceEpoch, + ISuiteBridgeNativeTimerModifyMutationCallback *callback, + std::size_t maximumReplayEntries) + : pluginInstanceEpoch_(std::move(pluginInstanceEpoch)), + mutationCallback_(callback), maximumReplayEntries_(maximumReplayEntries) +{ + if (!safeToken(pluginInstanceEpoch_,192)) pluginInstanceEpoch_="pie_invalid"; +} + +bool SuiteBridgeNativeTimerModifyService::ExecutionConfigured() const noexcept +{ + return mutationCallback_ != nullptr; +} + +SuiteBridgeCommandResult SuiteBridgeNativeTimerModifyService::Handle( + const char *command, const char *option) +{ + if (command == nullptr || strcasecmp(command,"NTMOD") != 0) return {}; + const auto values=split(option); + if (values.empty()) return genericRejection(MalformedReplyCode,"malformed"); + if (values.front()=="CAP") return capability(option); + if (values.front()=="EXEC") return execute(option); + return genericRejection(MalformedReplyCode,"unsupported"); +} + +SuiteBridgeCommandResult SuiteBridgeNativeTimerModifyService::capability( + const char *option) const +{ + const auto v=split(option); + if (v.size()!=3 || v[0]!="CAP" || v[1]!="1" || + (v[2]!="update" && v[2]!="toggle")) + return genericRejection(MalformedReplyCode,"capability-schema-unsupported"); + const char *op=v[2]=="toggle" ? ToggleOperation : UpdateOperation; + const char *state=ExecutionConfigured() ? "enabled" : "disabled"; + SuiteBridgeCommandResult result; + result.handled=true; result.replyCode=SuccessReplyCode; + std::ostringstream payload; + payload << CapabilityProtocol << ' ' << op << " 1 timer-modify " << state + << ' ' << ProviderKind << ' ' << pluginInstanceEpoch_ << ' ' + << ProviderGeneration << ' ' << CapabilityRevision << ' ' << state; + result.payload=payload.str(); + return result; +} + +SuiteBridgeCommandResult SuiteBridgeNativeTimerModifyService::execute( + const char *option) +{ + SuiteBridgeNativeTimerModifyRequest r; + if (!parseExecute(option,r)) + return genericRejection(MalformedReplyCode,"execute-malformed"); + const bool currentFence=r.authorityDomain==AuthorityDomain && + r.providerId==ProviderId && r.providerKind==ProviderKind && + r.providerInstanceEpoch==pluginInstanceEpoch_ && + r.providerGeneration==ProviderGeneration && + r.capabilityRevision==CapabilityRevision && + r.requiredCapability==operation(r.kind) && + r.localStartingPersistedAt>=r.controlPlaneClaimedAt; + if (!currentFence) + return typedRejection(StaleReplyCode,r,pluginInstanceEpoch_,"stale", + "ntmod:stale:"+r.commandId); + if (!ExecutionConfigured()) + return typedRejection(DisabledReplyCode,r,pluginInstanceEpoch_,"disabled", + "ntmod:disabled:"+r.commandId); + + bool inProgress=false, conflict=false, ledgerFull=false; + const auto result=executeReserved(r,canonicalRequest(r),inProgress,conflict,ledgerFull); + if (conflict) + return typedRejection(ReplayConflictReplyCode,r,pluginInstanceEpoch_,"replay_conflict", + "ntmod:replay-conflict:"+r.commandId); + if (ledgerFull) + return typedRejection(ReplayLedgerFullReplyCode,r,pluginInstanceEpoch_,"ledger_full", + "ntmod:ledger-full:"+r.commandId); + if (inProgress) + return typedResult(OutcomeUnknownReplyCode,r,pluginInstanceEpoch_, + "outcome_unknown","in_progress","ntmod:in-progress:"+r.commandId); + return mutationReply(r,pluginInstanceEpoch_,result); +} + +SuiteBridgeNativeTimerModifyMutationResult +SuiteBridgeNativeTimerModifyService::executeReserved( + const SuiteBridgeNativeTimerModifyRequest &r, const std::string &canonical, + bool &inProgress, bool &conflict, bool &ledgerFull) +{ + inProgress=false; conflict=false; ledgerFull=false; + { + std::lock_guard lock(replayMutex_); + const auto owner=operationByCommandId_.find(r.commandId); + if (owner!=operationByCommandId_.end() && owner->second!=r.operationId) { + conflict=true; return {}; + } + const auto existing=replayByOperationId_.find(r.operationId); + if (existing!=replayByOperationId_.end()) { + const ReplayEntry &entry=existing->second; + if (entry.commandId!=r.commandId || + entry.requestFingerprint!=r.requestFingerprint || + entry.canonicalRequest!=canonical) { conflict=true; return {}; } + if (!entry.terminal) { inProgress=true; return {}; } + return entry.result; + } + if (replayByOperationId_.size()>=maximumReplayEntries_) { + ledgerFull=true; return {}; + } + ReplayEntry entry; + entry.commandId=r.commandId; entry.requestFingerprint=r.requestFingerprint; + entry.canonicalRequest=canonical; + replayByOperationId_.emplace(r.operationId,std::move(entry)); + operationByCommandId_.emplace(r.commandId,r.operationId); + } + SuiteBridgeNativeTimerModifyMutationResult callbackResult; + try { callbackResult=normalized(r,mutationCallback_->ModifyTimer(r)); } + catch (...) { + callbackResult.disposition=SuiteBridgeNativeTimerModifyMutationDisposition::OutcomeUnknown; + callbackResult.evidenceReference="ntmod:callback-exception:"+r.commandId; + } + { + std::lock_guard lock(replayMutex_); + const auto existing=replayByOperationId_.find(r.operationId); + if (existing!=replayByOperationId_.end()) { + existing->second.result=callbackResult; + existing->second.terminal=true; + } + } + return callbackResult; +} diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_modify.h b/vdr-plugin-suite-bridge/suitebridge_native_timer_modify.h new file mode 100644 index 000000000..ae75b3266 --- /dev/null +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_modify.h @@ -0,0 +1,113 @@ +#ifndef VDR_SUITE_BRIDGE_NATIVE_TIMER_MODIFY_H +#define VDR_SUITE_BRIDGE_NATIVE_TIMER_MODIFY_H + +#include "suitebridge_command_result.h" + +#include +#include +#include +#include +#include + +enum class SuiteBridgeNativeTimerModifyKind { + Update, + Toggle, +}; + +struct SuiteBridgeNativeTimerModifyRequest final { + SuiteBridgeNativeTimerModifyKind kind = SuiteBridgeNativeTimerModifyKind::Update; + std::string commandId; + std::string requestFingerprint; + std::string operationId; + std::string operationRevision; + std::string nativeTimerBindingId; + std::string expectedBindingRevision; + std::string expectedNativeTimerFingerprint; + std::string timerAssignmentId; + std::string backendNativeTimerId; + std::string channelId; + std::string title; + std::string directory; + std::string day; + std::string weekdays; + std::string startTime; + std::string endTime; + std::uint32_t priority = 0; + std::uint32_t lifetime = 0; + bool enabled = false; + bool vps = false; + std::string jobId; + std::string attemptId; + std::uint64_t claimEpoch = 0; + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; + std::uint64_t controlPlaneClaimedAt = 0; + std::string authorityDomain; + std::string providerId; + std::string providerKind; + std::uint64_t ownershipGeneration = 0; + std::string providerInstanceEpoch; + std::uint64_t providerGeneration = 0; + std::uint64_t capabilityRevision = 0; + std::string requiredCapability; + std::uint64_t localStartingPersistedAt = 0; +}; + +enum class SuiteBridgeNativeTimerModifyMutationDisposition { + AppliedUnverified, + RejectedWithoutEffect, + OutcomeUnknown, +}; + +struct SuiteBridgeNativeTimerModifyMutationResult final { + SuiteBridgeNativeTimerModifyMutationDisposition disposition = + SuiteBridgeNativeTimerModifyMutationDisposition::OutcomeUnknown; + std::string evidenceReference; +}; + +class ISuiteBridgeNativeTimerModifyMutationCallback { +public: + virtual ~ISuiteBridgeNativeTimerModifyMutationCallback() = default; + virtual SuiteBridgeNativeTimerModifyMutationResult ModifyTimer( + const SuiteBridgeNativeTimerModifyRequest &request) = 0; +}; + +class SuiteBridgeNativeTimerModifyService final { +public: + explicit SuiteBridgeNativeTimerModifyService( + std::string pluginInstanceEpoch, + ISuiteBridgeNativeTimerModifyMutationCallback *mutationCallback = nullptr, + std::size_t maximumReplayEntries = 4096); + + SuiteBridgeCommandResult Handle(const char *command, const char *option); + bool ExecutionConfigured() const noexcept; + +private: + struct ReplayEntry final { + std::string commandId; + std::string requestFingerprint; + std::string canonicalRequest; + bool terminal = false; + SuiteBridgeNativeTimerModifyMutationResult result; + }; + + SuiteBridgeCommandResult capability(const char *option) const; + SuiteBridgeCommandResult execute(const char *option); + SuiteBridgeNativeTimerModifyMutationResult executeReserved( + const SuiteBridgeNativeTimerModifyRequest &request, + const std::string &canonicalRequest, + bool &inProgress, + bool &conflict, + bool &ledgerFull); + + std::string pluginInstanceEpoch_; + ISuiteBridgeNativeTimerModifyMutationCallback *mutationCallback_ = nullptr; + std::size_t maximumReplayEntries_ = 4096; + std::mutex replayMutex_; + std::unordered_map replayByOperationId_; + std::unordered_map operationByCommandId_; +}; + +#endif diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.cpp b/vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.cpp new file mode 100644 index 000000000..b981c8c1c --- /dev/null +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.cpp @@ -0,0 +1,318 @@ +#include "suitebridge_native_timer_modify_vdr.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int TimerWriteLockTimeoutMs = 1000; + +bool digits(const std::string &value) +{ + return !value.empty() && std::all_of(value.begin(), value.end(), + [](unsigned char c) { return std::isdigit(c) != 0; }); +} +bool validDay(const std::string &value) +{ + if (value.empty()) return true; + if (value.size()!=10 || value[4]!='-' || value[7]!='-') return false; + for (std::size_t i=0;i(value[i]))==0) + return false; + return true; +} +bool validWeekdays(const std::string &value) +{ + static constexpr char Names[7]={'M','T','W','T','F','S','S'}; + if (value.size()!=7) return false; + for (std::size_t i=0;i<7;++i) + if (value[i]!='-' && value[i]!=Names[i]) return false; + return true; +} +std::string timerDay(const SuiteBridgeNativeTimerModifyRequest &r, bool &valid) +{ + valid=validWeekdays(r.weekdays); + if (!valid) return {}; + const bool repeating=r.weekdays.find_first_not_of('-')!=std::string::npos; + if (repeating) { + valid=validDay(r.day); + return valid ? r.weekdays+(r.day.empty()?std::string():"@"+r.day) : std::string(); + } + valid=validDay(r.day) && !r.day.empty(); + return valid ? r.day : std::string(); +} +std::string timerFile(const SuiteBridgeNativeTimerModifyRequest &r, bool &valid) +{ + valid=!r.title.empty() && r.title.find('~')==std::string::npos && + r.title.find('|')==std::string::npos && r.directory.find('|')==std::string::npos; + if (!valid) return {}; + std::string value=r.directory.empty()?r.title:r.directory+"~"+r.title; + std::replace(value.begin(),value.end(),':','|'); + return value; +} + +SuiteBridgeNativeTimerModifyMutationResult result( + SuiteBridgeNativeTimerModifyMutationDisposition disposition, + const std::string &evidence) +{ + return {disposition, evidence}; +} + +std::string evidence(const char *reason, const std::string &commandId) +{ + return std::string("ntmod:vdr:") + reason + ":" + commandId; +} + +bool parseLocalTimerId(const std::string &value, int &timerId) +{ + if (value.empty() || value.size() > 10) return false; + errno = 0; + char *end = nullptr; + const long parsed = std::strtol(value.c_str(), &end, 10); + if (errno != 0 || end == value.c_str() || *end != '\0' || parsed <= 0 || + parsed > std::numeric_limits::max()) + return false; + timerId = static_cast(parsed); + return std::to_string(timerId) == value; +} + +void appendField(std::string &value, const std::string &field) +{ + value += std::to_string(field.size()); + value += ':'; + value += field; + value += '|'; +} + +void appendInteger(std::string &value, long long number) +{ + value += std::to_string(number); + value += '|'; +} + +void appendBoolean(std::string &value, bool flag) +{ + value += flag ? "1|" : "0|"; +} + +std::string hhmm(int value) +{ + char buffer[8] = {0}; + std::snprintf(buffer, sizeof(buffer), "%04d", value); + return buffer; +} + +std::string day(time_t value) +{ + if (value == 0) return {}; + std::tm local{}; + if (localtime_r(&value, &local) == nullptr) return {}; + char buffer[16] = {0}; + if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &local) == 0) + return {}; + return buffer; +} + +std::string weekdays(int value) +{ + static constexpr char Names[7] = {'M', 'T', 'W', 'T', 'F', 'S', 'S'}; + std::string result(7, '-'); + for (int index = 0; index < 7; ++index) + if ((value & (1 << index)) != 0) result[index] = Names[index]; + return result; +} + +void splitFile(const char *file, std::string &directory, std::string &title) +{ + const std::string value = file ? file : ""; + const std::size_t separator = value.find_last_of('~'); + if (separator == std::string::npos) { + directory.clear(); + title = value; + } else { + directory = value.substr(0, separator); + title = value.substr(separator + 1); + } +} + +std::string canonicalObservedState(const cTimer &timer) +{ + if (timer.Channel() == nullptr) return {}; + const cString channel = timer.Channel()->GetChannelID().ToString(); + if (!*channel) return {}; + + std::string directory; + std::string title; + splitFile(timer.File(), directory, title); + + std::string eventId; + if (timer.Event() != nullptr) + eventId = std::to_string(static_cast(timer.Event()->EventID())); + + std::string fingerprint = "native-timer-observed-state/1|"; + appendField(fingerprint, *channel); + appendField(fingerprint, eventId); + appendField(fingerprint, title); + appendField(fingerprint, directory); + appendField(fingerprint, day(timer.Day())); + appendField(fingerprint, weekdays(timer.WeekDays())); + appendField(fingerprint, hhmm(timer.Start())); + appendField(fingerprint, hhmm(timer.Stop())); + appendInteger(fingerprint, timer.Flags()); + appendInteger(fingerprint, timer.Priority()); + appendInteger(fingerprint, timer.Lifetime()); + appendBoolean(fingerprint, (timer.Flags() & tfActive) != 0); + appendBoolean(fingerprint, (timer.Flags() & tfVps) != 0); + appendBoolean(fingerprint, timer.Recording()); + appendBoolean(fingerprint, timer.Pending()); + return fingerprint; +} + +std::uint32_t rotateRight(std::uint32_t value, unsigned count) +{ + return (value >> count) | (value << (32U - count)); +} + +std::string sha256Token(const std::string &input) +{ + static constexpr std::array K = { + 0x428a2f98U,0x71374491U,0xb5c0fbcfU,0xe9b5dba5U,0x3956c25bU,0x59f111f1U,0x923f82a4U,0xab1c5ed5U, + 0xd807aa98U,0x12835b01U,0x243185beU,0x550c7dc3U,0x72be5d74U,0x80deb1feU,0x9bdc06a7U,0xc19bf174U, + 0xe49b69c1U,0xefbe4786U,0x0fc19dc6U,0x240ca1ccU,0x2de92c6fU,0x4a7484aaU,0x5cb0a9dcU,0x76f988daU, + 0x983e5152U,0xa831c66dU,0xb00327c8U,0xbf597fc7U,0xc6e00bf3U,0xd5a79147U,0x06ca6351U,0x14292967U, + 0x27b70a85U,0x2e1b2138U,0x4d2c6dfcU,0x53380d13U,0x650a7354U,0x766a0abbU,0x81c2c92eU,0x92722c85U, + 0xa2bfe8a1U,0xa81a664bU,0xc24b8b70U,0xc76c51a3U,0xd192e819U,0xd6990624U,0xf40e3585U,0x106aa070U, + 0x19a4c116U,0x1e376c08U,0x2748774cU,0x34b0bcb5U,0x391c0cb3U,0x4ed8aa4aU,0x5b9cca4fU,0x682e6ff3U, + 0x748f82eeU,0x78a5636fU,0x84c87814U,0x8cc70208U,0x90befffaU,0xa4506cebU,0xbef9a3f7U,0xc67178f2U}; + std::vector message(input.begin(), input.end()); + const std::uint64_t bits = static_cast(message.size()) * 8U; + message.push_back(0x80U); + while (message.size() % 64U != 56U) message.push_back(0U); + for (int shift = 56; shift >= 0; shift -= 8) + message.push_back(static_cast(bits >> shift)); + + std::array h = {0x6a09e667U,0xbb67ae85U,0x3c6ef372U,0xa54ff53aU, + 0x510e527fU,0x9b05688cU,0x1f83d9abU,0x5be0cd19U}; + for (std::size_t offset = 0; offset < message.size(); offset += 64U) { + std::array w{}; + for (std::size_t i = 0; i < 16; ++i) { + const std::size_t p = offset + i * 4U; + w[i] = (static_cast(message[p]) << 24U) | + (static_cast(message[p + 1]) << 16U) | + (static_cast(message[p + 2]) << 8U) | + static_cast(message[p + 3]); + } + for (std::size_t i = 16; i < 64; ++i) { + const std::uint32_t s0 = rotateRight(w[i-15],7) ^ rotateRight(w[i-15],18) ^ (w[i-15] >> 3U); + const std::uint32_t s1 = rotateRight(w[i-2],17) ^ rotateRight(w[i-2],19) ^ (w[i-2] >> 10U); + w[i] = w[i-16] + s0 + w[i-7] + s1; + } + std::uint32_t a=h[0],b=h[1],c=h[2],d=h[3],e=h[4],f=h[5],g=h[6],x=h[7]; + for (std::size_t i = 0; i < 64; ++i) { + const std::uint32_t s1 = rotateRight(e,6) ^ rotateRight(e,11) ^ rotateRight(e,25); + const std::uint32_t ch = (e & f) ^ ((~e) & g); + const std::uint32_t t1 = x + s1 + ch + K[i] + w[i]; + const std::uint32_t s0 = rotateRight(a,2) ^ rotateRight(a,13) ^ rotateRight(a,22); + const std::uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + const std::uint32_t t2 = s0 + maj; + x=g; g=f; f=e; e=d+t1; d=c; c=b; b=a; a=t1+t2; + } + h[0]+=a; h[1]+=b; h[2]+=c; h[3]+=d; h[4]+=e; h[5]+=f; h[6]+=g; h[7]+=x; + } + static constexpr char Hex[] = "0123456789abcdef"; + std::string token = "sha256:"; + token.reserve(71); + for (const std::uint32_t word : h) + for (int shift = 28; shift >= 0; shift -= 4) + token.push_back(Hex[(word >> shift) & 0x0fU]); + return token; +} +} + + +SuiteBridgeNativeTimerModifyMutationResult +SuiteBridgeNativeTimerModifyVdrMutationCallback::ModifyTimer( + const SuiteBridgeNativeTimerModifyRequest &request) +{ + int timerId=0; + if (!parseLocalTimerId(request.backendNativeTimerId,timerId)) + return result(SuiteBridgeNativeTimerModifyMutationDisposition::RejectedWithoutEffect, + evidence("invalid-id",request.commandId)); + try { + cStateKey stateKey; + cTimers *timers=cTimers::GetTimersWrite(stateKey,TimerWriteLockTimeoutMs); + if (timers==nullptr) + return result(SuiteBridgeNativeTimerModifyMutationDisposition::RejectedWithoutEffect, + evidence("lock-unavailable",request.commandId)); + cTimer *timer=timers->GetById(timerId,nullptr); + if (timer==nullptr) { + stateKey.Remove(false); + return result(SuiteBridgeNativeTimerModifyMutationDisposition::RejectedWithoutEffect, + evidence("not-found",request.commandId)); + } + const std::string canonical=canonicalObservedState(*timer); + const std::string fingerprint=canonical.empty()?std::string():sha256Token(canonical); + if (fingerprint.empty() || fingerprint!=request.expectedNativeTimerFingerprint) { + stateKey.Remove(false); + return result(SuiteBridgeNativeTimerModifyMutationDisposition::RejectedWithoutEffect, + evidence("fingerprint-mismatch",request.commandId)); + } + if (timer->Recording() || timer->Pending()) { + stateKey.Remove(false); + return result(SuiteBridgeNativeTimerModifyMutationDisposition::RejectedWithoutEffect, + evidence(timer->Recording()?"recording":"pending",request.commandId)); + } + + cTimer candidate=*timer; + if (request.kind==SuiteBridgeNativeTimerModifyKind::Toggle) { + if (request.enabled) candidate.SetFlags(tfActive); + else candidate.ClrFlags(tfActive); + } else { + bool valid=false; + const std::string selectedDay=timerDay(request,valid); + const std::string selectedFile=valid?timerFile(request,valid):std::string(); + if (!valid || request.startTime.size()!=4 || request.endTime.size()!=4 || + !digits(request.startTime) || !digits(request.endTime)) { + stateKey.Remove(false); + return result(SuiteBridgeNativeTimerModifyMutationDisposition::RejectedWithoutEffect, + evidence("invalid-specification",request.commandId)); + } + const unsigned flags=(request.enabled?tfActive:tfNone) | + (request.vps?tfVps:tfNone); + std::ostringstream definition; + definition << flags << ':' << request.channelId << ':' << selectedDay << ':' + << request.startTime << ':' << request.endTime << ':' + << request.priority << ':' << request.lifetime << ':' + << selectedFile << ':' << (timer->Aux()?timer->Aux():""); + if (!candidate.Parse(definition.str().c_str())) { + stateKey.Remove(false); + return result(SuiteBridgeNativeTimerModifyMutationDisposition::RejectedWithoutEffect, + evidence("parse-rejected",request.commandId)); + } + } + const bool recording=timer->Recording(); + timers->SetExplicitModify(); + *timer=candidate; + timer->SetRecording(recording); + timers->SetModified(); + stateKey.Remove(); + return result(SuiteBridgeNativeTimerModifyMutationDisposition::AppliedUnverified, + evidence("modified-unverified",request.commandId)); + } catch (...) { + return result(SuiteBridgeNativeTimerModifyMutationDisposition::OutcomeUnknown, + evidence("exception",request.commandId)); + } +} diff --git a/vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.h b/vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.h new file mode 100644 index 000000000..492222c79 --- /dev/null +++ b/vdr-plugin-suite-bridge/suitebridge_native_timer_modify_vdr.h @@ -0,0 +1,13 @@ +#ifndef VDR_SUITE_BRIDGE_NATIVE_TIMER_MODIFY_VDR_H +#define VDR_SUITE_BRIDGE_NATIVE_TIMER_MODIFY_VDR_H + +#include "suitebridge_native_timer_modify.h" + +class SuiteBridgeNativeTimerModifyVdrMutationCallback final + : public ISuiteBridgeNativeTimerModifyMutationCallback { +public: + SuiteBridgeNativeTimerModifyMutationResult ModifyTimer( + const SuiteBridgeNativeTimerModifyRequest &request) override; +}; + +#endif diff --git a/vdr-plugin-suite-bridge/suitebridge_svdrp.cpp b/vdr-plugin-suite-bridge/suitebridge_svdrp.cpp index a69aec1c1..dec444b55 100644 --- a/vdr-plugin-suite-bridge/suitebridge_svdrp.cpp +++ b/vdr-plugin-suite-bridge/suitebridge_svdrp.cpp @@ -72,6 +72,12 @@ cString cPluginSuiteBridge::SVDRPCommand( return ReturnResult(nativeTimerDelete, ReplyCode); } + const SuiteBridgeCommandResult nativeTimerModify = + nativeTimerModify_.Handle(Command, Option); + if (nativeTimerModify.handled) { + return ReturnResult(nativeTimerModify, ReplyCode); + } + const SuiteBridgeCapabilityDiscoveryReply capabilityReply( Command, Option, diff --git a/vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_modify.cpp b/vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_modify.cpp new file mode 100644 index 000000000..855d49a09 --- /dev/null +++ b/vdr-plugin-suite-bridge/tests/test_suitebridge_native_timer_modify.cpp @@ -0,0 +1,54 @@ +#include "../suitebridge_native_timer_modify.h" + +#include +#include +#include + +namespace { +struct Callback final : ISuiteBridgeNativeTimerModifyMutationCallback { + int calls=0; + SuiteBridgeNativeTimerModifyRequest last; + SuiteBridgeNativeTimerModifyMutationResult ModifyTimer( + const SuiteBridgeNativeTimerModifyRequest &request) override { + ++calls; last=request; + return {SuiteBridgeNativeTimerModifyMutationDisposition::AppliedUnverified, + "ntmod:test:applied"}; + } +}; +void require(bool value,const char *message) { + if (!value) { std::cerr << message << '\n'; std::exit(1); } +} +std::string execute(const char *operation,const char *capability) { + return std::string("EXEC vdr-suite-native/1 ")+operation+ + " 1 cmd_1 sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"+ + " op_1 1 bind_1 1 sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"+ + " assign_1 42 532d31312e38382d30 5469746c65 - 323032362d30382d3137"+ + " 2d2d2d2d2d2d2d 31323030 31333030 50 99 1 0 job_1 attempt_1 1"+ + " backend_1 agent_1 instance_1 1 100 vdr.timer suitebridge:local suitebridge"+ + " 1 pie_1 1 1 "+capability+" 101"; +} +} +int main() { + Callback callback; + SuiteBridgeNativeTimerModifyService service("pie_1",&callback); + auto cap=service.Handle("NTMOD","CAP 1 update"); + require(cap.handled && cap.replyCode==900 && + cap.payload.find("vdr.timer.update 1 timer-modify enabled")!=std::string::npos, + "UPDATE capability missing"); + cap=service.Handle("NTMOD","CAP 1 toggle"); + require(cap.replyCode==900 && cap.payload.find("vdr.timer.toggle")!=std::string::npos, + "TOGGLE capability missing"); + auto reply=service.Handle("NTMOD",execute("vdr.timer.update","vdr.timer.update").c_str()); + require(reply.replyCode==557 && callback.calls==1 && + callback.last.kind==SuiteBridgeNativeTimerModifyKind::Update, + "UPDATE dispatch failed"); + reply=service.Handle("NTMOD",execute("vdr.timer.update","vdr.timer.update").c_str()); + require(reply.replyCode==557 && callback.calls==1,"terminal replay mutated twice"); + reply=service.Handle("NTMOD",execute("vdr.timer.toggle","vdr.timer.toggle").c_str()); + require(reply.replyCode==559 && callback.calls==1,"operation replay conflict not fenced"); + SuiteBridgeNativeTimerModifyService disabled("pie_1"); + reply=disabled.Handle("NTMOD",execute("vdr.timer.toggle","vdr.timer.toggle").c_str()); + require(reply.replyCode==556,"disabled mutation accepted"); + require(!service.Handle("OTHER","CAP 1 update").handled,"foreign command handled"); + std::cout << "suitebridge native timer modify tests passed\n"; +} From fd01d8cdf5e6f52b8e8e91871c00c6bd654a7ce9 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:26:08 +0200 Subject: [PATCH 50/85] Add durable Agent contracts for native Timer modification --- Makefile | 1 - .../include/BackendAgentNativeTimerModify.h | 89 +++++ .../BackendAgentNativeTimerModifyExecutor.h | 36 ++ .../BackendAgentNativeTimerModifyLocalState.h | 44 +++ .../BackendAgentNativeTimerModifyPayload.h | 34 ++ .../src/BackendAgentNativeTimerModify.cpp | 102 ++++++ .../BackendAgentNativeTimerModifyExecutor.cpp | 315 ++++++++++++++++++ ...ackendAgentNativeTimerModifyLocalState.cpp | 211 ++++++++++++ .../BackendAgentNativeTimerModifyPayload.cpp | 156 +++++++++ ...test_backend_agent_native_timer_modify.cpp | 66 ++++ ...ative-timer-modify-agent-contract-tests.mk | 18 + ...se64_native_timer_modify_agent_contract.py | 22 ++ 12 files changed, 1093 insertions(+), 1 deletion(-) create mode 100644 core/agent/include/BackendAgentNativeTimerModify.h create mode 100644 core/agent/include/BackendAgentNativeTimerModifyExecutor.h create mode 100644 core/agent/include/BackendAgentNativeTimerModifyLocalState.h create mode 100644 core/agent/include/BackendAgentNativeTimerModifyPayload.h create mode 100644 core/agent/src/BackendAgentNativeTimerModify.cpp create mode 100644 core/agent/src/BackendAgentNativeTimerModifyExecutor.cpp create mode 100644 core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp create mode 100644 core/agent/src/BackendAgentNativeTimerModifyPayload.cpp create mode 100644 core/agent/tests/test_backend_agent_native_timer_modify.cpp create mode 100644 mk/phase64-native-timer-modify-agent-contract-tests.mk create mode 100755 tools/check_phase64_native_timer_modify_agent_contract.py diff --git a/Makefile b/Makefile index 9b324a4e7..cee02f1bf 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,6 @@ include mk/phase64-native-timer-absence-readback-verification-tests.mk include mk/phase64-mutation-operation-repository-tests.mk include mk/phase64-native-timer-create-operation-preparation-tests.mk include mk/phase64-native-timer-modify-operation-tests.mk -include mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk include mk/phase64-native-timer-delete-operation-completion-tests.mk include mk/phase64-native-timer-delete-operation-preparation-tests.mk include mk/phase64-native-timer-delete-dispatch-tests.mk diff --git a/core/agent/include/BackendAgentNativeTimerModify.h b/core/agent/include/BackendAgentNativeTimerModify.h new file mode 100644 index 000000000..1faae518a --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerModify.h @@ -0,0 +1,89 @@ +#pragma once + +#include "BackendAgentNativeTimerCreate.h" + +#include +#include + +namespace vdrsuite::agent +{ + +inline constexpr const char* kBackendAgentNativeTimerUpdateCommandType = "vdr.timer.update"; +inline constexpr const char* kBackendAgentNativeTimerToggleCommandType = "vdr.timer.toggle"; +inline constexpr const char* kBackendAgentNativeTimerModifyAuthorityDomain = "vdr.timer"; +inline constexpr const char* kBackendAgentNativeTimerUpdateCapability = "vdr.timer.update"; +inline constexpr const char* kBackendAgentNativeTimerToggleCapability = "vdr.timer.toggle"; +inline constexpr const char* kBackendAgentNativeTimerModifyProviderId = "suitebridge:local"; +inline constexpr const char* kBackendAgentNativeTimerModifyProviderKind = "suitebridge"; +inline constexpr std::uint64_t kBackendAgentNativeTimerModifyPayloadVersion = 1; + +enum class BackendAgentNativeTimerModifyKind { update, toggle }; +const char* backendAgentNativeTimerModifyKindName(BackendAgentNativeTimerModifyKind kind); +const char* backendAgentNativeTimerModifyCapability(BackendAgentNativeTimerModifyKind kind); + +struct BackendAgentNativeTimerModifyCommand +{ + BackendAgentNativeTimerModifyKind kind = BackendAgentNativeTimerModifyKind::update; + std::string commandId; + std::string requestFingerprint; + std::string operationId; + std::string operationRevision; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch = 0; + std::string nativeTimerBindingId; + std::string expectedBindingRevision; + std::string backendNativeTimerId; + std::string expectedCurrentFingerprint; + std::string expectedSpecificationFingerprint; + BackendAgentNativeTimerCreateSpecification specification; + std::string jobId; + std::string attemptId; + std::uint64_t claimEpoch = 0; + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; + std::int64_t controlPlaneClaimedAt = 0; + BackendAgentLocalProviderSelection localProviderSelection; +}; + +enum class BackendAgentNativeTimerModifyOutcomeCategory +{ + rejectedWithoutEffect, + acceptedUnverified, + outcomeUnknown, +}; + +struct BackendAgentNativeTimerModifyEvidence +{ + std::string commandId; + std::string requestFingerprint; + std::string operationId; + std::string operationRevision; + std::string jobId; + std::string attemptId; + std::uint64_t claimEpoch = 0; + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; + std::string providerInstanceEpoch; + std::int64_t localStartingPersistedAt = 0; + BackendAgentNativeTimerModifyOutcomeCategory outcome = + BackendAgentNativeTimerModifyOutcomeCategory::outcomeUnknown; + std::int64_t dispatchStartedAt = 0; + std::int64_t completedAt = 0; + std::string evidenceReference; +}; + +bool backendAgentNativeTimerModifyValidCommand( + const BackendAgentNativeTimerModifyCommand& command, + std::string& reasonCode); +bool backendAgentNativeTimerModifyEvidenceMatches( + const BackendAgentNativeTimerModifyEvidence& evidence, + const BackendAgentNativeTimerModifyCommand& command, + std::string& reasonCode); + +} diff --git a/core/agent/include/BackendAgentNativeTimerModifyExecutor.h b/core/agent/include/BackendAgentNativeTimerModifyExecutor.h new file mode 100644 index 000000000..d50132b9a --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerModifyExecutor.h @@ -0,0 +1,36 @@ +#pragma once + +#include "BackendAgentNativeTimerModifyLocalState.h" + +namespace vdrsuite::agent +{ +enum class BackendAgentNativeTimerModifyTransportDisposition { + rejectedWithoutEffect, acceptedUnverified, outcomeUnknown +}; +struct BackendAgentNativeTimerModifyTransportRequest { + BackendAgentNativeTimerModifyCommand command; + std::int64_t localStartingPersistedAt=0; +}; +struct BackendAgentNativeTimerModifyTransportReply { + BackendAgentNativeTimerModifyTransportDisposition disposition= + BackendAgentNativeTimerModifyTransportDisposition::outcomeUnknown; + std::string evidenceReference; +}; +class IBackendAgentNativeTimerModifyTransport { +public: + virtual ~IBackendAgentNativeTimerModifyTransport()=default; + virtual bool discoverProvider(BackendAgentLocalProviderFacts&,std::string&)=0; + virtual BackendAgentNativeTimerModifyTransportReply modifyTimer( + const BackendAgentNativeTimerModifyTransportRequest&)=0; +}; +struct BackendAgentNativeTimerModifyExecutorContext { + std::string backendId;std::string agentId;std::string agentInstanceId; + std::uint64_t backendGeneration=0;std::int64_t now=0; +}; +bool backendAgentNativeTimerModifyExecuteFreshStartingOnce( + const BackendAgentCommandAssignment&, + const BackendAgentNativeTimerModifyLocalState&, + const BackendAgentNativeTimerModifyExecutorContext&, + IBackendAgentNativeTimerModifyTransport&, + BackendAgentNativeTimerModifyEvidence&,std::string&); +} diff --git a/core/agent/include/BackendAgentNativeTimerModifyLocalState.h b/core/agent/include/BackendAgentNativeTimerModifyLocalState.h new file mode 100644 index 000000000..dd88409c4 --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerModifyLocalState.h @@ -0,0 +1,44 @@ +#pragma once + +#include "BackendAgentCommand.h" +#include "BackendAgentNativeTimerModify.h" + +namespace vdrsuite::agent +{ +enum class BackendAgentNativeTimerModifyLocalPhase { starting, completed }; +enum class BackendAgentNativeTimerModifyRecoveryDecision { + failClosed, reconcileOnly, returnPersistedEvidence +}; +struct BackendAgentNativeTimerModifyLocalState { + std::uint64_t schemaVersion=1; + BackendAgentNativeTimerModifyLocalPhase phase= + BackendAgentNativeTimerModifyLocalPhase::starting; + BackendAgentNativeTimerModifyCommand command; + std::int64_t localStartingPersistedAt=0; + BackendAgentNativeTimerModifyEvidence evidence; +}; +struct BackendAgentNativeTimerModifyRecoveryResult { + BackendAgentNativeTimerModifyRecoveryDecision decision= + BackendAgentNativeTimerModifyRecoveryDecision::failClosed; + std::string reasonCode; + BackendAgentNativeTimerModifyEvidence evidence; +}; +bool backendAgentNativeTimerModifyCommandFromAssignment( + const BackendAgentCommandAssignment&,BackendAgentNativeTimerModifyCommand&, + std::string&); +bool backendAgentNativeTimerModifyPrepareLocalStarting( + const BackendAgentCommandAssignment&,std::int64_t, + BackendAgentNativeTimerModifyLocalState&,std::string&); +bool backendAgentNativeTimerModifyLocalStateValid( + const BackendAgentNativeTimerModifyLocalState&,std::string&); +bool backendAgentNativeTimerModifyCompleteLocalState( + BackendAgentNativeTimerModifyLocalState&, + const BackendAgentNativeTimerModifyEvidence&,std::string&); +BackendAgentNativeTimerModifyRecoveryResult backendAgentNativeTimerModifyRecoverLocalState( + const BackendAgentNativeTimerModifyLocalState&,const std::string&, + const std::string&,const std::string&,std::uint64_t,std::int64_t); +std::string backendAgentNativeTimerModifySerializeLocalState( + const BackendAgentNativeTimerModifyLocalState&,std::string&); +bool backendAgentNativeTimerModifyParseLocalState( + const std::string&,BackendAgentNativeTimerModifyLocalState&,std::string&); +} diff --git a/core/agent/include/BackendAgentNativeTimerModifyPayload.h b/core/agent/include/BackendAgentNativeTimerModifyPayload.h new file mode 100644 index 000000000..620cd7cba --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerModifyPayload.h @@ -0,0 +1,34 @@ +#pragma once + +#include "BackendAgentNativeTimerModify.h" + +namespace vdrsuite::agent +{ +struct BackendAgentNativeTimerModifyPayload +{ + BackendAgentNativeTimerModifyKind kind = BackendAgentNativeTimerModifyKind::update; + std::string operationRevision; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch = 0; + std::string nativeTimerBindingId; + std::string expectedBindingRevision; + std::string backendId; + std::uint64_t backendGeneration = 0; + std::string backendNativeTimerId; + std::string expectedCurrentFingerprint; + std::string expectedSpecificationFingerprint; + BackendAgentNativeTimerCreateSpecification specification; + std::int64_t controlPlaneClaimedAt = 0; + BackendAgentLocalProviderSelection localProviderSelection; +}; +bool backendAgentNativeTimerModifyPayloadValid( + const BackendAgentNativeTimerModifyPayload& payload); +std::string backendAgentNativeTimerModifyPayload( + const BackendAgentNativeTimerModifyPayload& payload); +bool backendAgentNativeTimerModifyParsePayload( + const std::string& encoded, + BackendAgentNativeTimerModifyPayload& payload, + std::string& reasonCode); +} diff --git a/core/agent/src/BackendAgentNativeTimerModify.cpp b/core/agent/src/BackendAgentNativeTimerModify.cpp new file mode 100644 index 000000000..3312dec2b --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerModify.cpp @@ -0,0 +1,102 @@ +#include "BackendAgentNativeTimerModify.h" + +#include "BackendAgentNativeTimerDeleteFingerprint.h" + +#include + +namespace vdrsuite::agent +{ +namespace { +constexpr std::size_t MaxIdentity=192; +bool identity(const std::string& value) { + return !value.empty() && value.size()<=MaxIdentity; +} +bool exactSelection(const BackendAgentNativeTimerModifyCommand& command) { + const auto& s=command.localProviderSelection; + return backendAgentLocalProviderValidSelection(s) && + s.backendId==command.backendId && + s.authorityDomain==kBackendAgentNativeTimerModifyAuthorityDomain && + s.providerId==kBackendAgentNativeTimerModifyProviderId && + s.providerKind==kBackendAgentNativeTimerModifyProviderKind && + s.requiredCapability==backendAgentNativeTimerModifyCapability(command.kind); +} +} + +const char* backendAgentNativeTimerModifyKindName(BackendAgentNativeTimerModifyKind kind) +{ + switch (kind) { + case BackendAgentNativeTimerModifyKind::update: return "update"; + case BackendAgentNativeTimerModifyKind::toggle: return "toggle"; + } + return "invalid"; +} +const char* backendAgentNativeTimerModifyCapability(BackendAgentNativeTimerModifyKind kind) +{ + switch (kind) { + case BackendAgentNativeTimerModifyKind::update: + return kBackendAgentNativeTimerUpdateCapability; + case BackendAgentNativeTimerModifyKind::toggle: + return kBackendAgentNativeTimerToggleCapability; + } + return "invalid"; +} + +bool backendAgentNativeTimerModifyValidCommand( + const BackendAgentNativeTimerModifyCommand& c, std::string& reason) +{ + if (std::string(backendAgentNativeTimerModifyKindName(c.kind))=="invalid" || + !identity(c.commandId) || c.requestFingerprint.empty() || + c.requestFingerprint.size()>512 || !identity(c.operationId) || + !identity(c.operationRevision) || !identity(c.timerAssignmentId) || + !identity(c.expectedAssignmentRevision) || !identity(c.expectedIntentRevision) || + c.assignmentEpoch==0 || !identity(c.nativeTimerBindingId) || + !identity(c.expectedBindingRevision) || !identity(c.backendNativeTimerId) || + !backendAgentNativeTimerDeleteFingerprintTokenValid(c.expectedCurrentFingerprint) || + !backendAgentNativeTimerCreateSpecificationValid(c.specification) || + c.expectedSpecificationFingerprint != + backendAgentNativeTimerCreateSpecificationFingerprint(c.specification) || + !identity(c.jobId) || !identity(c.attemptId) || c.claimEpoch==0 || + !identity(c.backendId) || !identity(c.agentId) || + !identity(c.agentInstanceId) || c.backendGeneration==0 || + c.controlPlaneClaimedAt<=0 || !exactSelection(c)) + { + reason="invalid_native_timer_modify_command"; + return false; + } + reason.clear(); + return true; +} + +bool backendAgentNativeTimerModifyEvidenceMatches( + const BackendAgentNativeTimerModifyEvidence& e, + const BackendAgentNativeTimerModifyCommand& c, std::string& reason) +{ + if (!backendAgentNativeTimerModifyValidCommand(c,reason)) return false; + if (e.commandId!=c.commandId || e.requestFingerprint!=c.requestFingerprint || + e.operationId!=c.operationId || e.operationRevision!=c.operationRevision || + e.jobId!=c.jobId || e.attemptId!=c.attemptId || + e.claimEpoch!=c.claimEpoch || e.backendId!=c.backendId || + e.agentId!=c.agentId || e.agentInstanceId!=c.agentInstanceId || + e.backendGeneration!=c.backendGeneration || + e.providerInstanceEpoch!=c.localProviderSelection.providerInstanceEpoch || + e.localStartingPersistedAt512) + { + reason="native_timer_modify_evidence_mismatch"; + return false; + } + if (e.outcome==BackendAgentNativeTimerModifyOutcomeCategory::rejectedWithoutEffect) { + if (e.dispatchStartedAt!=0) { + reason="native_timer_modify_rejection_has_dispatch"; + return false; + } + } else if (e.dispatchStartedAte.completedAt) { + reason="native_timer_modify_evidence_timing_invalid"; + return false; + } + reason.clear(); + return true; +} +} diff --git a/core/agent/src/BackendAgentNativeTimerModifyExecutor.cpp b/core/agent/src/BackendAgentNativeTimerModifyExecutor.cpp new file mode 100644 index 000000000..8271367b9 --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerModifyExecutor.cpp @@ -0,0 +1,315 @@ +#include "BackendAgentNativeTimerModifyExecutor.h" + +#include +#include + +namespace vdrsuite::agent +{ +namespace +{ + +bool sameCommand( + const BackendAgentNativeTimerModifyCommand& left, + const BackendAgentNativeTimerModifyCommand& right) +{ + return left.commandId == right.commandId && + left.requestFingerprint == right.requestFingerprint && + left.operationId == right.operationId && + left.operationRevision == right.operationRevision && + left.kind == right.kind && + left.timerAssignmentId == right.timerAssignmentId && + left.expectedAssignmentRevision == right.expectedAssignmentRevision && + left.expectedIntentRevision == right.expectedIntentRevision && + left.assignmentEpoch == right.assignmentEpoch && + left.nativeTimerBindingId == right.nativeTimerBindingId && + left.expectedBindingRevision == right.expectedBindingRevision && + left.backendNativeTimerId == right.backendNativeTimerId && + left.expectedCurrentFingerprint == right.expectedCurrentFingerprint && + left.expectedSpecificationFingerprint == right.expectedSpecificationFingerprint && + left.jobId == right.jobId && + left.attemptId == right.attemptId && + left.claimEpoch == right.claimEpoch && + left.backendId == right.backendId && + left.agentId == right.agentId && + left.agentInstanceId == right.agentInstanceId && + left.backendGeneration == right.backendGeneration && + left.controlPlaneClaimedAt == right.controlPlaneClaimedAt && + backendAgentLocalProviderSameFence( + left.localProviderSelection, right.localProviderSelection); +} + +bool sameContext( + const BackendAgentNativeTimerModifyCommand& command, + const BackendAgentNativeTimerModifyExecutorContext& context) +{ + return command.backendId == context.backendId && + command.agentId == context.agentId && + command.agentInstanceId == context.agentInstanceId && + command.backendGeneration == context.backendGeneration; +} + +bool selectedProviderStillCurrent( + const BackendAgentLocalProviderSelection& selection, + const BackendAgentLocalProviderFacts& facts) +{ + return backendAgentLocalProviderValidSelection(selection) && + backendAgentLocalProviderValidFacts(facts) && + facts.available && + selection.providerId == facts.providerId && + selection.providerKind == facts.providerKind && + selection.providerInstanceEpoch == facts.providerInstanceEpoch && + selection.providerGeneration == facts.providerGeneration && + selection.capabilityRevision == facts.capabilityRevision && + std::find( + facts.capabilities.begin(), + facts.capabilities.end(), + selection.requiredCapability) != facts.capabilities.end(); +} + +BackendAgentNativeTimerModifyEvidence evidenceFor( + const BackendAgentNativeTimerModifyCommand& command, + std::int64_t localStartingPersistedAt, + BackendAgentNativeTimerModifyOutcomeCategory outcome, + std::int64_t dispatchStartedAt, + std::int64_t completedAt, + std::string evidenceReference) +{ + BackendAgentNativeTimerModifyEvidence value; + value.commandId = command.commandId; + value.requestFingerprint = command.requestFingerprint; + value.operationId = command.operationId; + value.operationRevision = command.operationRevision; + value.jobId = command.jobId; + value.attemptId = command.attemptId; + value.claimEpoch = command.claimEpoch; + value.backendId = command.backendId; + value.agentId = command.agentId; + value.agentInstanceId = command.agentInstanceId; + value.backendGeneration = command.backendGeneration; + value.providerInstanceEpoch = + command.localProviderSelection.providerInstanceEpoch; + value.localStartingPersistedAt = localStartingPersistedAt; + value.outcome = outcome; + value.dispatchStartedAt = dispatchStartedAt; + value.completedAt = completedAt; + value.evidenceReference = std::move(evidenceReference); + return value; +} + +bool finish( + const BackendAgentNativeTimerModifyLocalState& localState, + BackendAgentNativeTimerModifyOutcomeCategory outcome, + std::int64_t dispatchStartedAt, + std::int64_t completedAt, + const std::string& reference, + const std::string& reason, + BackendAgentNativeTimerModifyEvidence& evidence, + std::string& reasonCode) +{ + BackendAgentNativeTimerModifyEvidence candidate = evidenceFor( + localState.command, + localState.localStartingPersistedAt, + outcome, + dispatchStartedAt, + completedAt, + reference); + std::string validationReason; + if (!backendAgentNativeTimerModifyEvidenceMatches( + candidate, localState.command, validationReason)) + { + reasonCode = "native_timer_modify_executor_evidence_invalid"; + return false; + } + evidence = std::move(candidate); + reasonCode = reason; + return true; +} + +bool rejectWithoutEffect( + const BackendAgentNativeTimerModifyLocalState& localState, + std::int64_t now, + const std::string& reference, + BackendAgentNativeTimerModifyEvidence& evidence, + std::string& reasonCode) +{ + return finish( + localState, + BackendAgentNativeTimerModifyOutcomeCategory::rejectedWithoutEffect, + 0, + std::max(now, localState.localStartingPersistedAt), + reference, + "native_timer_modify_executor_rejected_without_effect", + evidence, + reasonCode); +} + +bool safeEvidenceReference(const std::string& value) +{ + return !value.empty() && ::backendAgentCommandSafeText(value, 512); +} + +} + +bool backendAgentNativeTimerModifyExecuteFreshStartingOnce( + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerModifyLocalState& localState, + const BackendAgentNativeTimerModifyExecutorContext& context, + IBackendAgentNativeTimerModifyTransport& transport, + BackendAgentNativeTimerModifyEvidence& evidence, + std::string& reasonCode) +{ + evidence = {}; + std::string validationReason; + BackendAgentNativeTimerModifyCommand expectedCommand; + if (!backendAgentNativeTimerModifyLocalStateValid( + localState, validationReason) || + localState.phase != BackendAgentNativeTimerModifyLocalPhase::starting || + !backendAgentNativeTimerModifyCommandFromAssignment( + assignment, expectedCommand, validationReason) || + !sameCommand(expectedCommand, localState.command) || + context.now <= 0 || + context.now < localState.localStartingPersistedAt) + { + reasonCode = "native_timer_modify_executor_fresh_state_invalid"; + return false; + } + + if (!sameContext(localState.command, context)) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:agent-generation", + evidence, + reasonCode); + } + + if (assignment.deadline <= context.now) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:deadline", + evidence, + reasonCode); + } + + BackendAgentLocalProviderFacts facts; + std::string discoveryReason; + try + { + if (!transport.discoverProvider(facts, discoveryReason)) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:provider-discovery", + evidence, + reasonCode); + } + } + catch (...) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:provider-discovery-exception", + evidence, + reasonCode); + } + + if (!selectedProviderStillCurrent( + localState.command.localProviderSelection, facts)) + { + return rejectWithoutEffect( + localState, + context.now, + "executor-fence:provider-changed", + evidence, + reasonCode); + } + + BackendAgentNativeTimerModifyTransportRequest request; + request.command = localState.command; + request.localStartingPersistedAt = localState.localStartingPersistedAt; + + const std::int64_t dispatchStartedAt = context.now; + BackendAgentNativeTimerModifyTransportReply reply; + try + { + reply = transport.modifyTimer(request); + } + catch (...) + { + return finish( + localState, + BackendAgentNativeTimerModifyOutcomeCategory::outcomeUnknown, + dispatchStartedAt, + context.now, + "executor:dispatch-exception", + "native_timer_modify_executor_outcome_unknown", + evidence, + reasonCode); + } + + switch (reply.disposition) + { + case BackendAgentNativeTimerModifyTransportDisposition::rejectedWithoutEffect: + return rejectWithoutEffect( + localState, + context.now, + safeEvidenceReference(reply.evidenceReference) + ? reply.evidenceReference + : "executor:transport-rejected", + evidence, + reasonCode); + + case BackendAgentNativeTimerModifyTransportDisposition::acceptedUnverified: + if (!safeEvidenceReference(reply.evidenceReference)) + { + return finish( + localState, + BackendAgentNativeTimerModifyOutcomeCategory::outcomeUnknown, + dispatchStartedAt, + context.now, + "executor:accepted-evidence-invalid", + "native_timer_modify_executor_outcome_unknown", + evidence, + reasonCode); + } + return finish( + localState, + BackendAgentNativeTimerModifyOutcomeCategory::acceptedUnverified, + dispatchStartedAt, + context.now, + reply.evidenceReference, + "native_timer_modify_executor_accepted_unverified", + evidence, + reasonCode); + + case BackendAgentNativeTimerModifyTransportDisposition::outcomeUnknown: + return finish( + localState, + BackendAgentNativeTimerModifyOutcomeCategory::outcomeUnknown, + dispatchStartedAt, + context.now, + safeEvidenceReference(reply.evidenceReference) + ? reply.evidenceReference + : "executor:transport-outcome-unknown", + "native_timer_modify_executor_outcome_unknown", + evidence, + reasonCode); + } + + return finish( + localState, + BackendAgentNativeTimerModifyOutcomeCategory::outcomeUnknown, + dispatchStartedAt, + context.now, + "executor:transport-disposition-invalid", + "native_timer_modify_executor_outcome_unknown", + evidence, + reasonCode); +} + +} diff --git a/core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp b/core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp new file mode 100644 index 000000000..0ebeceeb2 --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp @@ -0,0 +1,211 @@ +#include "BackendAgentNativeTimerModifyLocalState.h" + +#include "BackendAgentNativeTimerModifyPayload.h" + +#include +#include +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace { +constexpr std::size_t MaxBytes=40U*1024U; +char hexDigit(unsigned n){return n<10?static_cast('0'+n):static_cast('a'+n-10);} +std::string hex(const std::string& value){std::string out;out.reserve(value.size()*2); + for(unsigned char c:value){out.push_back(hexDigit(c>>4));out.push_back(hexDigit(c&15));}return out;} +int hexValue(char c){if(c>='0'&&c<='9')return c-'0';if(c>='a'&&c<='f')return c-'a'+10;return -1;} +bool unhex(const std::string& in,std::string& out){out.clear();if(in.size()%2)return false; + for(std::size_t i=0;i((h<<4)|l));}return true;} +bool number(const std::string& s,std::uint64_t& n){if(s.empty())return false;n=0; + for(char c:s){if(c<'0'||c>'9')return false;auto d=static_cast(c-'0'); + if(n>(static_cast(std::numeric_limits::max())-d)/10)return false;n=n*10+d;}return true;} +const std::vector& keys(){static const std::vector k={ + "schema","phase","command_id","request_fingerprint","operation_id","payload_hex", + "job_id","attempt_id","claim_epoch","agent_id","agent_instance_id", + "local_starting_persisted_at","outcome","dispatch_started_at","completed_at", + "evidence_reference"};return k;} +bool exact(const std::map& v){if(v.size()!=keys().size())return false; + for(const auto& k:keys())if(v.count(k)!=1)return false;return true;} +const char* outcome(BackendAgentNativeTimerModifyOutcomeCategory o){switch(o){ + case BackendAgentNativeTimerModifyOutcomeCategory::rejectedWithoutEffect:return "rejected_without_effect"; + case BackendAgentNativeTimerModifyOutcomeCategory::acceptedUnverified:return "accepted_unverified"; + case BackendAgentNativeTimerModifyOutcomeCategory::outcomeUnknown:return "outcome_unknown";}return "invalid";} +bool parseOutcome(const std::string& s,BackendAgentNativeTimerModifyOutcomeCategory& o){ + if(s=="rejected_without_effect")o=BackendAgentNativeTimerModifyOutcomeCategory::rejectedWithoutEffect; + else if(s=="accepted_unverified")o=BackendAgentNativeTimerModifyOutcomeCategory::acceptedUnverified; + else if(s=="outcome_unknown")o=BackendAgentNativeTimerModifyOutcomeCategory::outcomeUnknown; + else return false;return true;} +bool emptyEvidence(const BackendAgentNativeTimerModifyEvidence& e){ + return e.commandId.empty()&&e.evidenceReference.empty()&&e.completedAt==0&& + e.localStartingPersistedAt==0&&e.dispatchStartedAt==0;} +BackendAgentNativeTimerModifyPayload payloadFor(const BackendAgentNativeTimerModifyCommand& c){ + BackendAgentNativeTimerModifyPayload p; p.kind=c.kind;p.operationRevision=c.operationRevision; + p.timerAssignmentId=c.timerAssignmentId;p.expectedAssignmentRevision=c.expectedAssignmentRevision; + p.expectedIntentRevision=c.expectedIntentRevision;p.assignmentEpoch=c.assignmentEpoch; + p.nativeTimerBindingId=c.nativeTimerBindingId;p.expectedBindingRevision=c.expectedBindingRevision; + p.backendId=c.backendId;p.backendGeneration=c.backendGeneration; + p.backendNativeTimerId=c.backendNativeTimerId;p.expectedCurrentFingerprint=c.expectedCurrentFingerprint; + p.expectedSpecificationFingerprint=c.expectedSpecificationFingerprint;p.specification=c.specification; + p.controlPlaneClaimedAt=c.controlPlaneClaimedAt;p.localProviderSelection=c.localProviderSelection;return p;} +BackendAgentNativeTimerModifyCommand commandFor( + const std::string& commandId,const std::string& fingerprint,const std::string& operationId, + const std::string& job,const std::string& attempt,std::uint64_t claim, + const std::string& agent,const std::string& instance, + const BackendAgentNativeTimerModifyPayload& p){ + BackendAgentNativeTimerModifyCommand c;c.kind=p.kind;c.commandId=commandId; + c.requestFingerprint=fingerprint;c.operationId=operationId;c.operationRevision=p.operationRevision; + c.timerAssignmentId=p.timerAssignmentId;c.expectedAssignmentRevision=p.expectedAssignmentRevision; + c.expectedIntentRevision=p.expectedIntentRevision;c.assignmentEpoch=p.assignmentEpoch; + c.nativeTimerBindingId=p.nativeTimerBindingId;c.expectedBindingRevision=p.expectedBindingRevision; + c.backendNativeTimerId=p.backendNativeTimerId;c.expectedCurrentFingerprint=p.expectedCurrentFingerprint; + c.expectedSpecificationFingerprint=p.expectedSpecificationFingerprint;c.specification=p.specification; + c.jobId=job;c.attemptId=attempt;c.claimEpoch=claim;c.backendId=p.backendId;c.agentId=agent; + c.agentInstanceId=instance;c.backendGeneration=p.backendGeneration; + c.controlPlaneClaimedAt=p.controlPlaneClaimedAt;c.localProviderSelection=p.localProviderSelection;return c;} +BackendAgentNativeTimerModifyEvidence evidenceFor(const BackendAgentNativeTimerModifyCommand& c, + std::int64_t start,BackendAgentNativeTimerModifyOutcomeCategory o,std::int64_t dispatch, + std::int64_t completed,const std::string& ref){ + BackendAgentNativeTimerModifyEvidence e;e.commandId=c.commandId;e.requestFingerprint=c.requestFingerprint; + e.operationId=c.operationId;e.operationRevision=c.operationRevision;e.jobId=c.jobId;e.attemptId=c.attemptId; + e.claimEpoch=c.claimEpoch;e.backendId=c.backendId;e.agentId=c.agentId;e.agentInstanceId=c.agentInstanceId; + e.backendGeneration=c.backendGeneration;e.providerInstanceEpoch=c.localProviderSelection.providerInstanceEpoch; + e.localStartingPersistedAt=start;e.outcome=o;e.dispatchStartedAt=dispatch;e.completedAt=completed; + e.evidenceReference=ref;return e;} +} + +bool backendAgentNativeTimerModifyCommandFromAssignment( + const BackendAgentCommandAssignment& a,BackendAgentNativeTimerModifyCommand& command,std::string& reason){ + if(!::backendAgentCommandValidAssignment(a)|| + (a.commandType!=kBackendAgentNativeTimerUpdateCommandType&& + a.commandType!=kBackendAgentNativeTimerToggleCommandType)|| + a.payloadVersion!=kBackendAgentNativeTimerModifyPayloadVersion|| + a.verificationPolicy!="readback_required"){reason="invalid_native_timer_modify_assignment";return false;} + BackendAgentNativeTimerModifyPayload p; + if(!backendAgentNativeTimerModifyParsePayload(a.payload,p,reason)|| + (a.commandType==kBackendAgentNativeTimerUpdateCommandType)!=(p.kind==BackendAgentNativeTimerModifyKind::update)|| + p.backendId!=a.backendId||p.backendGeneration!=a.backendGeneration){ + reason="invalid_native_timer_modify_assignment_payload";return false;} + auto c=commandFor(a.commandId,a.requestFingerprint,a.operationId,a.jobId,a.attemptId, + a.claimEpoch,a.agentId,a.agentInstanceId,p); + if(!backendAgentNativeTimerModifyValidCommand(c,reason)){ + reason="invalid_native_timer_modify_command_envelope";return false;} + command=std::move(c);reason.clear();return true; +} + +bool backendAgentNativeTimerModifyPrepareLocalStarting( + const BackendAgentCommandAssignment& a,std::int64_t now, + BackendAgentNativeTimerModifyLocalState& state,std::string& reason){ + BackendAgentNativeTimerModifyCommand c; + if(!backendAgentNativeTimerModifyCommandFromAssignment(a,c,reason))return false; + if(nowa.deadline){reason="native_timer_modify_starting_time_fenced";return false;} + BackendAgentNativeTimerModifyLocalState s;s.command=std::move(c);s.localStartingPersistedAt=now; + if(!backendAgentNativeTimerModifyLocalStateValid(s,reason))return false; + state=std::move(s);reason="native_timer_modify_starting_ready_for_durable_persist";return true; +} + +bool backendAgentNativeTimerModifyLocalStateValid( + const BackendAgentNativeTimerModifyLocalState& s,std::string& reason){ + if(s.schemaVersion!=1||!backendAgentNativeTimerModifyValidCommand(s.command,reason)|| + s.localStartingPersistedAtMaxBytes){reason="native_timer_modify_state_too_large";return {};} + reason.clear();return encoded; +} + +bool backendAgentNativeTimerModifyParseLocalState( + const std::string& encoded,BackendAgentNativeTimerModifyLocalState& state,std::string& reason){ + if(encoded.empty()||encoded.size()>MaxBytes){reason="invalid_native_timer_modify_state";return false;} + std::map v;std::istringstream in(encoded);std::string line; + while(std::getline(in,line)){auto p=line.find('=');if(p==std::string::npos||p==0|| + !v.emplace(line.substr(0,p),line.substr(p+1)).second){reason="invalid_native_timer_modify_state";return false;}} + if(!exact(v)||v["schema"]!="1"){reason="invalid_native_timer_modify_state_schema";return false;} + BackendAgentNativeTimerModifyLocalState s; + if(v["phase"]=="completed")s.phase=BackendAgentNativeTimerModifyLocalPhase::completed; + else if(v["phase"]!="starting"){reason="invalid_native_timer_modify_state_phase";return false;} + std::string payloadText;BackendAgentNativeTimerModifyPayload p; + std::uint64_t claim=0,start=0,dispatch=0,completed=0; + if(!unhex(v["payload_hex"],payloadText)|| + !backendAgentNativeTimerModifyParsePayload(payloadText,p,reason)|| + !number(v["claim_epoch"],claim)||!number(v["local_starting_persisted_at"],start)|| + !number(v["dispatch_started_at"],dispatch)||!number(v["completed_at"],completed)){ + reason="invalid_native_timer_modify_state_encoding";return false;} + s.command=commandFor(v["command_id"],v["request_fingerprint"],v["operation_id"], + v["job_id"],v["attempt_id"],claim,v["agent_id"],v["agent_instance_id"],p); + s.localStartingPersistedAt=static_cast(start); + if(s.phase==BackendAgentNativeTimerModifyLocalPhase::starting){ + if(v["outcome"]!="none"||dispatch||completed||!v["evidence_reference"].empty()){ + reason="invalid_native_timer_modify_starting_encoding";return false;} + }else{ + BackendAgentNativeTimerModifyOutcomeCategory o;if(!parseOutcome(v["outcome"],o)){ + reason="invalid_native_timer_modify_outcome";return false;} + s.evidence=evidenceFor(s.command,s.localStartingPersistedAt,o, + static_cast(dispatch),static_cast(completed),v["evidence_reference"]); + } + if(!backendAgentNativeTimerModifyLocalStateValid(s,reason))return false; + state=std::move(s);reason.clear();return true; +} +} diff --git a/core/agent/src/BackendAgentNativeTimerModifyPayload.cpp b/core/agent/src/BackendAgentNativeTimerModifyPayload.cpp new file mode 100644 index 000000000..33c2fef3a --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerModifyPayload.cpp @@ -0,0 +1,156 @@ +#include "BackendAgentNativeTimerModifyPayload.h" + +#include "BackendAgentCommand.h" + +#include +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace { +constexpr const char* Prefix="native-timer-modify-agent-payload/1|"; +constexpr std::size_t Count=34; +void append(std::string& out,const std::string& value) { + out+=std::to_string(value.size())+":"+value+"|"; +} +void append(std::string& out,std::uint64_t value) { append(out,std::to_string(value)); } +void append(std::string& out,std::int32_t value) { append(out,std::to_string(value)); } +void append(std::string& out,bool value) { append(out,value?"1":"0"); } +bool read(const std::string& in,std::size_t& pos,std::string& value) { + std::size_t length=0; bool seen=false; + while(pos'9') return false; seen=true; + const std::size_t d=static_cast(c-'0'); + if(length>(std::numeric_limits::max()-d)/10) return false; + length=length*10+d; + } + if(!seen||pos>=in.size()||in[pos++]!=':'||length>in.size()-pos) return false; + value=in.substr(pos,length); pos+=length; + if(pos>=in.size()||in[pos++]!='|') return false; + return true; +} +bool number(const std::string& token,std::uint64_t& value,bool positive=true) { + if(token.empty()) return false; value=0; + for(char c:token) { if(c<'0'||c>'9') return false; + const auto d=static_cast(c-'0'); + if(value>(std::numeric_limits::max()-d)/10) return false; + value=value*10+d; + } + return !positive||value>0; +} +bool small(const std::string& token,std::int32_t& value) { + std::uint64_t n=0; if(!number(token,n,false)||n>99) return false; + value=static_cast(n); return true; +} +bool boolean(const std::string& token,bool& value) { + if(token=="0"){value=false;return true;} if(token=="1"){value=true;return true;} + return false; +} +std::string hhmm(const std::string& value) { + return std::string(4-value.size(),'0')+value; +} +} + +bool backendAgentNativeTimerModifyPayloadValid( + const BackendAgentNativeTimerModifyPayload& p) +{ + return backendAgentCommandSafeIdentifier(p.operationRevision) && + backendAgentCommandSafeIdentifier(p.timerAssignmentId) && + backendAgentCommandSafeIdentifier(p.expectedAssignmentRevision) && + backendAgentCommandSafeIdentifier(p.expectedIntentRevision) && + p.assignmentEpoch>0 && backendAgentCommandSafeIdentifier(p.nativeTimerBindingId) && + backendAgentCommandSafeIdentifier(p.expectedBindingRevision) && + backendAgentCommandSafeIdentifier(p.backendId) && p.backendGeneration>0 && + backendAgentCommandSafeIdentifier(p.backendNativeTimerId) && + !p.expectedCurrentFingerprint.empty() && + p.expectedSpecificationFingerprint== + backendAgentNativeTimerCreateSpecificationFingerprint(p.specification) && + p.controlPlaneClaimedAt>0 && + backendAgentLocalProviderValidSelection(p.localProviderSelection) && + p.localProviderSelection.backendId==p.backendId && + p.localProviderSelection.authorityDomain==kBackendAgentNativeTimerModifyAuthorityDomain && + p.localProviderSelection.providerId==kBackendAgentNativeTimerModifyProviderId && + p.localProviderSelection.providerKind==kBackendAgentNativeTimerModifyProviderKind && + p.localProviderSelection.requiredCapability== + backendAgentNativeTimerModifyCapability(p.kind); +} + +std::string backendAgentNativeTimerModifyPayload( + const BackendAgentNativeTimerModifyPayload& p) +{ + if(!backendAgentNativeTimerModifyPayloadValid(p)) return {}; + std::string out(Prefix); + append(out,backendAgentNativeTimerModifyKindName(p.kind)); + append(out,p.operationRevision); append(out,p.timerAssignmentId); + append(out,p.expectedAssignmentRevision); append(out,p.expectedIntentRevision); + append(out,p.assignmentEpoch); append(out,p.nativeTimerBindingId); + append(out,p.expectedBindingRevision); append(out,p.backendId); + append(out,p.backendGeneration); append(out,p.backendNativeTimerId); + append(out,p.expectedCurrentFingerprint); append(out,p.expectedSpecificationFingerprint); + append(out,static_cast(p.controlPlaneClaimedAt)); + const auto& s=p.specification; + append(out,s.channelId); append(out,s.title); append(out,s.directory); append(out,s.day); + append(out,s.weekdays); append(out,hhmm(s.startTime)); append(out,hhmm(s.endTime)); + append(out,s.priority); append(out,s.lifetime); append(out,s.enabled); append(out,s.vps); + const auto& q=p.localProviderSelection; + append(out,q.backendId); append(out,q.authorityDomain); append(out,q.providerId); + append(out,q.providerKind); append(out,q.ownershipGeneration); + append(out,q.providerInstanceEpoch); append(out,q.providerGeneration); + append(out,q.capabilityRevision); append(out,q.requiredCapability); + return out; +} + +bool backendAgentNativeTimerModifyParsePayload( + const std::string& encoded, BackendAgentNativeTimerModifyPayload& payload, + std::string& reason) +{ + const std::string prefix(Prefix); + if(encoded.compare(0,prefix.size(),prefix)!=0) { + reason="invalid_native_timer_modify_payload"; return false; + } + std::size_t pos=prefix.size(); std::vector f; + while(pos=Count) { + reason="invalid_native_timer_modify_payload"; return false; + } + f.push_back(std::move(value)); + } + if(f.size()!=Count) { reason="invalid_native_timer_modify_payload"; return false; } + BackendAgentNativeTimerModifyPayload p; + if(f[0]=="update") p.kind=BackendAgentNativeTimerModifyKind::update; + else if(f[0]=="toggle") p.kind=BackendAgentNativeTimerModifyKind::toggle; + else { reason="invalid_native_timer_modify_payload"; return false; } + p.operationRevision=f[1]; p.timerAssignmentId=f[2]; + p.expectedAssignmentRevision=f[3]; p.expectedIntentRevision=f[4]; + p.nativeTimerBindingId=f[6]; p.expectedBindingRevision=f[7]; p.backendId=f[8]; + p.backendNativeTimerId=f[10]; p.expectedCurrentFingerprint=f[11]; + p.expectedSpecificationFingerprint=f[12]; + std::uint64_t claimed=0; + if(!number(f[5],p.assignmentEpoch)||!number(f[9],p.backendGeneration)|| + !number(f[13],claimed)|| + claimed>static_cast(std::numeric_limits::max())) { + reason="invalid_native_timer_modify_payload"; return false; + } + p.controlPlaneClaimedAt=static_cast(claimed); + auto& s=p.specification; + s.channelId=f[14];s.title=f[15];s.directory=f[16];s.day=f[17]; + s.weekdays=f[18];s.startTime=f[19];s.endTime=f[20]; + if(!small(f[21],s.priority)||!small(f[22],s.lifetime)|| + !boolean(f[23],s.enabled)||!boolean(f[24],s.vps)) { + reason="invalid_native_timer_modify_payload"; return false; + } + auto& q=p.localProviderSelection; + q.backendId=f[25];q.authorityDomain=f[26];q.providerId=f[27];q.providerKind=f[28]; + q.providerInstanceEpoch=f[30];q.requiredCapability=f[33]; + if(!number(f[29],q.ownershipGeneration)||!number(f[31],q.providerGeneration)|| + !number(f[32],q.capabilityRevision)|| + !backendAgentNativeTimerModifyPayloadValid(p)|| + backendAgentNativeTimerModifyPayload(p)!=encoded) { + reason="invalid_native_timer_modify_payload"; return false; + } + payload=std::move(p); reason.clear(); return true; +} +} diff --git a/core/agent/tests/test_backend_agent_native_timer_modify.cpp b/core/agent/tests/test_backend_agent_native_timer_modify.cpp new file mode 100644 index 000000000..6808b4ed7 --- /dev/null +++ b/core/agent/tests/test_backend_agent_native_timer_modify.cpp @@ -0,0 +1,66 @@ +#include "../include/BackendAgentNativeTimerModifyExecutor.h" +#include "../include/BackendAgentNativeTimerModifyPayload.h" + +#include +#include + +using namespace vdrsuite::agent; +namespace { +void require(bool v,const char* m){if(!v){std::cerr< Date: Sun, 16 Aug 2026 23:28:58 +0200 Subject: [PATCH 51/85] Wire native Timer UPDATE and TOGGLE through the Backend Agent --- apps/agent/main.cpp | 8 + core/agent/include/BackendAgentClient.h | 3 + .../agent/include/BackendAgentCommandClient.h | 2 + .../BackendAgentCommandStateExtension.h | 14 + ...kendAgentNativeTimerModifyCommandHandler.h | 39 +++ .../SuiteBridgeNativeTimerModifyTransport.h | 22 ++ .../agent/include/SuiteBridgeSvdrpTransport.h | 7 + core/agent/src/BackendAgentClient.cpp | 3 + core/agent/src/BackendAgentCommandClient.cpp | 88 ++++- .../src/BackendAgentCommandStateExtension.cpp | 107 +++++++ ...ndAgentNativeTimerModifyCommandHandler.cpp | 301 ++++++++++++++++++ ...eBridgeSvdrpNativeTimerModifyTransport.cpp | 131 ++++++++ mk/agent-sources.mk | 14 + mk/backend-agent-runtime.mk | 1 + ...-native-timer-modify-command-path-tests.mk | 4 + ...hase64_native_timer_modify_command_path.py | 18 ++ 16 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 core/agent/include/BackendAgentNativeTimerModifyCommandHandler.h create mode 100644 core/agent/include/SuiteBridgeNativeTimerModifyTransport.h create mode 100644 core/agent/src/BackendAgentNativeTimerModifyCommandHandler.cpp create mode 100644 core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp create mode 100644 mk/phase64-native-timer-modify-command-path-tests.mk create mode 100755 tools/check_phase64_native_timer_modify_command_path.py diff --git a/apps/agent/main.cpp b/apps/agent/main.cpp index 7d88f7f43..50740f228 100644 --- a/apps/agent/main.cpp +++ b/apps/agent/main.cpp @@ -2,6 +2,7 @@ #include "BackendAgentCommandClient.h" #include "SuiteBridgeNativeTimerCreateTransport.h" #include "SuiteBridgeNativeTimerDeleteTransport.h" +#include "SuiteBridgeNativeTimerModifyTransport.h" #include "SuiteBridgeSvdrpTransport.h" #include @@ -126,6 +127,8 @@ int main(int argc, char** argv) nativeTimerCreateTransport; std::unique_ptr nativeTimerDeleteTransport; + std::unique_ptr + nativeTimerModifyTransport; if (!config.suiteBridgeHost.empty()) { vdrsuite::agent::SuiteBridgeSvdrpTransportConfig timerTransportConfig; @@ -137,10 +140,15 @@ int main(int argc, char** argv) nativeTimerDeleteTransport = std::make_unique< vdrsuite::agent::SuiteBridgeNativeTimerDeleteTransport>( timerTransportConfig); + nativeTimerModifyTransport = std::make_unique< + vdrsuite::agent::SuiteBridgeNativeTimerModifyTransport>( + timerTransportConfig); config.nativeTimerCreateTransport = nativeTimerCreateTransport.get(); config.nativeTimerDeleteTransport = nativeTimerDeleteTransport.get(); + config.nativeTimerModifyTransport = + nativeTimerModifyTransport.get(); } std::unique_ptr nativeTransport; diff --git a/core/agent/include/BackendAgentClient.h b/core/agent/include/BackendAgentClient.h index 594e006b3..c165612e8 100644 --- a/core/agent/include/BackendAgentClient.h +++ b/core/agent/include/BackendAgentClient.h @@ -10,6 +10,7 @@ namespace vdrsuite::agent { class IBackendAgentNativeTimerCreateTransport; class IBackendAgentNativeTimerDeleteTransport; +class IBackendAgentNativeTimerModifyTransport; } struct BackendAgentClientConfig @@ -31,6 +32,8 @@ struct BackendAgentClientConfig nativeTimerCreateTransport = nullptr; vdrsuite::agent::IBackendAgentNativeTimerDeleteTransport* nativeTimerDeleteTransport = nullptr; + vdrsuite::agent::IBackendAgentNativeTimerModifyTransport* + nativeTimerModifyTransport = nullptr; int heartbeatIntervalSeconds = 30; int reconnectInitialSeconds = 1; int reconnectMaximumSeconds = 30; diff --git a/core/agent/include/BackendAgentCommandClient.h b/core/agent/include/BackendAgentCommandClient.h index 35aa149f0..65d5ecee2 100644 --- a/core/agent/include/BackendAgentCommandClient.h +++ b/core/agent/include/BackendAgentCommandClient.h @@ -12,6 +12,7 @@ namespace vdrsuite::agent class IBackendAgentNativeProbeTransport; class IBackendAgentNativeTimerDeleteTransport; class IBackendAgentNativeTimerCreateTransport; +class IBackendAgentNativeTimerModifyTransport; } struct BackendAgentCommandClientConfig @@ -21,6 +22,7 @@ struct BackendAgentCommandClientConfig vdrsuite::agent::IBackendAgentNativeProbeTransport* nativeProbeTransport = nullptr; vdrsuite::agent::IBackendAgentNativeTimerDeleteTransport* nativeTimerDeleteTransport = nullptr; vdrsuite::agent::IBackendAgentNativeTimerCreateTransport* nativeTimerCreateTransport = nullptr; + vdrsuite::agent::IBackendAgentNativeTimerModifyTransport* nativeTimerModifyTransport = nullptr; }; struct BackendAgentCommandClientContext diff --git a/core/agent/include/BackendAgentCommandStateExtension.h b/core/agent/include/BackendAgentCommandStateExtension.h index 7e42106b2..357e25145 100644 --- a/core/agent/include/BackendAgentCommandStateExtension.h +++ b/core/agent/include/BackendAgentCommandStateExtension.h @@ -3,6 +3,7 @@ #include "BackendAgentCommand.h" #include "BackendAgentNativeTimerCreateLocalState.h" #include "BackendAgentNativeTimerDeleteLocalState.h" +#include "BackendAgentNativeTimerModifyLocalState.h" #include #include @@ -14,6 +15,8 @@ inline constexpr const char* kBackendAgentNativeTimerCreateLocalStateExtensionTy "vdr.timer.create.local-state.v1"; inline constexpr const char* kBackendAgentNativeTimerDeleteLocalStateExtensionType = "vdr.timer.delete.local-state.v1"; +inline constexpr const char* kBackendAgentNativeTimerModifyLocalStateExtensionType = + "vdr.timer.modify.local-state.v1"; struct BackendAgentCommandStateExtension { @@ -67,4 +70,15 @@ bool backendAgentNativeTimerDeleteParseCommandStateExtension( BackendAgentNativeTimerDeleteLocalState& state, std::string& reasonCode); +std::string backendAgentNativeTimerModifyCommandStateExtension( + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerModifyLocalState& state, + std::string& reasonCode); + +bool backendAgentNativeTimerModifyParseCommandStateExtension( + const std::string& encoded, + const BackendAgentCommandAssignment& assignment, + BackendAgentNativeTimerModifyLocalState& state, + std::string& reasonCode); + } diff --git a/core/agent/include/BackendAgentNativeTimerModifyCommandHandler.h b/core/agent/include/BackendAgentNativeTimerModifyCommandHandler.h new file mode 100644 index 000000000..e07b83485 --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerModifyCommandHandler.h @@ -0,0 +1,39 @@ +#pragma once + +#include "BackendAgentCommandStateStore.h" +#include "BackendAgentNativeTimerModifyExecutor.h" + +#include +#include + +namespace vdrsuite::agent +{ + +struct BackendAgentNativeTimerModifyCommandContext +{ + std::string backendId; + std::string agentId; + std::string agentInstanceId; + std::uint64_t backendGeneration = 0; +}; + +bool backendAgentNativeTimerModifyCommandReconcileExisting( + const std::string& statePath, + const BackendAgentNativeTimerModifyCommandContext& context, + commandstate::LocalState& state, + std::string& reasonCode); + +bool backendAgentNativeTimerModifyCommandPrepareFreshStarting( + const std::string& statePath, + commandstate::LocalState& state, + std::int64_t currentTime, + std::string& reasonCode); + +bool backendAgentNativeTimerModifyCommandExecuteFreshStartingAndPersistOutcome( + const std::string& statePath, + const BackendAgentNativeTimerModifyCommandContext& context, + IBackendAgentNativeTimerModifyTransport* transport, + commandstate::LocalState& state, + std::string& reasonCode); + +} diff --git a/core/agent/include/SuiteBridgeNativeTimerModifyTransport.h b/core/agent/include/SuiteBridgeNativeTimerModifyTransport.h new file mode 100644 index 000000000..c56c66a36 --- /dev/null +++ b/core/agent/include/SuiteBridgeNativeTimerModifyTransport.h @@ -0,0 +1,22 @@ +#pragma once + +#include "BackendAgentNativeTimerModifyExecutor.h" +#include "SuiteBridgeSvdrpTransport.h" + +namespace vdrsuite::agent +{ +class SuiteBridgeNativeTimerModifyTransport final + : public IBackendAgentNativeTimerModifyTransport +{ +public: + explicit SuiteBridgeNativeTimerModifyTransport( + SuiteBridgeSvdrpTransportConfig config = {}); + bool discoverProvider( + BackendAgentLocalProviderFacts& facts, + std::string& reasonCode) override; + BackendAgentNativeTimerModifyTransportReply modifyTimer( + const BackendAgentNativeTimerModifyTransportRequest& request) override; +private: + SuiteBridgeSvdrpTransport transport_; +}; +} diff --git a/core/agent/include/SuiteBridgeSvdrpTransport.h b/core/agent/include/SuiteBridgeSvdrpTransport.h index 3e1e4fddf..5d215cba9 100644 --- a/core/agent/include/SuiteBridgeSvdrpTransport.h +++ b/core/agent/include/SuiteBridgeSvdrpTransport.h @@ -20,6 +20,8 @@ namespace vdrsuite::agent struct BackendAgentNativeTimerCreateTransportRequest; struct BackendAgentNativeTimerDeleteTransportRequest; +struct BackendAgentNativeTimerModifyTransportRequest; +enum class BackendAgentNativeTimerModifyKind; struct SuiteBridgeSvdrpTransportConfig { @@ -132,6 +134,11 @@ class SuiteBridgeSvdrpTransport final : SuiteBridgeCommandReply executeNativeTimerDeleteContract( const BackendAgentNativeTimerDeleteTransportRequest& request); + SuiteBridgeCommandReply discoverNativeTimerModifyContract( + BackendAgentNativeTimerModifyKind kind); + SuiteBridgeCommandReply executeNativeTimerModifyContract( + const BackendAgentNativeTimerModifyTransportRequest& request); + private: static bool safeNativeToken(const std::string& value) { diff --git a/core/agent/src/BackendAgentClient.cpp b/core/agent/src/BackendAgentClient.cpp index e38e36ac3..5283847a9 100644 --- a/core/agent/src/BackendAgentClient.cpp +++ b/core/agent/src/BackendAgentClient.cpp @@ -708,6 +708,7 @@ bool BackendAgentClientRuntime::loadConfig( config.suiteBridgePort = 0; config.nativeTimerCreateTransport = nullptr; config.nativeTimerDeleteTransport = nullptr; + config.nativeTimerModifyTransport = nullptr; if (!values["SUITEBRIDGE_PORT"].empty() && !strictInt(values["SUITEBRIDGE_PORT"], 1, 65535, config.suiteBridgePort)) @@ -1914,6 +1915,8 @@ bool BackendAgentClientRuntime::heartbeat(std::string& reasonCode) config_.nativeTimerCreateTransport; commandConfig.nativeTimerDeleteTransport = config_.nativeTimerDeleteTransport; + commandConfig.nativeTimerModifyTransport = + config_.nativeTimerModifyTransport; BackendAgentCommandClientContext commandContext{state_.agentId, state_.credentialSecret, state_.backendId, agentInstanceId_, state_.backendGeneration}; if (!reconcileBackendAgentCommandState(commandConfig, commandContext, transport_, reasonCode)) { diff --git a/core/agent/src/BackendAgentCommandClient.cpp b/core/agent/src/BackendAgentCommandClient.cpp index 5abba9cf8..595dbc6ee 100644 --- a/core/agent/src/BackendAgentCommandClient.cpp +++ b/core/agent/src/BackendAgentCommandClient.cpp @@ -7,6 +7,7 @@ #include "BackendAgentNativeTimerCreate.h" #include "BackendAgentNativeTimerCreateCommandHandler.h" #include "BackendAgentNativeTimerDeleteCommandHandler.h" +#include "BackendAgentNativeTimerModifyCommandHandler.h" #include #include @@ -215,6 +216,39 @@ bool executeFreshNativeTimerDeleteAndPersistOutcome( reason); } + +vdrsuite::agent::BackendAgentNativeTimerModifyCommandContext +nativeTimerModifyContext(const BackendAgentCommandClientContext& context) +{ + return {context.backendId, context.agentId, context.agentInstanceId, + context.backendGeneration}; +} +bool reconcileNativeTimerModifyLocalState( + const BackendAgentCommandClientConfig& config, + const BackendAgentCommandClientContext& context, + LocalState& state, std::string& reason) +{ + return vdrsuite::agent::backendAgentNativeTimerModifyCommandReconcileExisting( + config.statePath, nativeTimerModifyContext(context), state, reason); +} +bool prepareFreshNativeTimerModifyLocalStarting( + const BackendAgentCommandClientConfig& config, LocalState& state, + std::int64_t currentTime, std::string& reason) +{ + return vdrsuite::agent::backendAgentNativeTimerModifyCommandPrepareFreshStarting( + config.statePath, state, currentTime, reason); +} +bool executeFreshNativeTimerModifyAndPersistOutcome( + const BackendAgentCommandClientConfig& config, + const BackendAgentCommandClientContext& context, + LocalState& state, std::string& reason) +{ + return vdrsuite::agent:: + backendAgentNativeTimerModifyCommandExecuteFreshStartingAndPersistOutcome( + config.statePath, nativeTimerModifyContext(context), + config.nativeTimerModifyTransport, state, reason); +} + struct CommandAvailability { std::vector commandTypes; @@ -228,7 +262,9 @@ CommandAvailability availableCommands( for (const std::string& type : config.commandTypes) { if (type == vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType || - type == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType) + type == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType || + type == vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType || + type == vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType) continue; if (type != "vdr.native.probe") { @@ -272,12 +308,20 @@ bool reconcileBackendAgentCommandState( vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType; const bool timerDeleteCommand = state.assignment.commandType == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType; + const bool timerModifyCommand = + state.assignment.commandType == + vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType || + state.assignment.commandType == + vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType; if (timerCreateCommand && state.stateExtensionPresent && !reconcileNativeTimerCreateLocalState(config, context, state, reason)) return false; if (timerDeleteCommand && state.stateExtensionPresent && !reconcileNativeTimerDeleteLocalState(config, context, state, reason)) return false; + if (timerModifyCommand && state.stateExtensionPresent && + !reconcileNativeTimerModifyLocalState(config, context, state, reason)) + return false; if (!sameContext(state.assignment, context)) { if (state.resultPresent && state.receiptAcknowledged && @@ -297,9 +341,51 @@ bool reconcileBackendAgentCommandState( const std::int64_t currentTime = nowSeconds(); if (state.assignment.deadline <= currentTime) { + + if (timerModifyCommand && !state.stateExtensionPresent && !state.resultPresent) + { + if (state.dispatchState != "not_started") + { + reason = "native_modify_fresh_starting_state_invalid"; + return false; + } + const std::int64_t currentTime = nowSeconds(); + if (state.assignment.deadline <= currentTime) + { if (!state.receiptAcknowledged && !sendReceipt(config, context, transport, state, reason)) return false; + createResult( + state, "not_started", "outcome_unknown", "rejected", + "expired", "none", "command deadline expired before native dispatch"); + if (!persist(config.statePath, state, reason)) return false; + if (!sendResult(config, context, transport, state, reason)) return false; + reason = "command_result_reconciled"; + return true; + } + if (!prepareFreshNativeTimerModifyLocalStarting( + config, state, currentTime, reason)) + return false; + if (!state.receiptAcknowledged && + !sendReceipt(config, context, transport, state, reason)) + return false; + if (config.nativeTimerModifyTransport == nullptr) + { + reason = "native_modify_local_starting_handoff_persisted"; + return true; + } + if (!executeFreshNativeTimerModifyAndPersistOutcome( + config, context, state, reason)) + return false; + if (!sendResult(config, context, transport, state, reason)) + return false; + reason = "native_modify_executor_outcome_reconciled"; + return true; + } + + if (!state.receiptAcknowledged && + !sendReceipt(config, context, transport, state, reason)) + return false; createResult( state, "not_started", "outcome_unknown", "rejected", "expired", "none", diff --git a/core/agent/src/BackendAgentCommandStateExtension.cpp b/core/agent/src/BackendAgentCommandStateExtension.cpp index 5fea910b1..c4aaf2832 100644 --- a/core/agent/src/BackendAgentCommandStateExtension.cpp +++ b/core/agent/src/BackendAgentCommandStateExtension.cpp @@ -168,6 +168,35 @@ bool sameCommand( sameProviderSelection(left.localProviderSelection, right.localProviderSelection); } + +bool sameCommand( + const BackendAgentNativeTimerModifyCommand& left, + const BackendAgentNativeTimerModifyCommand& right) +{ + return left.kind == right.kind && + left.commandId == right.commandId && + left.requestFingerprint == right.requestFingerprint && + left.operationId == right.operationId && + left.operationRevision == right.operationRevision && + left.timerAssignmentId == right.timerAssignmentId && + left.expectedAssignmentRevision == right.expectedAssignmentRevision && + left.expectedIntentRevision == right.expectedIntentRevision && + left.assignmentEpoch == right.assignmentEpoch && + left.nativeTimerBindingId == right.nativeTimerBindingId && + left.expectedBindingRevision == right.expectedBindingRevision && + left.backendNativeTimerId == right.backendNativeTimerId && + left.expectedCurrentFingerprint == right.expectedCurrentFingerprint && + left.expectedSpecificationFingerprint == right.expectedSpecificationFingerprint && + sameCreateSpecification(left.specification, right.specification) && + left.jobId == right.jobId && left.attemptId == right.attemptId && + left.claimEpoch == right.claimEpoch && left.backendId == right.backendId && + left.agentId == right.agentId && + left.agentInstanceId == right.agentInstanceId && + left.backendGeneration == right.backendGeneration && + left.controlPlaneClaimedAt == right.controlPlaneClaimedAt && + sameProviderSelection(left.localProviderSelection, right.localProviderSelection); +} + } // namespace bool backendAgentCommandStateExtensionValid( @@ -288,6 +317,26 @@ bool backendAgentCommandStateExtensionValidateSupported( return true; } + + if (extension.extensionType == + kBackendAgentNativeTimerModifyLocalStateExtensionType) + { + BackendAgentNativeTimerModifyLocalState candidate; + BackendAgentNativeTimerModifyCommand expected; + if (!backendAgentNativeTimerModifyParseLocalState( + extension.payload, candidate, reasonCode) || + !backendAgentNativeTimerModifyCommandFromAssignment( + assignment, expected, reasonCode) || + !sameCommand(expected, candidate.command)) + { + reasonCode = + "native_timer_modify_state_extension_assignment_mismatch"; + return false; + } + reasonCode.clear(); + return true; + } + reasonCode = "unsupported_command_state_extension_type"; return false; } @@ -414,4 +463,62 @@ bool backendAgentNativeTimerDeleteParseCommandStateExtension( return true; } + +std::string backendAgentNativeTimerModifyCommandStateExtension( + const BackendAgentCommandAssignment& assignment, + const BackendAgentNativeTimerModifyLocalState& state, + std::string& reasonCode) +{ + BackendAgentNativeTimerModifyCommand expected; + if (!backendAgentNativeTimerModifyCommandFromAssignment( + assignment, expected, reasonCode) || + !backendAgentNativeTimerModifyLocalStateValid(state, reasonCode) || + !sameCommand(expected, state.command)) + { + reasonCode = "native_timer_modify_state_extension_assignment_mismatch"; + return {}; + } + const std::string payload = + backendAgentNativeTimerModifySerializeLocalState(state, reasonCode); + if (payload.empty()) return {}; + BackendAgentCommandStateExtension extension; + extension.extensionType = kBackendAgentNativeTimerModifyLocalStateExtensionType; + extension.commandId = assignment.commandId; + extension.requestFingerprint = assignment.requestFingerprint; + extension.payload = payload; + return backendAgentCommandStateExtensionSerialize( + extension, assignment, reasonCode); +} + +bool backendAgentNativeTimerModifyParseCommandStateExtension( + const std::string& encoded, + const BackendAgentCommandAssignment& assignment, + BackendAgentNativeTimerModifyLocalState& state, + std::string& reasonCode) +{ + BackendAgentCommandStateExtension extension; + if (!backendAgentCommandStateExtensionParse( + encoded, assignment, extension, reasonCode) || + extension.extensionType != + kBackendAgentNativeTimerModifyLocalStateExtensionType) + { + reasonCode = "native_timer_modify_state_extension_type_mismatch"; + return false; + } + BackendAgentNativeTimerModifyLocalState candidate; + BackendAgentNativeTimerModifyCommand expected; + if (!backendAgentNativeTimerModifyParseLocalState( + extension.payload, candidate, reasonCode) || + !backendAgentNativeTimerModifyCommandFromAssignment( + assignment, expected, reasonCode) || + !sameCommand(expected, candidate.command)) + { + reasonCode = "native_timer_modify_state_extension_assignment_mismatch"; + return false; + } + state = candidate; + reasonCode.clear(); + return true; +} + } // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentNativeTimerModifyCommandHandler.cpp b/core/agent/src/BackendAgentNativeTimerModifyCommandHandler.cpp new file mode 100644 index 000000000..ee831c02d --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerModifyCommandHandler.cpp @@ -0,0 +1,301 @@ +#include "BackendAgentNativeTimerModifyCommandHandler.h" + +#include "BackendAgentCommandStateExtension.h" +#include "BackendAgentNativeTimerModifyLocalState.h" + +#include +#include + +namespace +{ +using vdrsuite::agent::commandstate::LocalState; +using vdrsuite::agent::commandstate::persist; + +std::int64_t nowSeconds() +{ + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +struct NativeTimerModifyGenericProjection +{ + std::string dispatchState; + std::string verificationState; + std::string resultCategory; + std::string errorCategory; + std::string retryClassification; + std::string diagnostics; +}; + +bool nativeTimerModifyGenericProjection( + vdrsuite::agent::BackendAgentNativeTimerModifyOutcomeCategory outcome, + NativeTimerModifyGenericProjection& projection) +{ + using namespace vdrsuite::agent; + switch (outcome) + { + case BackendAgentNativeTimerModifyOutcomeCategory::rejectedWithoutEffect: + projection = { + "not_started", "verified", "rejected", "fenced", "none", + "native Timer modify rejected without effect"}; + return true; + case BackendAgentNativeTimerModifyOutcomeCategory::acceptedUnverified: + projection = { + "accepted_by_executor", "outcome_unknown", "outcome_unknown", + "none", "reconcile_only", + "native Timer modify accepted; readback reconciliation required"}; + return true; + case BackendAgentNativeTimerModifyOutcomeCategory::outcomeUnknown: + projection = { + "starting", "outcome_unknown", "outcome_unknown", + "executor_unknown", "reconcile_only", + "native Timer modify outcome unknown; reconciliation required"}; + return true; + } + return false; +} + +void createTimerDeleteResult( + LocalState& state, + const NativeTimerModifyGenericProjection& projection, + std::int64_t completedAt) +{ + state.dispatchState = projection.dispatchState; + state.resultPresent = true; + state.resultAcknowledged = false; + auto& result = state.result; + const auto& assignment = state.assignment; + result.commandId = assignment.commandId; + result.requestFingerprint = assignment.requestFingerprint; + result.jobId = assignment.jobId; + result.attemptId = assignment.attemptId; + result.claimEpoch = assignment.claimEpoch; + result.backendId = assignment.backendId; + result.agentId = assignment.agentId; + result.agentInstanceId = assignment.agentInstanceId; + result.backendGeneration = assignment.backendGeneration; + result.dispatchState = projection.dispatchState; + result.verificationState = projection.verificationState; + result.resultCategory = projection.resultCategory; + result.errorCategory = projection.errorCategory; + result.retryClassification = projection.retryClassification; + result.boundedDiagnostics = projection.diagnostics; + result.completedAt = completedAt; +} + +bool genericResultMatchesTimerDeleteEvidence( + const LocalState& state, + const NativeTimerModifyGenericProjection& projection, + const vdrsuite::agent::BackendAgentNativeTimerModifyEvidence& evidence) +{ + if (!state.resultPresent) return false; + const auto& result = state.result; + return state.dispatchState == projection.dispatchState && + result.dispatchState == projection.dispatchState && + result.verificationState == projection.verificationState && + result.resultCategory == projection.resultCategory && + result.errorCategory == projection.errorCategory && + result.retryClassification == projection.retryClassification && + result.boundedDiagnostics == projection.diagnostics && + result.completedAt == evidence.completedAt; +} +} + +namespace vdrsuite::agent +{ + +bool backendAgentNativeTimerModifyCommandReconcileExisting( + const std::string& statePath, + const BackendAgentNativeTimerModifyCommandContext& context, + commandstate::LocalState& state, + std::string& reason) +{ + if (!state.stateExtensionPresent || + state.stateExtension.extensionType != + kBackendAgentNativeTimerModifyLocalStateExtensionType) + { + reason = "native_modify_local_state_required"; + return false; + } + + BackendAgentNativeTimerModifyLocalState localState; + if (!backendAgentNativeTimerModifyParseLocalState( + state.stateExtension.payload, localState, reason)) + { + reason = "native_modify_local_state_invalid"; + return false; + } + + const auto recovery = backendAgentNativeTimerModifyRecoverLocalState( + localState, + context.backendId, + context.agentId, + context.agentInstanceId, + context.backendGeneration, + nowSeconds()); + if (recovery.decision == + BackendAgentNativeTimerModifyRecoveryDecision::failClosed) + { + reason = recovery.reasonCode.empty() + ? "native_modify_recovery_failed" : recovery.reasonCode; + return false; + } + + bool stateChanged = false; + if (recovery.decision == + BackendAgentNativeTimerModifyRecoveryDecision::reconcileOnly) + { + if (!backendAgentNativeTimerModifyCompleteLocalState( + localState, recovery.evidence, reason)) + return false; + const std::string payload = + backendAgentNativeTimerModifySerializeLocalState( + localState, reason); + if (payload.empty()) return false; + state.stateExtension.payload = payload; + stateChanged = true; + } + else if (recovery.decision != + BackendAgentNativeTimerModifyRecoveryDecision::returnPersistedEvidence) + { + reason = "native_modify_recovery_decision_invalid"; + return false; + } + + NativeTimerModifyGenericProjection projection; + if (!nativeTimerModifyGenericProjection( + recovery.evidence.outcome, projection)) + { + reason = "native_modify_outcome_projection_invalid"; + return false; + } + + if (state.resultPresent) + { + if (!genericResultMatchesTimerDeleteEvidence( + state, projection, recovery.evidence)) + { + reason = "native_modify_result_evidence_conflict"; + return false; + } + } + else + { + createTimerDeleteResult(state, projection, recovery.evidence.completedAt); + stateChanged = true; + } + + if (stateChanged) + return persist(statePath, state, reason); + + reason = "native_modify_local_state_reconciled"; + return true; +} + +bool backendAgentNativeTimerModifyCommandPrepareFreshStarting( + const std::string& statePath, + commandstate::LocalState& state, + std::int64_t currentTime, + std::string& reason) +{ + if (state.stateExtensionPresent || state.resultPresent || + state.dispatchState != "not_started" || currentTime <= 0 || + currentTime > state.assignment.deadline) + { + reason = "native_modify_fresh_starting_state_invalid"; + return false; + } + + BackendAgentNativeTimerModifyLocalState localState; + if (!backendAgentNativeTimerModifyPrepareLocalStarting( + state.assignment, currentTime, localState, reason)) + return false; + + BackendAgentCommandStateExtension extension; + extension.extensionType = + kBackendAgentNativeTimerModifyLocalStateExtensionType; + extension.commandId = state.assignment.commandId; + extension.requestFingerprint = state.assignment.requestFingerprint; + extension.payload = backendAgentNativeTimerModifySerializeLocalState( + localState, reason); + if (extension.payload.empty() || + !backendAgentCommandStateExtensionValidateSupported( + extension, state.assignment, reason)) + { + reason = "native_modify_fresh_starting_extension_invalid"; + return false; + } + + state.stateExtensionPresent = true; + state.stateExtension = extension; + state.dispatchState = "starting"; + if (!persist(statePath, state, reason)) return false; + reason = "native_modify_local_starting_persisted"; + return true; +} + +bool backendAgentNativeTimerModifyCommandExecuteFreshStartingAndPersistOutcome( + const std::string& statePath, + const BackendAgentNativeTimerModifyCommandContext& context, + IBackendAgentNativeTimerModifyTransport* transport, + commandstate::LocalState& state, + std::string& reason) +{ + if (transport == nullptr || + !state.stateExtensionPresent || state.resultPresent || + state.dispatchState != "starting" || !state.receiptAcknowledged || + state.stateExtension.extensionType != + kBackendAgentNativeTimerModifyLocalStateExtensionType) + { + reason = "native_modify_executor_handoff_state_invalid"; + return false; + } + + BackendAgentNativeTimerModifyLocalState localState; + if (!backendAgentNativeTimerModifyParseLocalState( + state.stateExtension.payload, localState, reason) || + localState.phase != BackendAgentNativeTimerModifyLocalPhase::starting) + { + reason = "native_modify_executor_starting_state_invalid"; + return false; + } + + BackendAgentNativeTimerModifyExecutorContext executorContext; + executorContext.backendId = context.backendId; + executorContext.agentId = context.agentId; + executorContext.agentInstanceId = context.agentInstanceId; + executorContext.backendGeneration = context.backendGeneration; + executorContext.now = nowSeconds(); + + BackendAgentNativeTimerModifyEvidence evidence; + if (!backendAgentNativeTimerModifyExecuteFreshStartingOnce( + state.assignment, + localState, + executorContext, + *transport, + evidence, + reason)) + return false; + + if (!backendAgentNativeTimerModifyCompleteLocalState( + localState, evidence, reason)) + return false; + const std::string payload = backendAgentNativeTimerModifySerializeLocalState( + localState, reason); + if (payload.empty()) return false; + state.stateExtension.payload = payload; + + NativeTimerModifyGenericProjection projection; + if (!nativeTimerModifyGenericProjection(evidence.outcome, projection)) + { + reason = "native_modify_executor_outcome_projection_invalid"; + return false; + } + createTimerDeleteResult(state, projection, evidence.completedAt); + + if (!persist(statePath, state, reason)) return false; + reason = "native_modify_executor_outcome_persisted"; + return true; +} + +} diff --git a/core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp b/core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp new file mode 100644 index 000000000..0b4247c39 --- /dev/null +++ b/core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp @@ -0,0 +1,131 @@ +#include "SuiteBridgeNativeTimerModifyTransport.h" + +#include +#include +#include +#include +#include +#include + +namespace vdrsuite::agent +{ +namespace { +constexpr const char* CapabilityProtocol="vdr-suite-ntmod-cap/1"; +constexpr const char* ResultProtocol="vdr-suite-ntmod-result/1"; +constexpr int CapabilityReplyCode=900; +constexpr int AcceptedCode=557; +constexpr int UnknownCode=558; +bool safeToken(const std::string& value,std::size_t max=512){ + return !value.empty()&&value.size()<=max&&std::all_of(value.begin(),value.end(), + [](unsigned char c){return std::isalnum(c)||c=='-'||c=='_'||c=='.'||c==':';});} +std::vector split(const std::string& in,std::size_t max){ + std::vector out;std::size_t pos=0;while(posmax)return {};if(end==std::string::npos)break;pos=end+1;}return out;} +bool number(const std::string& s,std::uint64_t& n){if(s.empty())return false;n=0;for(char c:s){ + if(c<'0'||c>'9')return false;auto d=static_cast(c-'0'); + if(n>(std::numeric_limits::max()-d)/10)return false;n=n*10+d;}return n>0;} +char hd(unsigned n){return n<10?static_cast('0'+n):static_cast('a'+n-10);} +std::string hexText(const std::string& value){if(value.empty())return "-";std::string out; + out.reserve(value.size()*2);for(unsigned char c:value){out.push_back(hd(c>>4));out.push_back(hd(c&15));}return out;} +BackendAgentNativeTimerModifyTransportReply result( + BackendAgentNativeTimerModifyTransportDisposition d,std::string e){return {d,std::move(e)};} +bool parseCapability(const SuiteBridgeCommandReply& reply,const char* capability, + std::string& epoch,std::uint64_t& generation,std::uint64_t& revision,std::string& reason){ + if(!reply.transportSucceeded()||reply.replyCode!=CapabilityReplyCode){reason="ntmod_capability_unavailable";return false;} + auto v=split(reply.payload,10);if(v.size()!=10||v[0]!=CapabilityProtocol||v[1]!=capability|| + v[2]!="1"||v[3]!="timer-modify"||(v[4]!="enabled"&&v[4]!="disabled")||v[9]!=v[4]|| + v[5]!=kBackendAgentNativeTimerModifyProviderKind||!safeToken(v[6],192)|| + !number(v[7],generation)||!number(v[8],revision)){reason="ntmod_capability_invalid";return false;} + epoch=v[6];return true;} +} + +SuiteBridgeCommandReply SuiteBridgeSvdrpTransport::discoverNativeTimerModifyContract( + BackendAgentNativeTimerModifyKind kind) +{ + return executeRequest(std::string("PLUG suitebridge NTMOD CAP 1 ")+ + backendAgentNativeTimerModifyKindName(kind)+"\r\n"); +} + +SuiteBridgeCommandReply SuiteBridgeSvdrpTransport::executeNativeTimerModifyContract( + const BackendAgentNativeTimerModifyTransportRequest& request) +{ + const auto& c=request.command;const auto& q=c.localProviderSelection;const auto& s=c.specification; + std::ostringstream wire;wire<<"PLUG suitebridge NTMOD EXEC vdr-suite-native/1 " + < Date: Sun, 16 Aug 2026 23:31:10 +0200 Subject: [PATCH 52/85] Add fenced control-plane assignment for Timer modification --- .../include/BackendAgentCommandDelivery.h | 1 + .../BackendAgentNativeTimerModifyAssignment.h | 52 +++++ ...ackendAgentNativeTimerModifyAssignment.cpp | 195 ++++++++++++++++++ mk/agent-sources.mk | 17 +- ...64-native-timer-modify-assignment-tests.mk | 4 + ..._phase64_native_timer_modify_assignment.py | 11 + 6 files changed, 265 insertions(+), 15 deletions(-) create mode 100644 core/agent/include/BackendAgentNativeTimerModifyAssignment.h create mode 100644 core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp create mode 100644 mk/phase64-native-timer-modify-assignment-tests.mk create mode 100755 tools/check_phase64_native_timer_modify_assignment.py diff --git a/core/agent/include/BackendAgentCommandDelivery.h b/core/agent/include/BackendAgentCommandDelivery.h index 1deedd933..79c823269 100644 --- a/core/agent/include/BackendAgentCommandDelivery.h +++ b/core/agent/include/BackendAgentCommandDelivery.h @@ -28,6 +28,7 @@ class BackendAgentCommandRepository const BackendAgentCommandAssignment& assignment, const vdrsuite::agent::BackendAgentLocalProviderSelection* selection = nullptr); bool ensureNativeTimerDeleteAssignmentSchema(); + bool ensureNativeTimerModifyAssignmentSchema(); std::optional findAssignmentForOperation( const std::string& backendId, const std::string& operationId, diff --git a/core/agent/include/BackendAgentNativeTimerModifyAssignment.h b/core/agent/include/BackendAgentNativeTimerModifyAssignment.h new file mode 100644 index 000000000..286c1f667 --- /dev/null +++ b/core/agent/include/BackendAgentNativeTimerModifyAssignment.h @@ -0,0 +1,52 @@ +#pragma once + +#include "BackendAgentCommand.h" +#include "BackendAgentNativeTimerModify.h" +#include "SecurityIdentity.h" + +class BackendAgentCommandRepository; +class BackendAgentRepository; + +namespace vdrsuite::agent +{ +struct BackendAgentNativeTimerModifyAssignmentRequest +{ + BackendAgentNativeTimerModifyKind kind = + BackendAgentNativeTimerModifyKind::update; + std::string operationId; + std::string operationRevision; + std::string timerAssignmentId; + std::string expectedAssignmentRevision; + std::string expectedIntentRevision; + std::uint64_t assignmentEpoch=0; + std::string nativeTimerBindingId; + std::string expectedBindingRevision; + std::string backendId; + std::uint64_t backendGeneration=0; + std::string backendNativeTimerId; + std::string expectedCurrentFingerprint; + std::string expectedSpecificationFingerprint; + BackendAgentNativeTimerCreateSpecification specification; + std::int64_t controlPlaneClaimedAt=0; +}; +struct BackendAgentNativeTimerModifyAssignmentResult +{ + bool accepted=false; + bool replayed=false; + std::string reasonCode; + BackendAgentCommandAssignment assignment; +}; +class BackendAgentNativeTimerModifyAssignmentService +{ +public: + BackendAgentNativeTimerModifyAssignmentService( + BackendAgentCommandRepository&,BackendAgentRepository&); + BackendAgentNativeTimerModifyAssignmentResult assign( + const RequestSecurityContext&, + const BackendAgentNativeTimerModifyAssignmentRequest&, + std::int64_t now,std::int64_t deadline); +private: + BackendAgentCommandRepository& commandRepository_; + BackendAgentRepository& agentRepository_; +}; +} diff --git a/core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp b/core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp new file mode 100644 index 000000000..511e346e7 --- /dev/null +++ b/core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp @@ -0,0 +1,195 @@ +#include "BackendAgentNativeTimerModifyAssignment.h" + +#include "BackendAgentCommandDelivery.h" +#include "BackendAgentLifecycle.h" +#include "BackendAgentNativeTimerDeleteFingerprint.h" +#include "BackendAgentNativeTimerModifyPayload.h" +#include "Database.h" + +#include + +namespace vdrsuite::agent +{ +namespace { +const char* commandType(BackendAgentNativeTimerModifyKind kind) { + return kind==BackendAgentNativeTimerModifyKind::toggle + ? kBackendAgentNativeTimerToggleCommandType:kBackendAgentNativeTimerUpdateCommandType; +} +BackendAgentNativeTimerModifyAssignmentResult rejected(const std::string& reason) { + BackendAgentNativeTimerModifyAssignmentResult r;r.reasonCode=reason;return r; +} +bool requestValid(const RequestSecurityContext& context, + const BackendAgentNativeTimerModifyAssignmentRequest& r, + std::int64_t now,std::int64_t deadline) { + return context.authenticated()&&context.actor.type==ActorType::System&& + backendAgentCommandSafeIdentifier(r.operationId)&& + backendAgentCommandSafeIdentifier(r.operationRevision)&& + backendAgentCommandSafeIdentifier(r.timerAssignmentId)&& + backendAgentCommandSafeIdentifier(r.expectedAssignmentRevision)&& + backendAgentCommandSafeIdentifier(r.expectedIntentRevision)&&r.assignmentEpoch>0&& + backendAgentCommandSafeIdentifier(r.nativeTimerBindingId)&& + backendAgentCommandSafeIdentifier(r.expectedBindingRevision)&& + backendAgentCommandSafeIdentifier(r.backendId)&&r.backendGeneration>0&& + backendAgentCommandSafeIdentifier(r.backendNativeTimerId)&& + backendAgentNativeTimerDeleteCanonicalFingerprintValid(r.expectedCurrentFingerprint)&& + backendAgentNativeTimerCreateSpecificationValid(r.specification)&& + r.expectedSpecificationFingerprint== + backendAgentNativeTimerCreateSpecificationFingerprint(r.specification)&& + r.controlPlaneClaimedAt>0&&r.controlPlaneClaimedAt<=now&&now>0&& + deadline>now&&deadline-now<=3600; +} +BackendAgentNativeTimerModifyPayload payloadFor( + const BackendAgentNativeTimerModifyAssignmentRequest& r, + const BackendAgentLocalProviderSelection& selection) { + BackendAgentNativeTimerModifyPayload p;p.kind=r.kind;p.operationRevision=r.operationRevision; + p.timerAssignmentId=r.timerAssignmentId;p.expectedAssignmentRevision=r.expectedAssignmentRevision; + p.expectedIntentRevision=r.expectedIntentRevision;p.assignmentEpoch=r.assignmentEpoch; + p.nativeTimerBindingId=r.nativeTimerBindingId;p.expectedBindingRevision=r.expectedBindingRevision; + p.backendId=r.backendId;p.backendGeneration=r.backendGeneration; + p.backendNativeTimerId=r.backendNativeTimerId; + p.expectedCurrentFingerprint=backendAgentNativeTimerDeleteFingerprintToken( + r.expectedCurrentFingerprint); + p.expectedSpecificationFingerprint=r.expectedSpecificationFingerprint; + p.specification=r.specification;p.controlPlaneClaimedAt=r.controlPlaneClaimedAt; + p.localProviderSelection=selection;return p; +} +BackendAgentNativeTimerModifyCommand domainCommand( + const BackendAgentCommandAssignment& a,const BackendAgentNativeTimerModifyPayload& p) { + BackendAgentNativeTimerModifyCommand c;c.kind=p.kind;c.commandId=a.commandId; + c.requestFingerprint=a.requestFingerprint;c.operationId=a.operationId; + c.operationRevision=p.operationRevision;c.timerAssignmentId=p.timerAssignmentId; + c.expectedAssignmentRevision=p.expectedAssignmentRevision; + c.expectedIntentRevision=p.expectedIntentRevision;c.assignmentEpoch=p.assignmentEpoch; + c.nativeTimerBindingId=p.nativeTimerBindingId;c.expectedBindingRevision=p.expectedBindingRevision; + c.backendNativeTimerId=p.backendNativeTimerId;c.expectedCurrentFingerprint=p.expectedCurrentFingerprint; + c.expectedSpecificationFingerprint=p.expectedSpecificationFingerprint;c.specification=p.specification; + c.jobId=a.jobId;c.attemptId=a.attemptId;c.claimEpoch=a.claimEpoch;c.backendId=a.backendId; + c.agentId=a.agentId;c.agentInstanceId=a.agentInstanceId;c.backendGeneration=a.backendGeneration; + c.controlPlaneClaimedAt=p.controlPlaneClaimedAt;c.localProviderSelection=p.localProviderSelection; + return c; +} +bool exactRequest(const BackendAgentNativeTimerModifyAssignmentRequest& r, + const BackendAgentCommandAssignment& a,const BackendAgentNativeTimerModifyPayload& p) { + return a.commandType==commandType(r.kind)&&a.payloadVersion==1&& + a.verificationPolicy=="readback_required"&&a.operationId==r.operationId&& + a.backendId==r.backendId&&a.backendGeneration==r.backendGeneration&& + p.kind==r.kind&&p.operationRevision==r.operationRevision&& + p.timerAssignmentId==r.timerAssignmentId&& + p.expectedAssignmentRevision==r.expectedAssignmentRevision&& + p.expectedIntentRevision==r.expectedIntentRevision&&p.assignmentEpoch==r.assignmentEpoch&& + p.nativeTimerBindingId==r.nativeTimerBindingId&& + p.expectedBindingRevision==r.expectedBindingRevision&& + p.backendNativeTimerId==r.backendNativeTimerId&& + p.expectedCurrentFingerprint==backendAgentNativeTimerDeleteFingerprintToken(r.expectedCurrentFingerprint)&& + p.expectedSpecificationFingerprint==r.expectedSpecificationFingerprint&& + p.controlPlaneClaimedAt==r.controlPlaneClaimedAt; +} +bool exactExisting(BackendAgentCommandRepository& repository, + const BackendAgentRecord& agent,const BackendAgentNativeTimerModifyAssignmentRequest& r, + const BackendAgentCommandAssignment& a,std::string& reason) { + BackendAgentNativeTimerModifyPayload p; + if(!backendAgentNativeTimerModifyParsePayload(a.payload,p,reason)|| + !exactRequest(r,a,p)||!backendAgentCommandValidAssignment(a)){ + reason="native_timer_modify_assignment_conflict";return false;} + if(a.agentId!=agent.agentId||a.agentInstanceId!=agent.agentInstanceId|| + a.backendGeneration!=agent.backendGeneration){ + reason="native_timer_modify_agent_fence_stale";return false;} + const auto recorded=repository.localProviderSelectionForCommand(a.commandId); + if(!recorded.has_value()|| + !backendAgentLocalProviderSameFence(*recorded,p.localProviderSelection)){ + reason="native_timer_modify_provider_selection_missing";return false;} + const auto current=repository.selectLocalProvider(r.backendId,agent.agentId, + agent.agentInstanceId,agent.backendGeneration,kBackendAgentNativeTimerModifyAuthorityDomain, + backendAgentNativeTimerModifyCapability(r.kind),reason); + if(!current.has_value()||!backendAgentLocalProviderSameFence(*recorded,*current)){ + reason="native_timer_modify_provider_selection_stale";return false;} + if(!backendAgentNativeTimerModifyValidCommand(domainCommand(a,p),reason)){ + reason="native_timer_modify_assignment_contract_invalid";return false;} + return true; +} +} + +bool BackendAgentCommandRepository::ensureNativeTimerModifyAssignmentSchema() +{ + auto lease=database_.acquireTransactionLease();(void)lease; + if(!database_.execute("BEGIN IMMEDIATE;"))return false; + const bool ok=database_.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_backend_agent_timer_modify_operation " + "ON backend_agent_commands(backend_id,operation_id) " + "WHERE command_type IN ('vdr.timer.update','vdr.timer.toggle');")&& + database_.execute( + "DELETE FROM backend_agent_command_capabilities " + "WHERE command_type IN ('vdr.timer.update','vdr.timer.toggle');")&& + database_.execute( + "CREATE TRIGGER IF NOT EXISTS trg_backend_agent_timer_update_dormant_capability " + "BEFORE INSERT ON backend_agent_command_capabilities " + "WHEN NEW.command_type='vdr.timer.update' BEGIN SELECT RAISE(IGNORE); END;")&& + database_.execute( + "CREATE TRIGGER IF NOT EXISTS trg_backend_agent_timer_toggle_dormant_capability " + "BEFORE INSERT ON backend_agent_command_capabilities " + "WHEN NEW.command_type='vdr.timer.toggle' BEGIN SELECT RAISE(IGNORE); END;"); + if(!ok||!database_.execute("COMMIT;")){database_.execute("ROLLBACK;");return false;} + return true; +} + +BackendAgentNativeTimerModifyAssignmentService:: +BackendAgentNativeTimerModifyAssignmentService( + BackendAgentCommandRepository& commands,BackendAgentRepository& agents) + :commandRepository_(commands),agentRepository_(agents){} + +BackendAgentNativeTimerModifyAssignmentResult +BackendAgentNativeTimerModifyAssignmentService::assign( + const RequestSecurityContext& context, + const BackendAgentNativeTimerModifyAssignmentRequest& request, + std::int64_t now,std::int64_t deadline) +{ + if(!requestValid(context,request,now,deadline)) + return rejected("invalid_native_timer_modify_assignment_request"); + const auto agent=agentRepository_.findAgentForBackend(request.backendId); + if(!agent.has_value()||agent->revoked||agent->incompatible|| + agent->agentInstanceId.empty()||agent->backendGeneration==0|| + agent->leaseExpiresAtbackendGeneration!=request.backendGeneration) + return rejected("native_timer_modify_backend_generation_conflict"); + if(!commandRepository_.ensureNativeTimerModifyAssignmentSchema()) + return rejected("native_timer_modify_assignment_schema_failed"); + const char* type=commandType(request.kind); + if(const auto existing=commandRepository_.findAssignmentForOperation( + request.backendId,request.operationId,type);existing.has_value()){ + std::string reason;if(!exactExisting(commandRepository_,*agent,request,*existing,reason)) + return rejected(reason); + BackendAgentNativeTimerModifyAssignmentResult r;r.accepted=true;r.replayed=true; + r.reasonCode="native_timer_modify_assignment_replayed";r.assignment=*existing;return r; + } + std::string reason;const auto selection=commandRepository_.selectLocalProvider( + request.backendId,agent->agentId,agent->agentInstanceId,agent->backendGeneration, + kBackendAgentNativeTimerModifyAuthorityDomain, + backendAgentNativeTimerModifyCapability(request.kind),reason); + if(!selection.has_value())return rejected(reason); + const auto payload=payloadFor(request,*selection); + BackendAgentCommandAssignment a;a.present=true; + a.requestId=backendAgentGenerateOpaqueId("req_",8);a.correlationId=a.requestId; + a.operationId=request.operationId;a.jobId=backendAgentGenerateOpaqueId("job_",12); + a.attemptId=backendAgentGenerateOpaqueId("att_",12);a.claimEpoch=1; + a.commandId=backendAgentGenerateOpaqueId("cmd_",12);a.backendId=request.backendId; + a.agentId=agent->agentId;a.agentInstanceId=agent->agentInstanceId; + a.backendGeneration=agent->backendGeneration;a.commandType=type;a.payloadVersion=1; + a.payload=backendAgentNativeTimerModifyPayload(payload); + a.verificationPolicy="readback_required";a.assignedAt=now;a.deadline=deadline; + a.requestFingerprint=backendAgentCommandFingerprint(a); + if(a.payload.empty()||!backendAgentNativeTimerModifyValidCommand(domainCommand(a,payload),reason)|| + !backendAgentCommandValidAssignment(a)) + return rejected("native_timer_modify_assignment_contract_invalid"); + if(commandRepository_.insertAssignment(a,&*selection)){ + BackendAgentNativeTimerModifyAssignmentResult r;r.accepted=true; + r.reasonCode="native_timer_modify_assigned";r.assignment=a;return r; + } + const auto raced=commandRepository_.findAssignmentForOperation( + request.backendId,request.operationId,type); + if(raced.has_value()&&exactExisting(commandRepository_,*agent,request,*raced,reason)){ + BackendAgentNativeTimerModifyAssignmentResult r;r.accepted=true;r.replayed=true; + r.reasonCode="native_timer_modify_assignment_replayed";r.assignment=*raced;return r; + } + return rejected(raced.has_value()?reason:"native_timer_modify_assignment_persist_failed"); +} +} diff --git a/mk/agent-sources.mk b/mk/agent-sources.mk index 8b723d047..fe6389493 100644 --- a/mk/agent-sources.mk +++ b/mk/agent-sources.mk @@ -16,9 +16,6 @@ AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC := \ AGENT_NATIVE_TIMER_DELETE_TRANSPORT_SRC := \ core/agent/src/SuiteBridgeSvdrpNativeTimerDeleteTransport.cpp -AGENT_NATIVE_TIMER_MODIFY_TRANSPORT_SRC := \ - core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp - AGENT_OBSERVATION_SRC := \ core/agent/src/SuiteBridgeObservation.cpp \ core/agent/src/SuiteBridgeObservationService.cpp \ @@ -41,8 +38,6 @@ AGENT_COMMAND_DOMAIN_SRC := \ core/agent/src/BackendAgentCommand.cpp \ core/agent/src/BackendAgentNativeTimerCreate.cpp \ core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ - core/agent/src/BackendAgentNativeTimerModify.cpp \ - core/agent/src/BackendAgentNativeTimerModifyPayload.cpp \ $(AGENT_NATIVE_PROBE_SRC) AGENT_COMMAND_JSON_SRC := \ @@ -50,23 +45,20 @@ AGENT_COMMAND_JSON_SRC := \ AGENT_COMMAND_DELIVERY_SRC := \ core/agent/src/BackendAgentCommandDelivery.cpp \ - core/agent/src/BackendAgentNativeProbeDelivery.cpp + core/agent/src/BackendAgentNativeProbeDelivery.cpp \ + core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp AGENT_COMMAND_STATE_SRC := \ core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ core/agent/src/BackendAgentNativeTimerDelete.cpp \ core/agent/src/BackendAgentNativeTimerDeleteLocalState.cpp \ - core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp \ core/agent/src/BackendAgentCommandStateExtension.cpp \ core/agent/src/BackendAgentCommandStateStore.cpp AGENT_TIMER_DELETE_EXECUTOR_SRC := \ core/agent/src/BackendAgentNativeTimerDeleteExecutor.cpp -AGENT_TIMER_MODIFY_EXECUTOR_SRC := \ - core/agent/src/BackendAgentNativeTimerModifyExecutor.cpp - AGENT_NATIVE_PROBE_COMMAND_HANDLER_SRC := \ core/agent/src/BackendAgentNativeProbeCommandHandler.cpp @@ -79,18 +71,13 @@ AGENT_NATIVE_TIMER_CREATE_COMMAND_HANDLER_SRC := \ AGENT_NATIVE_TIMER_DELETE_COMMAND_HANDLER_SRC := \ core/agent/src/BackendAgentNativeTimerDeleteCommandHandler.cpp -AGENT_NATIVE_TIMER_MODIFY_COMMAND_HANDLER_SRC := \ - core/agent/src/BackendAgentNativeTimerModifyCommandHandler.cpp - AGENT_COMMAND_CLIENT_SRC := \ $(AGENT_COMMAND_STATE_SRC) \ $(AGENT_TIMER_CREATE_EXECUTOR_SRC) \ $(AGENT_TIMER_DELETE_EXECUTOR_SRC) \ - $(AGENT_TIMER_MODIFY_EXECUTOR_SRC) \ $(AGENT_NATIVE_PROBE_COMMAND_HANDLER_SRC) \ $(AGENT_NATIVE_TIMER_CREATE_COMMAND_HANDLER_SRC) \ $(AGENT_NATIVE_TIMER_DELETE_COMMAND_HANDLER_SRC) \ - $(AGENT_NATIVE_TIMER_MODIFY_COMMAND_HANDLER_SRC) \ core/agent/src/BackendAgentCommandClient.cpp AGENT_CONTROL_PLANE_DOMAIN_SRC := \ diff --git a/mk/phase64-native-timer-modify-assignment-tests.mk b/mk/phase64-native-timer-modify-assignment-tests.mk new file mode 100644 index 000000000..3dd344c24 --- /dev/null +++ b/mk/phase64-native-timer-modify-assignment-tests.mk @@ -0,0 +1,4 @@ +.PHONY: test-phase64-native-timer-modify-assignment + +test-phase64-native-timer-modify-assignment: + python3 tools/check_phase64_native_timer_modify_assignment.py diff --git a/tools/check_phase64_native_timer_modify_assignment.py b/tools/check_phase64_native_timer_modify_assignment.py new file mode 100755 index 000000000..edb24244c --- /dev/null +++ b/tools/check_phase64_native_timer_modify_assignment.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +source=(ROOT/"core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp").read_text() +for token in ("ensureNativeTimerModifyAssignmentSchema","selectLocalProvider", +"insertAssignment","vdr.timer.update","vdr.timer.toggle","readback_required"): + if token not in source:raise SystemExit("missing modify assignment invariant: "+token) +client=(ROOT/"core/agent/src/BackendAgentCommandClient.cpp").read_text() +if "kBackendAgentNativeTimerToggleCommandType" not in client: + raise SystemExit("modify advertisement fence missing") +print("phase64 native Timer modify assignment guard passed") From 8cd9dbd9a9a4d9b5d20039d40cb4301ec5ef2d04 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:34:15 +0200 Subject: [PATCH 53/85] Fix Timer modification CI integration --- .../src/BackendAgentNativeTimerModifyAssignment.cpp | 4 ++++ mk/phase64-native-timer-modify-agent-contract-tests.mk | 9 +++++++-- mk/phase64-native-timer-modify-assignment-tests.mk | 7 ++++++- mk/phase64-native-timer-modify-command-path-tests.mk | 7 ++++++- ...tebridge-native-timer-modify-command-service-tests.mk | 9 +++++++-- tools/check_phase64_native_timer_create_delivery.py | 9 +++++++-- 6 files changed, 37 insertions(+), 8 deletions(-) diff --git a/core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp b/core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp index 511e346e7..a39a3809f 100644 --- a/core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp +++ b/core/agent/src/BackendAgentNativeTimerModifyAssignment.cpp @@ -108,6 +108,7 @@ bool exactExisting(BackendAgentCommandRepository& repository, return true; } } +} // namespace vdrsuite::agent bool BackendAgentCommandRepository::ensureNativeTimerModifyAssignmentSchema() { @@ -132,6 +133,9 @@ bool BackendAgentCommandRepository::ensureNativeTimerModifyAssignmentSchema() return true; } +namespace vdrsuite::agent +{ + BackendAgentNativeTimerModifyAssignmentService:: BackendAgentNativeTimerModifyAssignmentService( BackendAgentCommandRepository& commands,BackendAgentRepository& agents) diff --git a/mk/phase64-native-timer-modify-agent-contract-tests.mk b/mk/phase64-native-timer-modify-agent-contract-tests.mk index 8b6f6cd67..90939b2b4 100644 --- a/mk/phase64-native-timer-modify-agent-contract-tests.mk +++ b/mk/phase64-native-timer-modify-agent-contract-tests.mk @@ -1,7 +1,9 @@ -.PHONY: test-phase64-native-timer-modify-agent-contract +.PHONY: test-phase64-native-timer-modify-agent-contract test-phase64-native-timer-modify-agent-contract-architecture -test-phase64-native-timer-modify-agent-contract: +test-phase64-native-timer-modify-agent-contract-architecture: python3 tools/check_phase64_native_timer_modify_agent_contract.py + +test-phase64-native-timer-modify-agent-contract: test-phase64-native-timer-modify-agent-contract-architecture mkdir -p $(BUILD_DIR) $(BUILD_CXX) $(CXXFLAGS) \ core/agent/src/BackendAgentCommand.cpp \ @@ -16,3 +18,6 @@ test-phase64-native-timer-modify-agent-contract: -o $(BUILD_DIR)/test_backend_agent_native_timer_modify $(BUILD_DIR)/test_backend_agent_native_timer_modify rm -f $(BUILD_DIR)/test_backend_agent_native_timer_modify + +test-fast: test-phase64-native-timer-modify-agent-contract +test-architecture: test-phase64-native-timer-modify-agent-contract-architecture diff --git a/mk/phase64-native-timer-modify-assignment-tests.mk b/mk/phase64-native-timer-modify-assignment-tests.mk index 3dd344c24..ed9a51a72 100644 --- a/mk/phase64-native-timer-modify-assignment-tests.mk +++ b/mk/phase64-native-timer-modify-assignment-tests.mk @@ -1,4 +1,9 @@ -.PHONY: test-phase64-native-timer-modify-assignment +.PHONY: test-phase64-native-timer-modify-assignment test-phase64-native-timer-modify-assignment-architecture test-phase64-native-timer-modify-assignment: python3 tools/check_phase64_native_timer_modify_assignment.py + +test-phase64-native-timer-modify-assignment-architecture: test-phase64-native-timer-modify-assignment + +test-fast: test-phase64-native-timer-modify-assignment +test-architecture: test-phase64-native-timer-modify-assignment-architecture diff --git a/mk/phase64-native-timer-modify-command-path-tests.mk b/mk/phase64-native-timer-modify-command-path-tests.mk index 6f9861a55..bd1800c8c 100644 --- a/mk/phase64-native-timer-modify-command-path-tests.mk +++ b/mk/phase64-native-timer-modify-command-path-tests.mk @@ -1,4 +1,9 @@ -.PHONY: test-phase64-native-timer-modify-command-path +.PHONY: test-phase64-native-timer-modify-command-path test-phase64-native-timer-modify-command-path-architecture test-phase64-native-timer-modify-command-path: python3 tools/check_phase64_native_timer_modify_command_path.py + +test-phase64-native-timer-modify-command-path-architecture: test-phase64-native-timer-modify-command-path + +test-fast: test-phase64-native-timer-modify-command-path +test-architecture: test-phase64-native-timer-modify-command-path-architecture diff --git a/mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk b/mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk index b7b6f4e1a..16bc6df84 100644 --- a/mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk +++ b/mk/phase64-suitebridge-native-timer-modify-command-service-tests.mk @@ -1,5 +1,10 @@ -.PHONY: test-phase64-suitebridge-native-timer-modify-command-service +.PHONY: test-phase64-suitebridge-native-timer-modify-command-service test-phase64-suitebridge-native-timer-modify-command-service-architecture -test-phase64-suitebridge-native-timer-modify-command-service: +test-phase64-suitebridge-native-timer-modify-command-service-architecture: python3 tools/check_phase64_suitebridge_native_timer_modify_command_service.py + +test-phase64-suitebridge-native-timer-modify-command-service: test-phase64-suitebridge-native-timer-modify-command-service-architecture $(MAKE) -C vdr-plugin-suite-bridge test-native-timer-modify + +test-fast: test-phase64-suitebridge-native-timer-modify-command-service +test-architecture: test-phase64-suitebridge-native-timer-modify-command-service-architecture diff --git a/tools/check_phase64_native_timer_create_delivery.py b/tools/check_phase64_native_timer_create_delivery.py index b8f0b5f6f..88b6f60d6 100755 --- a/tools/check_phase64_native_timer_create_delivery.py +++ b/tools/check_phase64_native_timer_create_delivery.py @@ -38,9 +38,14 @@ client = (ROOT / "core/agent/src/BackendAgentCommandClient.cpp").read_text( encoding="utf-8" ) +availability_start = client.find("CommandAvailability availableCommands") +availability_end = client.find("\n}", availability_start) +availability = client[availability_start:availability_end] closed = ( - 'type == vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType ||' in client - and 'type == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType)\n continue;' in client + availability_start >= 0 + and "kBackendAgentNativeTimerCreateCommandType" in availability + and "kBackendAgentNativeTimerDeleteCommandType" in availability + and "continue;" in availability ) if not closed: errors.append("productive vdr.timer.create Agent advertisement must remain closed") From 4382d60ff0f1685920d17774fce5b66f0c4e9973 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:35:59 +0200 Subject: [PATCH 54/85] Carry Timer modification commands through delivery --- core/agent/src/BackendAgentCommand.cpp | 1 + .../agent/src/BackendAgentCommandDelivery.cpp | 31 +++++++++++++++++-- core/agent/src/BackendAgentCommandJson.cpp | 1 + ...se64-native-timer-modify-delivery-tests.mk | 9 ++++++ ...ck_phase64_native_timer_modify_delivery.py | 21 +++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 mk/phase64-native-timer-modify-delivery-tests.mk create mode 100755 tools/check_phase64_native_timer_modify_delivery.py diff --git a/core/agent/src/BackendAgentCommand.cpp b/core/agent/src/BackendAgentCommand.cpp index 4c6ee2c26..b169ebe0c 100644 --- a/core/agent/src/BackendAgentCommand.cpp +++ b/core/agent/src/BackendAgentCommand.cpp @@ -2,6 +2,7 @@ #include "BackendAgentNativeProbe.h" #include "BackendAgentNativeTimerCreatePayload.h" #include "BackendAgentNativeTimerDeletePayload.h" +#include "BackendAgentNativeTimerModifyPayload.h" #include #include diff --git a/core/agent/src/BackendAgentCommandDelivery.cpp b/core/agent/src/BackendAgentCommandDelivery.cpp index 4935e93c7..153e944f0 100644 --- a/core/agent/src/BackendAgentCommandDelivery.cpp +++ b/core/agent/src/BackendAgentCommandDelivery.cpp @@ -7,6 +7,7 @@ #include "BackendAgentNativeTimerCreatePayload.h" #include "BackendAgentNativeTimerDeleteAdvertisement.h" #include "BackendAgentNativeTimerDeletePayload.h" +#include "BackendAgentNativeTimerModifyPayload.h" #include "Database.h" #include @@ -313,7 +314,10 @@ bool BackendAgentCommandRepository::localProviderSelectionCurrent( const bool nativeProbe=commandType=="vdr.native.probe"; const bool timerCreate=commandType==kBackendAgentNativeTimerCreateCommandType; const bool timerDelete=commandType==kBackendAgentNativeTimerDeleteCommandType; - if(!nativeProbe&&!timerCreate&&!timerDelete) + const bool timerModify= + commandType==kBackendAgentNativeTimerUpdateCommandType|| + commandType==kBackendAgentNativeTimerToggleCommandType; + if(!nativeProbe&&!timerCreate&&!timerDelete&&!timerModify) {sqlite3_finalize(s);reason="local_provider_selection_not_required";return true;} if(sqlite3_column_type(s,4)==SQLITE_NULL) {sqlite3_finalize(s);reason="local_provider_selection_required";return false;} @@ -483,7 +487,9 @@ BackendAgentCommandReceiptResult BackendAgentCommandRepository::acceptReceipt(co } sqlite3_finalize(existing); if(commandType==kBackendAgentNativeTimerCreateCommandType|| - commandType==kBackendAgentNativeTimerDeleteCommandType) + commandType==kBackendAgentNativeTimerDeleteCommandType|| + commandType==kBackendAgentNativeTimerUpdateCommandType|| + commandType==kBackendAgentNativeTimerToggleCommandType) { std::string providerReason; if(!localProviderSelectionCurrent(r.commandId,providerReason)) @@ -578,6 +584,27 @@ BackendAgentCommandResultAck BackendAgentCommandRepository::acceptResult(const B if(!recorded) {result.reasonCode="local_provider_selection_required";return result;} } + + else if(commandType==kBackendAgentNativeTimerUpdateCommandType|| + commandType==kBackendAgentNativeTimerToggleCommandType) + { + if(payloadVersion!=kBackendAgentNativeTimerModifyPayloadVersion) + {result.reasonCode="local_provider_selection_required";return result;} + BackendAgentNativeTimerModifyPayload modifyPayload; + std::string payloadReason; + if(!backendAgentNativeTimerModifyParsePayload(payload,modifyPayload,payloadReason)) + {result.reasonCode="local_provider_selection_invalid";return result;} + const std::string expectedIdentity=backendAgentLocalProviderSelectionIdentity( + modifyPayload.localProviderSelection); + sqlite3_stmt* selected=nullptr; + if(sqlite3_prepare_v2(database_.handle(),"SELECT selection_identity FROM backend_agent_command_provider_selections WHERE command_id=?;",-1,&selected,nullptr)!=SQLITE_OK||!bindText(selected,1,r.commandId)) + {if(selected)sqlite3_finalize(selected);result.reasonCode="command_database_unavailable";return result;} + const bool recorded=sqlite3_step(selected)==SQLITE_ROW&& + text(selected,0)==expectedIdentity; + sqlite3_finalize(selected); + if(!recorded) + {result.reasonCode="local_provider_selection_required";return result;} + } sqlite3_stmt* receipt=nullptr; if(sqlite3_prepare_v2(database_.handle(),"SELECT 1 FROM backend_agent_command_receipts WHERE command_id=?;",-1,&receipt,nullptr)!=SQLITE_OK||!bindText(receipt,1,r.commandId)){if(receipt)sqlite3_finalize(receipt);result.reasonCode="command_database_unavailable";return result;} const bool hasReceipt=sqlite3_step(receipt)==SQLITE_ROW; sqlite3_finalize(receipt); if(!hasReceipt){result.reasonCode="command_receipt_required";return result;} diff --git a/core/agent/src/BackendAgentCommandJson.cpp b/core/agent/src/BackendAgentCommandJson.cpp index 4dac3005a..1dac5e459 100644 --- a/core/agent/src/BackendAgentCommandJson.cpp +++ b/core/agent/src/BackendAgentCommandJson.cpp @@ -1,5 +1,6 @@ #include "BackendAgentCommandJson.h" #include "BackendAgentNativeTimerDelete.h" +#include "BackendAgentNativeTimerModify.h" #include #include diff --git a/mk/phase64-native-timer-modify-delivery-tests.mk b/mk/phase64-native-timer-modify-delivery-tests.mk new file mode 100644 index 000000000..c81c99d66 --- /dev/null +++ b/mk/phase64-native-timer-modify-delivery-tests.mk @@ -0,0 +1,9 @@ +.PHONY: test-phase64-native-timer-modify-delivery test-phase64-native-timer-modify-delivery-architecture + +test-phase64-native-timer-modify-delivery: + python3 tools/check_phase64_native_timer_modify_delivery.py + +test-phase64-native-timer-modify-delivery-architecture: test-phase64-native-timer-modify-delivery + +test-fast: test-phase64-native-timer-modify-delivery +test-architecture: test-phase64-native-timer-modify-delivery-architecture diff --git a/tools/check_phase64_native_timer_modify_delivery.py b/tools/check_phase64_native_timer_modify_delivery.py new file mode 100755 index 000000000..309fe0cc0 --- /dev/null +++ b/tools/check_phase64_native_timer_modify_delivery.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +def read(p):return (ROOT/p).read_text(encoding="utf-8") +command=read("core/agent/src/BackendAgentCommand.cpp") +json=read("core/agent/src/BackendAgentCommandJson.cpp") +delivery=read("core/agent/src/BackendAgentCommandDelivery.cpp") +for token in ("BackendAgentNativeTimerModifyPayload", +"kBackendAgentNativeTimerUpdateCommandType","kBackendAgentNativeTimerToggleCommandType"): + if token not in command:raise SystemExit("modify assignment validation missing: "+token) + if token not in delivery:raise SystemExit("modify delivery missing: "+token) +for token in ("kBackendAgentNativeTimerUpdateCommandType", +"kBackendAgentNativeTimerToggleCommandType"): + if token not in json:raise SystemExit("modify poll parser missing: "+token) +client=read("core/agent/src/BackendAgentCommandClient.cpp") +start=client.find("CommandAvailability availableCommands") +end=client.find("\n}",start) +for token in ("kBackendAgentNativeTimerUpdateCommandType", +"kBackendAgentNativeTimerToggleCommandType","continue;"): + if token not in client[start:end]:raise SystemExit("modify advertisement fence missing: "+token) +print("phase64 native Timer modify delivery guard passed") From b9efe6ef4089b1bb9957c9df7454657c504501d0 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:37:07 +0200 Subject: [PATCH 55/85] Complete Timer modification command validation --- core/agent/src/BackendAgentCommand.cpp | 17 +++++++++++++++++ core/agent/src/BackendAgentCommandDelivery.cpp | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/core/agent/src/BackendAgentCommand.cpp b/core/agent/src/BackendAgentCommand.cpp index b169ebe0c..8599c43c1 100644 --- a/core/agent/src/BackendAgentCommand.cpp +++ b/core/agent/src/BackendAgentCommand.cpp @@ -95,6 +95,23 @@ bool validCommandPayload(const BackendAgentCommandAssignment& value) payload.localProviderSelection.backendId == value.backendId && payload.controlPlaneClaimedAt <= value.assignedAt; } + if ((value.commandType == + vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType || + value.commandType == + vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType) && + value.payloadVersion == + vdrsuite::agent::kBackendAgentNativeTimerModifyPayloadVersion && + value.verificationPolicy == "readback_required") + { + vdrsuite::agent::BackendAgentNativeTimerModifyPayload payload; + std::string reason; + return vdrsuite::agent::backendAgentNativeTimerModifyParsePayload( + value.payload, payload, reason) && + payload.localProviderSelection.backendId == value.backendId && + payload.backendId == value.backendId && + payload.backendGeneration == value.backendGeneration && + payload.controlPlaneClaimedAt <= value.assignedAt; + } return false; } } diff --git a/core/agent/src/BackendAgentCommandDelivery.cpp b/core/agent/src/BackendAgentCommandDelivery.cpp index 153e944f0..9a4ac4d64 100644 --- a/core/agent/src/BackendAgentCommandDelivery.cpp +++ b/core/agent/src/BackendAgentCommandDelivery.cpp @@ -348,6 +348,15 @@ bool BackendAgentCommandRepository::localProviderSelectionCurrent( selection.providerKind!=kBackendAgentNativeTimerDeleteProviderKind|| selection.requiredCapability!=kBackendAgentNativeTimerDeleteCapability)) {reason="native_timer_delete_provider_selection_mismatch";return false;} + if(timerModify&& + (selection.authorityDomain!=kBackendAgentNativeTimerModifyAuthorityDomain|| + selection.providerId!=kBackendAgentNativeTimerModifyProviderId|| + selection.providerKind!=kBackendAgentNativeTimerModifyProviderKind|| + selection.requiredCapability!= + (commandType==kBackendAgentNativeTimerToggleCommandType + ?kBackendAgentNativeTimerToggleCapability + :kBackendAgentNativeTimerUpdateCapability))) + {reason="native_timer_modify_provider_selection_mismatch";return false;} const auto current=selectLocalProvider( selection.backendId,agentId,instance,backendGeneration, selection.authorityDomain,selection.requiredCapability,reason); @@ -478,6 +487,13 @@ BackendAgentCommandReceiptResult BackendAgentCommandRepository::acceptReceipt(co result.reasonCode="local_provider_selection_required"; return result; } + else if((commandType==kBackendAgentNativeTimerUpdateCommandType|| + commandType==kBackendAgentNativeTimerToggleCommandType)&& + payloadVersion!=kBackendAgentNativeTimerModifyPayloadVersion) + { + result.reasonCode="local_provider_selection_required"; + return result; + } const std::string identity=backendAgentCommandReceiptIdentity(r); sqlite3_stmt* existing=nullptr; if (sqlite3_prepare_v2(database_.handle(),"SELECT receipt_identity FROM backend_agent_command_receipts WHERE command_id=?;",-1,&existing,nullptr)!=SQLITE_OK||!bindText(existing,1,r.commandId)) { if(existing)sqlite3_finalize(existing); result.reasonCode="command_database_unavailable"; return result; } From ba47c22d85d82cafc07dd6e4d9607089db5a3aee Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:37:59 +0200 Subject: [PATCH 56/85] Restore independent CREATE and modify command paths --- core/agent/src/BackendAgentCommandClient.cpp | 51 ++++++++++---------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/core/agent/src/BackendAgentCommandClient.cpp b/core/agent/src/BackendAgentCommandClient.cpp index 595dbc6ee..0fdbed20b 100644 --- a/core/agent/src/BackendAgentCommandClient.cpp +++ b/core/agent/src/BackendAgentCommandClient.cpp @@ -342,79 +342,80 @@ bool reconcileBackendAgentCommandState( if (state.assignment.deadline <= currentTime) { - if (timerModifyCommand && !state.stateExtensionPresent && !state.resultPresent) - { - if (state.dispatchState != "not_started") - { - reason = "native_modify_fresh_starting_state_invalid"; - return false; - } - const std::int64_t currentTime = nowSeconds(); - if (state.assignment.deadline <= currentTime) - { - if (!state.receiptAcknowledged && + if (!state.receiptAcknowledged && !sendReceipt(config, context, transport, state, reason)) return false; createResult( state, "not_started", "outcome_unknown", "rejected", - "expired", "none", "command deadline expired before native dispatch"); + "expired", "none", + "command deadline expired before native dispatch"); if (!persist(config.statePath, state, reason)) return false; if (!sendResult(config, context, transport, state, reason)) return false; reason = "command_result_reconciled"; return true; } - if (!prepareFreshNativeTimerModifyLocalStarting( + if (!prepareFreshNativeTimerCreateLocalStarting( config, state, currentTime, reason)) return false; if (!state.receiptAcknowledged && !sendReceipt(config, context, transport, state, reason)) return false; - if (config.nativeTimerModifyTransport == nullptr) + if (config.nativeTimerCreateTransport == nullptr) { - reason = "native_modify_local_starting_handoff_persisted"; + reason = "native_create_local_starting_handoff_persisted"; return true; } - if (!executeFreshNativeTimerModifyAndPersistOutcome( + if (!executeFreshNativeTimerCreateAndPersistOutcome( config, context, state, reason)) return false; if (!sendResult(config, context, transport, state, reason)) return false; - reason = "native_modify_executor_outcome_reconciled"; + reason = "native_create_executor_outcome_reconciled"; return true; } - if (!state.receiptAcknowledged && + if (timerModifyCommand && !state.stateExtensionPresent && !state.resultPresent) + { + if (state.dispatchState != "not_started") + { + reason = "native_modify_fresh_starting_state_invalid"; + return false; + } + const std::int64_t currentTime = nowSeconds(); + if (state.assignment.deadline <= currentTime) + { + if (!state.receiptAcknowledged && !sendReceipt(config, context, transport, state, reason)) return false; createResult( state, "not_started", "outcome_unknown", "rejected", - "expired", "none", - "command deadline expired before native dispatch"); + "expired", "none", "command deadline expired before native dispatch"); if (!persist(config.statePath, state, reason)) return false; if (!sendResult(config, context, transport, state, reason)) return false; reason = "command_result_reconciled"; return true; } - if (!prepareFreshNativeTimerCreateLocalStarting( + if (!prepareFreshNativeTimerModifyLocalStarting( config, state, currentTime, reason)) return false; if (!state.receiptAcknowledged && !sendReceipt(config, context, transport, state, reason)) return false; - if (config.nativeTimerCreateTransport == nullptr) + if (config.nativeTimerModifyTransport == nullptr) { - reason = "native_create_local_starting_handoff_persisted"; + reason = "native_modify_local_starting_handoff_persisted"; return true; } - if (!executeFreshNativeTimerCreateAndPersistOutcome( + if (!executeFreshNativeTimerModifyAndPersistOutcome( config, context, state, reason)) return false; if (!sendResult(config, context, transport, state, reason)) return false; - reason = "native_create_executor_outcome_reconciled"; + reason = "native_modify_executor_outcome_reconciled"; return true; } + if (timerDeleteCommand && !state.stateExtensionPresent && !state.resultPresent) { if (state.dispatchState != "not_started") From f820c8eb5a9d4be12945a98da629a2ccb2602992 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:39:02 +0200 Subject: [PATCH 57/85] Strengthen native Timer modify contract regression --- core/agent/tests/test_backend_agent_native_timer_modify.cpp | 5 +++-- mk/phase64-native-timer-modify-agent-contract-tests.mk | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/agent/tests/test_backend_agent_native_timer_modify.cpp b/core/agent/tests/test_backend_agent_native_timer_modify.cpp index 6808b4ed7..358bb07d4 100644 --- a/core/agent/tests/test_backend_agent_native_timer_modify.cpp +++ b/core/agent/tests/test_backend_agent_native_timer_modify.cpp @@ -25,12 +25,13 @@ BackendAgentNativeTimerModifyPayload payload(){ } BackendAgentCommandAssignment assignment(){ BackendAgentCommandAssignment a;a.commandId="cmd_1"; - a.requestFingerprint="sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + a.requestId="req_1";a.correlationId="req_1"; a.operationId="op_1";a.commandType="vdr.timer.update";a.payloadVersion=1; a.payload=backendAgentNativeTimerModifyPayload(payload());a.verificationPolicy="readback_required"; a.jobId="job_1";a.attemptId="attempt_1";a.claimEpoch=1;a.backendId="backend_1"; a.agentId="agent_1";a.agentInstanceId="instance_1";a.backendGeneration=1; - a.deadline=1000;return a; + a.assignedAt=100;a.deadline=1000;a.requestFingerprint=backendAgentCommandFingerprint(a); + return a; } struct Transport final:IBackendAgentNativeTimerModifyTransport{ int calls=0;bool discoverProvider(BackendAgentLocalProviderFacts& f,std::string&)override{ diff --git a/mk/phase64-native-timer-modify-agent-contract-tests.mk b/mk/phase64-native-timer-modify-agent-contract-tests.mk index 90939b2b4..551036fa9 100644 --- a/mk/phase64-native-timer-modify-agent-contract-tests.mk +++ b/mk/phase64-native-timer-modify-agent-contract-tests.mk @@ -8,8 +8,11 @@ test-phase64-native-timer-modify-agent-contract: test-phase64-native-timer-modif $(BUILD_CXX) $(CXXFLAGS) \ core/agent/src/BackendAgentCommand.cpp \ core/agent/src/BackendAgentLocalProvider.cpp \ + core/agent/src/BackendAgentNativeProbe.cpp \ core/agent/src/BackendAgentNativeTimerCreate.cpp \ + core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ core/agent/src/BackendAgentNativeTimerDelete.cpp \ + core/agent/src/BackendAgentNativeTimerDeletePayload.cpp \ core/agent/src/BackendAgentNativeTimerModify.cpp \ core/agent/src/BackendAgentNativeTimerModifyPayload.cpp \ core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp \ From 0119ae7d7b74ba8c9177a352c4508ba3645c6d94 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:45:02 +0200 Subject: [PATCH 58/85] Fix native Timer modify runtime linkage --- .../agent/src/BackendAgentCommandDelivery.cpp | 82 +++++++++++++ ...ackendAgentNativeTimerDeleteAssignment.cpp | 116 ------------------ mk/agent-sources.mk | 14 +++ ..._phase64_native_timer_delete_assignment.py | 7 +- 4 files changed, 101 insertions(+), 118 deletions(-) mode change 100755 => 100644 tools/check_phase64_native_timer_delete_assignment.py diff --git a/core/agent/src/BackendAgentCommandDelivery.cpp b/core/agent/src/BackendAgentCommandDelivery.cpp index 9a4ac4d64..820db066f 100644 --- a/core/agent/src/BackendAgentCommandDelivery.cpp +++ b/core/agent/src/BackendAgentCommandDelivery.cpp @@ -170,6 +170,88 @@ bool BackendAgentCommandRepository::insertAssignment( return true; } +std::optional +BackendAgentCommandRepository::findAssignmentForOperation( + const std::string& backendId, + const std::string& operationId, + const std::string& commandType) const +{ + if (!backendAgentCommandSafeIdentifier(backendId) || + !backendAgentCommandSafeIdentifier(operationId) || + !backendAgentCommandSafeIdentifier(commandType)) + return std::nullopt; + + sqlite3_stmt* statement = nullptr; + const std::string sql = std::string("SELECT ") + kAssignmentColumns + + " FROM backend_agent_commands WHERE backend_id=? AND operation_id=? " + "AND command_type=? ORDER BY assigned_at,command_id LIMIT 1;"; + if (sqlite3_prepare_v2( + database_.handle(), sql.c_str(), -1, &statement, nullptr) != SQLITE_OK || + !bindText(statement, 1, backendId) || + !bindText(statement, 2, operationId) || + !bindText(statement, 3, commandType)) + { + if (statement != nullptr) sqlite3_finalize(statement); + return std::nullopt; + } + + std::optional result; + if (sqlite3_step(statement) == SQLITE_ROW) + result = readAssignment(statement); + sqlite3_finalize(statement); + return result; +} + +std::optional +BackendAgentCommandRepository::localProviderSelectionForCommand( + const std::string& commandId) const +{ + using namespace vdrsuite::agent; + if (!backendAgentCommandSafeIdentifier(commandId)) return std::nullopt; + + sqlite3_stmt* statement = nullptr; + const char* sql = + "SELECT selection_identity,backend_id,authority_domain,provider_id," + "provider_kind,ownership_generation,provider_instance_epoch," + "provider_generation,capability_revision,required_capability " + "FROM backend_agent_command_provider_selections WHERE command_id=?;"; + if (sqlite3_prepare_v2( + database_.handle(), sql, -1, &statement, nullptr) != SQLITE_OK || + !bindText(statement, 1, commandId)) + { + if (statement != nullptr) sqlite3_finalize(statement); + return std::nullopt; + } + + if (sqlite3_step(statement) != SQLITE_ROW) + { + sqlite3_finalize(statement); + return std::nullopt; + } + + const std::string identity = text(statement, 0); + BackendAgentLocalProviderSelection selection; + selection.backendId = text(statement, 1); + selection.authorityDomain = text(statement, 2); + selection.providerId = text(statement, 3); + selection.providerKind = text(statement, 4); + selection.ownershipGeneration = static_cast( + sqlite3_column_int64(statement, 5)); + selection.providerInstanceEpoch = text(statement, 6); + selection.providerGeneration = static_cast( + sqlite3_column_int64(statement, 7)); + selection.capabilityRevision = static_cast( + sqlite3_column_int64(statement, 8)); + selection.requiredCapability = text(statement, 9); + sqlite3_finalize(statement); + + if (!backendAgentLocalProviderValidSelection(selection) || + identity != backendAgentLocalProviderSelectionIdentity(selection)) + return std::nullopt; + return selection; +} + + bool BackendAgentCommandRepository::hasCapability(const std::string& backendId,const std::string& agentId,const std::string& agentInstanceId,std::uint64_t backendGeneration,const std::string& commandType) const { sqlite3_stmt* statement=nullptr; diff --git a/core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp b/core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp index 641cd1a93..ec2830449 100644 --- a/core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp +++ b/core/agent/src/BackendAgentNativeTimerDeleteAssignment.cpp @@ -33,41 +33,6 @@ std::string text(sqlite3_stmt* statement, int column) : std::string(); } -BackendAgentCommandAssignment readAssignment(sqlite3_stmt* statement) -{ - BackendAgentCommandAssignment assignment; - assignment.present = true; - assignment.protocolVersion = text(statement, 0); - assignment.requestId = text(statement, 1); - assignment.correlationId = text(statement, 2); - assignment.operationId = text(statement, 3); - assignment.jobId = text(statement, 4); - assignment.attemptId = text(statement, 5); - assignment.claimEpoch = static_cast( - sqlite3_column_int64(statement, 6)); - assignment.commandId = text(statement, 7); - assignment.backendId = text(statement, 8); - assignment.agentId = text(statement, 9); - assignment.agentInstanceId = text(statement, 10); - assignment.backendGeneration = static_cast( - sqlite3_column_int64(statement, 11)); - assignment.commandType = text(statement, 12); - assignment.payloadVersion = static_cast( - sqlite3_column_int64(statement, 13)); - assignment.payload = text(statement, 14); - assignment.requestFingerprint = text(statement, 15); - assignment.verificationPolicy = text(statement, 16); - assignment.assignedAt = sqlite3_column_int64(statement, 17); - assignment.deadline = sqlite3_column_int64(statement, 18); - return assignment; -} - -constexpr const char* kAssignmentColumns = - "protocol_version,request_id,correlation_id,operation_id,job_id,attempt_id," - "claim_epoch,command_id,backend_id,agent_id,agent_instance_id," - "backend_generation,command_type,payload_version,payload,request_fingerprint," - "verification_policy,assigned_at,deadline"; - bool requestValid( const RequestSecurityContext& context, const vdrsuite::agent::BackendAgentNativeTimerDeleteAssignmentRequest& request, @@ -170,87 +135,6 @@ bool BackendAgentCommandRepository::ensureNativeTimerDeleteAssignmentSchema() return true; } -std::optional -BackendAgentCommandRepository::findAssignmentForOperation( - const std::string& backendId, - const std::string& operationId, - const std::string& commandType) const -{ - if (!backendAgentCommandSafeIdentifier(backendId) || - !backendAgentCommandSafeIdentifier(operationId) || - !backendAgentCommandSafeIdentifier(commandType)) - return std::nullopt; - - sqlite3_stmt* statement = nullptr; - const std::string sql = std::string("SELECT ") + kAssignmentColumns + - " FROM backend_agent_commands WHERE backend_id=? AND operation_id=? " - "AND command_type=? ORDER BY assigned_at,command_id LIMIT 1;"; - if (sqlite3_prepare_v2( - database_.handle(), sql.c_str(), -1, &statement, nullptr) != SQLITE_OK || - !bindText(statement, 1, backendId) || - !bindText(statement, 2, operationId) || - !bindText(statement, 3, commandType)) - { - if (statement != nullptr) sqlite3_finalize(statement); - return std::nullopt; - } - - std::optional result; - if (sqlite3_step(statement) == SQLITE_ROW) - result = readAssignment(statement); - sqlite3_finalize(statement); - return result; -} - -std::optional -BackendAgentCommandRepository::localProviderSelectionForCommand( - const std::string& commandId) const -{ - using namespace vdrsuite::agent; - if (!backendAgentCommandSafeIdentifier(commandId)) return std::nullopt; - - sqlite3_stmt* statement = nullptr; - const char* sql = - "SELECT selection_identity,backend_id,authority_domain,provider_id," - "provider_kind,ownership_generation,provider_instance_epoch," - "provider_generation,capability_revision,required_capability " - "FROM backend_agent_command_provider_selections WHERE command_id=?;"; - if (sqlite3_prepare_v2( - database_.handle(), sql, -1, &statement, nullptr) != SQLITE_OK || - !bindText(statement, 1, commandId)) - { - if (statement != nullptr) sqlite3_finalize(statement); - return std::nullopt; - } - - if (sqlite3_step(statement) != SQLITE_ROW) - { - sqlite3_finalize(statement); - return std::nullopt; - } - - const std::string identity = text(statement, 0); - BackendAgentLocalProviderSelection selection; - selection.backendId = text(statement, 1); - selection.authorityDomain = text(statement, 2); - selection.providerId = text(statement, 3); - selection.providerKind = text(statement, 4); - selection.ownershipGeneration = static_cast( - sqlite3_column_int64(statement, 5)); - selection.providerInstanceEpoch = text(statement, 6); - selection.providerGeneration = static_cast( - sqlite3_column_int64(statement, 7)); - selection.capabilityRevision = static_cast( - sqlite3_column_int64(statement, 8)); - selection.requiredCapability = text(statement, 9); - sqlite3_finalize(statement); - - if (!backendAgentLocalProviderValidSelection(selection) || - identity != backendAgentLocalProviderSelectionIdentity(selection)) - return std::nullopt; - return selection; -} - namespace vdrsuite::agent { namespace diff --git a/mk/agent-sources.mk b/mk/agent-sources.mk index fe6389493..8131ae77b 100644 --- a/mk/agent-sources.mk +++ b/mk/agent-sources.mk @@ -16,6 +16,9 @@ AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC := \ AGENT_NATIVE_TIMER_DELETE_TRANSPORT_SRC := \ core/agent/src/SuiteBridgeSvdrpNativeTimerDeleteTransport.cpp +AGENT_NATIVE_TIMER_MODIFY_TRANSPORT_SRC := \ + core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp + AGENT_OBSERVATION_SRC := \ core/agent/src/SuiteBridgeObservation.cpp \ core/agent/src/SuiteBridgeObservationService.cpp \ @@ -38,6 +41,8 @@ AGENT_COMMAND_DOMAIN_SRC := \ core/agent/src/BackendAgentCommand.cpp \ core/agent/src/BackendAgentNativeTimerCreate.cpp \ core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ + core/agent/src/BackendAgentNativeTimerModify.cpp \ + core/agent/src/BackendAgentNativeTimerModifyPayload.cpp \ $(AGENT_NATIVE_PROBE_SRC) AGENT_COMMAND_JSON_SRC := \ @@ -53,12 +58,16 @@ AGENT_COMMAND_STATE_SRC := \ core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ core/agent/src/BackendAgentNativeTimerDelete.cpp \ core/agent/src/BackendAgentNativeTimerDeleteLocalState.cpp \ + core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp \ core/agent/src/BackendAgentCommandStateExtension.cpp \ core/agent/src/BackendAgentCommandStateStore.cpp AGENT_TIMER_DELETE_EXECUTOR_SRC := \ core/agent/src/BackendAgentNativeTimerDeleteExecutor.cpp +AGENT_TIMER_MODIFY_EXECUTOR_SRC := \ + core/agent/src/BackendAgentNativeTimerModifyExecutor.cpp + AGENT_NATIVE_PROBE_COMMAND_HANDLER_SRC := \ core/agent/src/BackendAgentNativeProbeCommandHandler.cpp @@ -71,13 +80,18 @@ AGENT_NATIVE_TIMER_CREATE_COMMAND_HANDLER_SRC := \ AGENT_NATIVE_TIMER_DELETE_COMMAND_HANDLER_SRC := \ core/agent/src/BackendAgentNativeTimerDeleteCommandHandler.cpp +AGENT_NATIVE_TIMER_MODIFY_COMMAND_HANDLER_SRC := \ + core/agent/src/BackendAgentNativeTimerModifyCommandHandler.cpp + AGENT_COMMAND_CLIENT_SRC := \ $(AGENT_COMMAND_STATE_SRC) \ $(AGENT_TIMER_CREATE_EXECUTOR_SRC) \ $(AGENT_TIMER_DELETE_EXECUTOR_SRC) \ + $(AGENT_TIMER_MODIFY_EXECUTOR_SRC) \ $(AGENT_NATIVE_PROBE_COMMAND_HANDLER_SRC) \ $(AGENT_NATIVE_TIMER_CREATE_COMMAND_HANDLER_SRC) \ $(AGENT_NATIVE_TIMER_DELETE_COMMAND_HANDLER_SRC) \ + $(AGENT_NATIVE_TIMER_MODIFY_COMMAND_HANDLER_SRC) \ core/agent/src/BackendAgentCommandClient.cpp AGENT_CONTROL_PLANE_DOMAIN_SRC := \ diff --git a/tools/check_phase64_native_timer_delete_assignment.py b/tools/check_phase64_native_timer_delete_assignment.py old mode 100755 new mode 100644 index 017ba2359..93a51f4b6 --- a/tools/check_phase64_native_timer_delete_assignment.py +++ b/tools/check_phase64_native_timer_delete_assignment.py @@ -63,8 +63,6 @@ def require(path: Path, markers: list[str]) -> None: "idx_backend_agent_timer_delete_operation", "trg_backend_agent_timer_delete_dormant_capability", "DELETE FROM backend_agent_command_capabilities", - "findAssignmentForOperation", - "localProviderSelectionForCommand", "selectLocalProvider", "backendAgentNativeTimerDeleteCanonicalFingerprintValid(", "backendAgentNativeTimerDeleteFingerprintToken(", @@ -76,6 +74,11 @@ def require(path: Path, markers: list[str]) -> None: "native_timer_delete_assignment_replayed", "native_timer_delete_provider_selection_stale", ]) +require(DELIVERY, [ + "findAssignmentForOperation", + "localProviderSelectionForCommand", +]) + require(COMMAND, [ 'BackendAgentNativeTimerDeletePayload.h', "kBackendAgentNativeTimerDeleteCommandType", From 1cab6d9e89dc652bf87af712f2f48b2fbd3130d7 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:47:39 +0200 Subject: [PATCH 59/85] Fix shared assignment column reference --- core/agent/src/BackendAgentCommandDelivery.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/agent/src/BackendAgentCommandDelivery.cpp b/core/agent/src/BackendAgentCommandDelivery.cpp index 820db066f..b4aaeb7c1 100644 --- a/core/agent/src/BackendAgentCommandDelivery.cpp +++ b/core/agent/src/BackendAgentCommandDelivery.cpp @@ -182,7 +182,7 @@ BackendAgentCommandRepository::findAssignmentForOperation( return std::nullopt; sqlite3_stmt* statement = nullptr; - const std::string sql = std::string("SELECT ") + kAssignmentColumns + + const std::string sql = std::string("SELECT ") + AssignmentColumns + " FROM backend_agent_commands WHERE backend_id=? AND operation_id=? " "AND command_type=? ORDER BY assigned_at,command_id LIMIT 1;"; if (sqlite3_prepare_v2( From 9cca9ea2c575bdb08f3a91ac81137f09c317ec3a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:53:28 +0200 Subject: [PATCH 60/85] Make Phase 63 guard aware of Timer modify successor --- ...heck_phase63_fenced_native_operation_runtime.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tools/check_phase63_fenced_native_operation_runtime.py b/tools/check_phase63_fenced_native_operation_runtime.py index 79780cf0b..31b2dcacd 100755 --- a/tools/check_phase63_fenced_native_operation_runtime.py +++ b/tools/check_phase63_fenced_native_operation_runtime.py @@ -417,6 +417,20 @@ def text(relative: str) -> str: "prepareFreshNativeTimerDeleteLocalStarting", ), ), + ( + "Timer-modify", + ( + "const bool timerModifyCommand =\n" + " state.assignment.commandType ==\n" + " vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType ||\n" + " state.assignment.commandType ==\n" + " vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType;" + ), + ( + "prepareFreshNativeTimerModifyLocalStarting", + "executeFreshNativeTimerModifyAndPersistOutcome", + ), + ), ) scoped_runtime_boundary = scoped_runtime From d95888b087753a5a7e7ddff3545753cc8a668c3e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Sun, 16 Aug 2026 23:54:02 +0200 Subject: [PATCH 61/85] Complete native Timer modify dispatch and verification --- Makefile | 1 + .../NativeTimerModifyDispatchService.h | 130 ++++++ ...iveTimerModifyOperationCompletionService.h | 66 +++ ...veTimerModifyReadbackVerificationService.h | 3 + .../src/NativeTimerModifyDispatchService.cpp | 419 ++++++++++++++++++ ...eTimerModifyOperationCompletionService.cpp | 242 ++++++++++ ...TimerModifyReadbackVerificationService.cpp | 6 + .../test_native_timer_modify_dispatch.cpp | 268 +++++++++++ .../phase-64-native-timer-modify-operation.md | 22 + ...se64-native-timer-modify-dispatch-tests.mk | 27 ++ ...ck_phase64_native_timer_modify_dispatch.py | 97 ++++ 11 files changed, 1281 insertions(+) create mode 100644 core/timers/include/NativeTimerModifyDispatchService.h create mode 100644 core/timers/include/NativeTimerModifyOperationCompletionService.h create mode 100644 core/timers/src/NativeTimerModifyDispatchService.cpp create mode 100644 core/timers/src/NativeTimerModifyOperationCompletionService.cpp create mode 100644 core/timers/tests/test_native_timer_modify_dispatch.cpp create mode 100644 mk/phase64-native-timer-modify-dispatch-tests.mk create mode 100755 tools/check_phase64_native_timer_modify_dispatch.py diff --git a/Makefile b/Makefile index cee02f1bf..4e7eefb05 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,7 @@ include mk/phase64-native-timer-absence-readback-verification-tests.mk include mk/phase64-mutation-operation-repository-tests.mk include mk/phase64-native-timer-create-operation-preparation-tests.mk include mk/phase64-native-timer-modify-operation-tests.mk +include mk/phase64-native-timer-modify-dispatch-tests.mk include mk/phase64-native-timer-delete-operation-completion-tests.mk include mk/phase64-native-timer-delete-operation-preparation-tests.mk include mk/phase64-native-timer-delete-dispatch-tests.mk diff --git a/core/timers/include/NativeTimerModifyDispatchService.h b/core/timers/include/NativeTimerModifyDispatchService.h new file mode 100644 index 000000000..841185b4e --- /dev/null +++ b/core/timers/include/NativeTimerModifyDispatchService.h @@ -0,0 +1,130 @@ +#pragma once + +#include "MutationOperation.h" +#include "NativeTimerModifyReadbackVerificationService.h" + +#include +#include + +namespace vdrsuite::operations +{ +class MutationOperationRepository; +} + +namespace vdrsuite::timers +{ + +class NativeTimerBindingRepository; + +enum class NativeTimerModifyDispatchClaimStatus +{ + claimed, + alreadyClaimed, + operationNotFound, + payloadNotFound, + bindingNotFound, + identityConflict, + payloadConflict, + operationStateConflict, + operationRevisionConflict, + bindingRevisionConflict, + generationConflict, + ownershipConflict, + bindingStateConflict, + operationRepositoryError, + bindingRepositoryError, + invalid, +}; + +struct NativeTimerModifyDispatchClaimRequest +{ + std::string operationId; + std::string expectedOperationRevision; +}; + +struct NativeTimerModifyDispatchClaim +{ + std::string operationId; + std::string operationRevision; + NativeTimerModifyOperationPayload payload; + std::int64_t claimedAt = 0; +}; + +struct NativeTimerModifyDispatchClaimResult +{ + NativeTimerModifyDispatchClaimStatus status = + NativeTimerModifyDispatchClaimStatus::operationRepositoryError; + vdrsuite::operations::MutationOperation operation; + NativeTimerModifyDispatchClaim claim; + + bool ok() const + { + return status == NativeTimerModifyDispatchClaimStatus::claimed || + status == NativeTimerModifyDispatchClaimStatus::alreadyClaimed; + } +}; + +enum class NativeTimerModifyExecutorOutcomeCategory +{ + rejectedWithoutEffect, + acceptedUnverified, + outcomeUnknown, +}; + +struct NativeTimerModifyExecutorOutcome +{ + NativeTimerModifyExecutorOutcomeCategory category = + NativeTimerModifyExecutorOutcomeCategory::outcomeUnknown; + std::int64_t dispatchStartedAt = 0; + std::int64_t completedAt = 0; + std::string evidenceReference; +}; + +enum class NativeTimerModifyDispatchOutcomeStatus +{ + applied, + alreadyApplied, + operationNotFound, + identityConflict, + operationStateConflict, + operationRevisionConflict, + operationRepositoryError, + invalid, +}; + +struct NativeTimerModifyDispatchOutcomeResult +{ + NativeTimerModifyDispatchOutcomeStatus status = + NativeTimerModifyDispatchOutcomeStatus::operationRepositoryError; + vdrsuite::operations::MutationOperation operation; + bool expectationPresent = false; + NativeTimerModifyReadbackExpectation expectation; + + bool ok() const + { + return status == NativeTimerModifyDispatchOutcomeStatus::applied || + status == NativeTimerModifyDispatchOutcomeStatus::alreadyApplied; + } +}; + +class NativeTimerModifyDispatchService +{ +public: + NativeTimerModifyDispatchService( + vdrsuite::operations::MutationOperationRepository& operationRepository, + NativeTimerBindingRepository& bindingRepository); + + NativeTimerModifyDispatchClaimResult claim( + const NativeTimerModifyDispatchClaimRequest& request, + std::int64_t claimedAt); + + NativeTimerModifyDispatchOutcomeResult applyOutcome( + const NativeTimerModifyDispatchClaim& claim, + const NativeTimerModifyExecutorOutcome& outcome); + +private: + vdrsuite::operations::MutationOperationRepository& operationRepository_; + NativeTimerBindingRepository& bindingRepository_; +}; + +} // namespace vdrsuite::timers diff --git a/core/timers/include/NativeTimerModifyOperationCompletionService.h b/core/timers/include/NativeTimerModifyOperationCompletionService.h new file mode 100644 index 000000000..eb36bbe6c --- /dev/null +++ b/core/timers/include/NativeTimerModifyOperationCompletionService.h @@ -0,0 +1,66 @@ +#pragma once + +#include "MutationOperation.h" +#include "NativeTimerModifyReadbackVerificationService.h" + +#include + +namespace vdrsuite::operations +{ +class MutationOperationRepository; +} + +namespace vdrsuite::timers +{ + +class NativeTimerBindingRepository; + +enum class NativeTimerModifyOperationCompletionStatus +{ + completed, + alreadyCompleted, + operationNotFound, + bindingNotFound, + identityConflict, + verificationPolicyConflict, + operationStateConflict, + verificationEvidenceMissing, + operationRevisionConflict, + operationRepositoryError, + bindingRepositoryError, + invalid, +}; + +struct NativeTimerModifyOperationCompletionResult +{ + NativeTimerModifyOperationCompletionStatus status = + NativeTimerModifyOperationCompletionStatus::operationRepositoryError; + vdrsuite::operations::MutationOperation operation; + NativeTimerBinding binding; + + bool ok() const + { + return status == + NativeTimerModifyOperationCompletionStatus::completed || + status == + NativeTimerModifyOperationCompletionStatus::alreadyCompleted; + } +}; + +class NativeTimerModifyOperationCompletionService +{ +public: + NativeTimerModifyOperationCompletionService( + vdrsuite::operations::MutationOperationRepository& operationRepository, + NativeTimerBindingRepository& bindingRepository); + + NativeTimerModifyOperationCompletionResult complete( + const NativeTimerModifyReadbackExpectation& expectation, + std::int64_t completedAt); + +private: + vdrsuite::operations::MutationOperationRepository& operationRepository_; + NativeTimerBindingRepository& bindingRepository_; +}; + +} // namespace vdrsuite::timers diff --git a/core/timers/include/NativeTimerModifyReadbackVerificationService.h b/core/timers/include/NativeTimerModifyReadbackVerificationService.h index 96107d881..a3a8f4b14 100644 --- a/core/timers/include/NativeTimerModifyReadbackVerificationService.h +++ b/core/timers/include/NativeTimerModifyReadbackVerificationService.h @@ -2,6 +2,7 @@ #include "NativeTimerModifyOperationPayload.h" #include "NativeTimerObservation.h" +#include "NativeTimerReadbackExpectation.h" namespace vdrsuite::timers { @@ -28,6 +29,8 @@ enum class NativeTimerModifyReadbackVerificationStatus struct NativeTimerModifyReadbackExpectation { std::string operationId; + NativeTimerReadbackOperationState operationState = + NativeTimerReadbackOperationState::executedUnverified; NativeTimerModifyOperationPayload payload; std::int64_t readbackNotBefore = 0; }; diff --git a/core/timers/src/NativeTimerModifyDispatchService.cpp b/core/timers/src/NativeTimerModifyDispatchService.cpp new file mode 100644 index 000000000..a67b5d098 --- /dev/null +++ b/core/timers/src/NativeTimerModifyDispatchService.cpp @@ -0,0 +1,419 @@ +#include "NativeTimerModifyDispatchService.h" + +#include "MutationOperationRepository.h" +#include "NativeTimerBindingRepository.h" + +#include + +namespace vdrsuite::timers +{ +namespace +{ +using vdrsuite::operations::MutationOperation; +using vdrsuite::operations::MutationOperationPayload; +using vdrsuite::operations::MutationOperationRepositoryStatus; +using vdrsuite::operations::MutationOperationState; +using vdrsuite::operations::MutationOperationVerificationPolicy; + +constexpr std::size_t MaxIdentity = 160; +constexpr std::size_t MaxEvidenceReference = 512; + +bool identity(const std::string& value) +{ + return !value.empty() && value.size() <= MaxIdentity; +} + +bool suiteManaged(NativeTimerBindingOwnership ownership) +{ + return ownership == NativeTimerBindingOwnership::managed || + ownership == NativeTimerBindingOwnership::adopted; +} + +const char* actionFamily(NativeTimerModifyKind kind) +{ + return kind == NativeTimerModifyKind::update + ? "timer.update" : "timer.toggle"; +} + +bool durablePayloadMatches( + const MutationOperationPayload& durable, + NativeTimerModifyOperationPayload& payload) +{ + if (durable.payloadType != "native.timer.modify" || + durable.payloadVersion != 1 || + !parseNativeTimerModifyOperationPayload(durable.payload, payload)) + return false; + return durable.payloadFingerprint == + nativeTimerModifyOperationPayloadFingerprint(payload) && + durable.payload == + serializeNativeTimerModifyOperationPayload(payload); +} + +bool operationMatchesPayload( + const MutationOperation& operation, + const NativeTimerModifyOperationPayload& payload) +{ + return operation.backendId == payload.backendId && + operation.backendGeneration == payload.backendGeneration && + operation.resourceType == "NativeTimerBinding" && + operation.resourceId == payload.nativeTimerBindingId && + operation.expectedRevision == payload.expectedBindingRevision && + operation.expectedResourceFingerprint == + payload.expectedCurrentFingerprint && + operation.actionFamily == actionFamily(payload.kind) && + operation.verificationPolicy == + MutationOperationVerificationPolicy::readbackRequired; +} + +bool bindingMatchesPayload( + const NativeTimerBinding& binding, + const NativeTimerModifyOperationPayload& payload) +{ + return binding.nativeTimerBindingId == payload.nativeTimerBindingId && + binding.bindingRevision == payload.expectedBindingRevision && + binding.timerAssignmentId == payload.timerAssignmentId && + binding.backendId == payload.backendId && + binding.backendGeneration == payload.backendGeneration && + binding.backendNativeTimerId == payload.backendNativeTimerId; +} + +bool bindingDispatchable( + const NativeTimerBinding& binding, + const NativeTimerModifyOperationPayload& payload) +{ + return bindingMatchesPayload(binding, payload) && + suiteManaged(binding.ownership) && + binding.observedFingerprint == payload.expectedCurrentFingerprint && + binding.missingSince == 0 && + binding.driftState == NativeTimerBindingDriftState::none && + !binding.observedState.recording && + !binding.observedState.pending; +} + +NativeTimerModifyDispatchClaim claimFrom( + const MutationOperation& operation, + const NativeTimerModifyOperationPayload& payload, + std::int64_t claimedAt) +{ + NativeTimerModifyDispatchClaim claim; + claim.operationId = operation.operationId; + claim.operationRevision = operation.operationRevision; + claim.payload = payload; + claim.claimedAt = claimedAt; + return claim; +} + +NativeTimerModifyDispatchClaimResult claimResult( + NativeTimerModifyDispatchClaimStatus status, + const MutationOperation& operation = {}, + const NativeTimerModifyDispatchClaim& claim = {}) +{ + NativeTimerModifyDispatchClaimResult result; + result.status = status; + result.operation = operation; + result.claim = claim; + return result; +} + +NativeTimerModifyDispatchOutcomeResult outcomeResult( + NativeTimerModifyDispatchOutcomeStatus status, + const MutationOperation& operation = {}, + const NativeTimerModifyReadbackExpectation* expectation = nullptr) +{ + NativeTimerModifyDispatchOutcomeResult result; + result.status = status; + result.operation = operation; + if (expectation != nullptr) + { + result.expectationPresent = true; + result.expectation = *expectation; + } + return result; +} + +bool claimValid(const NativeTimerModifyDispatchClaim& claim) +{ + return identity(claim.operationId) && + identity(claim.operationRevision) && + nativeTimerModifyOperationPayloadValid(claim.payload) && + claim.claimedAt > 0; +} + +MutationOperationState outcomeState( + NativeTimerModifyExecutorOutcomeCategory category) +{ + switch (category) + { + case NativeTimerModifyExecutorOutcomeCategory::rejectedWithoutEffect: + return MutationOperationState::failedVerified; + case NativeTimerModifyExecutorOutcomeCategory::acceptedUnverified: + return MutationOperationState::executedUnverified; + case NativeTimerModifyExecutorOutcomeCategory::outcomeUnknown: + return MutationOperationState::outcomeUnknown; + } + return MutationOperationState::outcomeUnknown; +} + +bool outcomeValid( + const NativeTimerModifyDispatchClaim& claim, + const NativeTimerModifyExecutorOutcome& outcome) +{ + if (!claimValid(claim) || + outcome.completedAt < claim.claimedAt || + outcome.evidenceReference.empty() || + outcome.evidenceReference.size() > MaxEvidenceReference) + return false; + + switch (outcome.category) + { + case NativeTimerModifyExecutorOutcomeCategory::rejectedWithoutEffect: + return outcome.dispatchStartedAt == 0; + case NativeTimerModifyExecutorOutcomeCategory::acceptedUnverified: + case NativeTimerModifyExecutorOutcomeCategory::outcomeUnknown: + return outcome.dispatchStartedAt >= claim.claimedAt && + outcome.dispatchStartedAt <= outcome.completedAt; + } + return false; +} + +NativeTimerModifyReadbackExpectation expectationFor( + const NativeTimerModifyDispatchClaim& claim, + const NativeTimerModifyExecutorOutcome& outcome) +{ + NativeTimerModifyReadbackExpectation expectation; + expectation.operationId = claim.operationId; + expectation.operationState = + outcome.category == + NativeTimerModifyExecutorOutcomeCategory::acceptedUnverified + ? NativeTimerReadbackOperationState::executedUnverified + : NativeTimerReadbackOperationState::outcomeUnknown; + expectation.payload = claim.payload; + expectation.readbackNotBefore = outcome.dispatchStartedAt; + return expectation; +} +} // namespace + +NativeTimerModifyDispatchService::NativeTimerModifyDispatchService( + vdrsuite::operations::MutationOperationRepository& operationRepository, + NativeTimerBindingRepository& bindingRepository) + : operationRepository_(operationRepository), + bindingRepository_(bindingRepository) +{ +} + +NativeTimerModifyDispatchClaimResult NativeTimerModifyDispatchService::claim( + const NativeTimerModifyDispatchClaimRequest& request, + std::int64_t claimedAt) +{ + if (!identity(request.operationId) || + !identity(request.expectedOperationRevision) || + claimedAt <= 0) + return claimResult(NativeTimerModifyDispatchClaimStatus::invalid); + + const auto foundOperation = + operationRepository_.findById(request.operationId); + if (foundOperation.status == MutationOperationRepositoryStatus::notFound) + return claimResult( + NativeTimerModifyDispatchClaimStatus::operationNotFound); + if (!foundOperation.ok()) + return claimResult( + NativeTimerModifyDispatchClaimStatus::operationRepositoryError); + + const MutationOperation& operation = foundOperation.operation; + if (operation.state != MutationOperationState::accepted && + operation.state != MutationOperationState::dispatching) + return claimResult( + NativeTimerModifyDispatchClaimStatus::operationStateConflict, + operation); + if (operation.state == MutationOperationState::accepted && + operation.operationRevision != request.expectedOperationRevision) + return claimResult( + NativeTimerModifyDispatchClaimStatus::operationRevisionConflict, + operation); + + const auto foundPayload = + operationRepository_.findPayloadByOperationId(request.operationId); + if (foundPayload.status == MutationOperationRepositoryStatus::notFound) + return claimResult( + NativeTimerModifyDispatchClaimStatus::payloadNotFound, + operation); + if (!foundPayload.ok()) + return claimResult( + NativeTimerModifyDispatchClaimStatus::operationRepositoryError, + operation); + + NativeTimerModifyOperationPayload payload; + if (!durablePayloadMatches(foundPayload.payload, payload)) + return claimResult( + NativeTimerModifyDispatchClaimStatus::payloadConflict, + operation); + if (!operationMatchesPayload(operation, payload)) + return claimResult( + NativeTimerModifyDispatchClaimStatus::identityConflict, + operation); + + const auto foundBinding = + bindingRepository_.findById(payload.nativeTimerBindingId); + if (foundBinding.status == NativeTimerBindingRepositoryStatus::notFound) + return claimResult( + NativeTimerModifyDispatchClaimStatus::bindingNotFound, + operation); + if (!foundBinding.ok()) + return claimResult( + NativeTimerModifyDispatchClaimStatus::bindingRepositoryError, + operation); + + const NativeTimerBinding& binding = foundBinding.binding; + if (!bindingMatchesPayload(binding, payload)) + { + if (binding.bindingRevision != payload.expectedBindingRevision) + return claimResult( + NativeTimerModifyDispatchClaimStatus::bindingRevisionConflict, + operation); + if (binding.backendGeneration != payload.backendGeneration) + return claimResult( + NativeTimerModifyDispatchClaimStatus::generationConflict, + operation); + return claimResult( + NativeTimerModifyDispatchClaimStatus::identityConflict, + operation); + } + if (!suiteManaged(binding.ownership)) + return claimResult( + NativeTimerModifyDispatchClaimStatus::ownershipConflict, + operation); + if (!bindingDispatchable(binding, payload)) + return claimResult( + NativeTimerModifyDispatchClaimStatus::bindingStateConflict, + operation); + + if (operation.state == MutationOperationState::dispatching) + { + return claimResult( + NativeTimerModifyDispatchClaimStatus::alreadyClaimed, + operation, + claimFrom(operation, payload, operation.updatedAt)); + } + + const auto transitioned = operationRepository_.transition( + operation.operationId, + operation.operationRevision, + MutationOperationState::accepted, + MutationOperationState::dispatching, + "", + claimedAt); + if (transitioned.status == MutationOperationRepositoryStatus::ok) + { + return claimResult( + NativeTimerModifyDispatchClaimStatus::claimed, + transitioned.operation, + claimFrom(transitioned.operation, payload, claimedAt)); + } + if (transitioned.status == + MutationOperationRepositoryStatus::idempotentReplay) + { + return claimResult( + NativeTimerModifyDispatchClaimStatus::alreadyClaimed, + transitioned.operation, + claimFrom( + transitioned.operation, + payload, + transitioned.operation.updatedAt)); + } + if (transitioned.status == + MutationOperationRepositoryStatus::revisionConflict) + return claimResult( + NativeTimerModifyDispatchClaimStatus::operationRevisionConflict, + transitioned.operation); + if (transitioned.status == + MutationOperationRepositoryStatus::stateConflict) + return claimResult( + NativeTimerModifyDispatchClaimStatus::operationStateConflict, + transitioned.operation); + return claimResult( + NativeTimerModifyDispatchClaimStatus::operationRepositoryError, + transitioned.operation); +} + +NativeTimerModifyDispatchOutcomeResult +NativeTimerModifyDispatchService::applyOutcome( + const NativeTimerModifyDispatchClaim& claim, + const NativeTimerModifyExecutorOutcome& outcome) +{ + if (!outcomeValid(claim, outcome)) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::invalid); + + const auto found = operationRepository_.findById(claim.operationId); + if (found.status == MutationOperationRepositoryStatus::notFound) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::operationNotFound); + if (!found.ok()) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::operationRepositoryError); + + const MutationOperation& operation = found.operation; + if (!operationMatchesPayload(operation, claim.payload)) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::identityConflict, + operation); + + const MutationOperationState target = outcomeState(outcome.category); + if (operation.state != MutationOperationState::dispatching && + operation.state != target) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::operationStateConflict, + operation); + if (operation.state == MutationOperationState::dispatching && + operation.operationRevision != claim.operationRevision) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::operationRevisionConflict, + operation); + + NativeTimerModifyReadbackExpectation expectation; + const bool needsReadback = + outcome.category != + NativeTimerModifyExecutorOutcomeCategory::rejectedWithoutEffect; + if (needsReadback) + { + expectation = expectationFor(claim, outcome); + if (!nativeTimerModifyReadbackExpectationValid(expectation)) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::invalid, + operation); + } + + const auto transitioned = operationRepository_.transition( + operation.operationId, + claim.operationRevision, + MutationOperationState::dispatching, + target, + outcome.evidenceReference, + outcome.completedAt); + if (transitioned.status == MutationOperationRepositoryStatus::ok) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::applied, + transitioned.operation, + needsReadback ? &expectation : nullptr); + if (transitioned.status == + MutationOperationRepositoryStatus::idempotentReplay) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::alreadyApplied, + transitioned.operation, + needsReadback ? &expectation : nullptr); + if (transitioned.status == + MutationOperationRepositoryStatus::revisionConflict) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::operationRevisionConflict, + transitioned.operation); + if (transitioned.status == + MutationOperationRepositoryStatus::stateConflict) + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::operationStateConflict, + transitioned.operation); + return outcomeResult( + NativeTimerModifyDispatchOutcomeStatus::operationRepositoryError, + transitioned.operation); +} + +} // namespace vdrsuite::timers diff --git a/core/timers/src/NativeTimerModifyOperationCompletionService.cpp b/core/timers/src/NativeTimerModifyOperationCompletionService.cpp new file mode 100644 index 000000000..66a7a6e76 --- /dev/null +++ b/core/timers/src/NativeTimerModifyOperationCompletionService.cpp @@ -0,0 +1,242 @@ +#include "NativeTimerModifyOperationCompletionService.h" + +#include "MutationOperationRepository.h" +#include "NativeTimerBindingRepository.h" +#include "NativeTimerSpecification.h" + +#include + +namespace vdrsuite::timers +{ +namespace +{ +using vdrsuite::operations::MutationOperation; +using vdrsuite::operations::MutationOperationRepositoryStatus; +using vdrsuite::operations::MutationOperationState; +using vdrsuite::operations::MutationOperationVerificationPolicy; + +NativeTimerModifyOperationCompletionResult result( + NativeTimerModifyOperationCompletionStatus status, + const MutationOperation& operation = {}, + const NativeTimerBinding& binding = {}) +{ + NativeTimerModifyOperationCompletionResult value; + value.status = status; + value.operation = operation; + value.binding = binding; + return value; +} + +MutationOperationState operationState( + NativeTimerReadbackOperationState state) +{ + switch (state) + { + case NativeTimerReadbackOperationState::executedUnverified: + return MutationOperationState::executedUnverified; + case NativeTimerReadbackOperationState::outcomeUnknown: + return MutationOperationState::outcomeUnknown; + } + return MutationOperationState::conflict; +} + +bool suiteManaged(NativeTimerBindingOwnership ownership) +{ + return ownership == NativeTimerBindingOwnership::managed || + ownership == NativeTimerBindingOwnership::adopted; +} + +const char* actionFamily(NativeTimerModifyKind kind) +{ + return kind == NativeTimerModifyKind::update + ? "timer.update" : "timer.toggle"; +} + +std::string completionReference( + const NativeTimerModifyReadbackExpectation& expectation) +{ + return "native-timer-modify-readback:" + + expectation.payload.nativeTimerBindingId + + ":operation:" + expectation.operationId; +} + +bool operationIdentityMatches( + const MutationOperation& operation, + const NativeTimerModifyReadbackExpectation& expectation) +{ + const auto& payload = expectation.payload; + return operation.operationId == expectation.operationId && + operation.backendId == payload.backendId && + operation.backendGeneration == payload.backendGeneration && + operation.resourceType == "NativeTimerBinding" && + operation.resourceId == payload.nativeTimerBindingId && + operation.expectedRevision == payload.expectedBindingRevision && + operation.expectedResourceFingerprint == + payload.expectedCurrentFingerprint && + operation.actionFamily == actionFamily(payload.kind); +} + +bool bindingEvidenceMatches( + const NativeTimerBinding& binding, + const NativeTimerModifyReadbackExpectation& expectation) +{ + const auto& payload = expectation.payload; + return binding.nativeTimerBindingId == payload.nativeTimerBindingId && + binding.timerAssignmentId == payload.timerAssignmentId && + binding.backendId == payload.backendId && + binding.backendGeneration == payload.backendGeneration && + binding.backendNativeTimerId == payload.backendNativeTimerId && + binding.lastVerifiedOperationId == expectation.operationId && + binding.lastObservedAt >= expectation.readbackNotBefore && + binding.missingSince == 0 && + binding.driftState == NativeTimerBindingDriftState::none && + suiteManaged(binding.ownership) && + nativeTimerObservationMatchesSpecification( + payload.expectedSpecification, + binding.observedState); +} +} // namespace + +NativeTimerModifyOperationCompletionService:: +NativeTimerModifyOperationCompletionService( + vdrsuite::operations::MutationOperationRepository& operationRepository, + NativeTimerBindingRepository& bindingRepository) + : operationRepository_(operationRepository), + bindingRepository_(bindingRepository) +{ +} + +NativeTimerModifyOperationCompletionResult +NativeTimerModifyOperationCompletionService::complete( + const NativeTimerModifyReadbackExpectation& expectation, + std::int64_t completedAt) +{ + if (!nativeTimerModifyReadbackExpectationValid(expectation) || + completedAt <= 0) + return result( + NativeTimerModifyOperationCompletionStatus::invalid); + + const auto foundOperation = + operationRepository_.findById(expectation.operationId); + if (foundOperation.status == MutationOperationRepositoryStatus::notFound) + return result( + NativeTimerModifyOperationCompletionStatus::operationNotFound); + if (!foundOperation.ok()) + return result( + NativeTimerModifyOperationCompletionStatus:: + operationRepositoryError); + + const MutationOperation& operation = foundOperation.operation; + if (!operationIdentityMatches(operation, expectation)) + return result( + NativeTimerModifyOperationCompletionStatus::identityConflict, + operation); + if (operation.verificationPolicy != + MutationOperationVerificationPolicy::readbackRequired) + return result( + NativeTimerModifyOperationCompletionStatus:: + verificationPolicyConflict, + operation); + + const std::string resultReference = completionReference(expectation); + if (operation.state == MutationOperationState::succeeded) + { + return result( + operation.resultReference == resultReference + ? NativeTimerModifyOperationCompletionStatus::alreadyCompleted + : NativeTimerModifyOperationCompletionStatus:: + operationStateConflict, + operation); + } + + if (operation.state != operationState(expectation.operationState)) + return result( + NativeTimerModifyOperationCompletionStatus:: + operationStateConflict, + operation); + if (completedAt < operation.updatedAt) + return result( + NativeTimerModifyOperationCompletionStatus::invalid, + operation); + + const auto foundBinding = + bindingRepository_.findById( + expectation.payload.nativeTimerBindingId); + if (foundBinding.status == + NativeTimerBindingRepositoryStatus::notFound) + return result( + NativeTimerModifyOperationCompletionStatus::bindingNotFound, + operation); + if (!foundBinding.ok()) + return result( + NativeTimerModifyOperationCompletionStatus:: + bindingRepositoryError, + operation); + + const NativeTimerBinding& binding = foundBinding.binding; + if (!bindingEvidenceMatches(binding, expectation)) + return result( + NativeTimerModifyOperationCompletionStatus:: + verificationEvidenceMissing, + operation, + binding); + if (completedAt < binding.lastObservedAt) + return result( + NativeTimerModifyOperationCompletionStatus::invalid, + operation, + binding); + + const auto transitioned = operationRepository_.transition( + operation.operationId, + operation.operationRevision, + operation.state, + MutationOperationState::succeeded, + resultReference, + completedAt); + + switch (transitioned.status) + { + case MutationOperationRepositoryStatus::ok: + return result( + NativeTimerModifyOperationCompletionStatus::completed, + transitioned.operation, + binding); + case MutationOperationRepositoryStatus::idempotentReplay: + return result( + NativeTimerModifyOperationCompletionStatus::alreadyCompleted, + transitioned.operation, + binding); + case MutationOperationRepositoryStatus::notFound: + return result( + NativeTimerModifyOperationCompletionStatus::operationNotFound, + operation, + binding); + case MutationOperationRepositoryStatus::revisionConflict: + return result( + NativeTimerModifyOperationCompletionStatus:: + operationRevisionConflict, + transitioned.operation, + binding); + case MutationOperationRepositoryStatus::stateConflict: + return result( + NativeTimerModifyOperationCompletionStatus:: + operationStateConflict, + transitioned.operation, + binding); + case MutationOperationRepositoryStatus::invalid: + case MutationOperationRepositoryStatus::idempotencyConflict: + case MutationOperationRepositoryStatus::operationConflict: + case MutationOperationRepositoryStatus::storageError: + return result( + NativeTimerModifyOperationCompletionStatus:: + operationRepositoryError, + transitioned.operation, + binding); + } + return result( + NativeTimerModifyOperationCompletionStatus::operationRepositoryError, + operation, + binding); +} + +} // namespace vdrsuite::timers diff --git a/core/timers/src/NativeTimerModifyReadbackVerificationService.cpp b/core/timers/src/NativeTimerModifyReadbackVerificationService.cpp index d3b6d2da4..6ddd435a4 100644 --- a/core/timers/src/NativeTimerModifyReadbackVerificationService.cpp +++ b/core/timers/src/NativeTimerModifyReadbackVerificationService.cpp @@ -40,8 +40,14 @@ bool alreadyVerified( bool nativeTimerModifyReadbackExpectationValid( const NativeTimerModifyReadbackExpectation& expectation) { + const bool stateValid = + expectation.operationState == + NativeTimerReadbackOperationState::executedUnverified || + expectation.operationState == + NativeTimerReadbackOperationState::outcomeUnknown; return !expectation.operationId.empty() && expectation.operationId.size() <= 160 + && stateValid && nativeTimerModifyOperationPayloadValid(expectation.payload) && expectation.readbackNotBefore > 0; } diff --git a/core/timers/tests/test_native_timer_modify_dispatch.cpp b/core/timers/tests/test_native_timer_modify_dispatch.cpp new file mode 100644 index 000000000..4df34e0b6 --- /dev/null +++ b/core/timers/tests/test_native_timer_modify_dispatch.cpp @@ -0,0 +1,268 @@ +#include "Database.h" +#include "MutationOperationRepository.h" +#include "NativeTimerBindingRepository.h" +#include "NativeTimerModifyDispatchService.h" +#include "NativeTimerModifyOperationCompletionService.h" +#include "NativeTimerModifyReadbackVerificationService.h" +#include "NativeTimerSpecification.h" + +#include +#include +#include + +using namespace vdrsuite::operations; +using namespace vdrsuite::timers; + +namespace +{ +NativeTimerSpecification specification( + NativeTimerModifyKind kind, + bool enabled = true) +{ + NativeTimerSpecification value; + value.channelId = "S19.2E-1-1019-10301"; + value.title = kind == NativeTimerModifyKind::update + ? "Updated managed Timer" : "Managed Timer"; + value.directory = "VDR-Suite"; + value.day = "2026-08-18"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1015"; + value.priority = 50; + value.lifetime = 99; + value.enabled = enabled; + value.vps = false; + return value; +} + +NativeTimerObservedState observedState( + const NativeTimerSpecification& specification) +{ + NativeTimerObservedState value; + value.channelId = specification.channelId; + value.title = specification.title; + value.directory = specification.directory; + value.day = specification.day; + value.weekdays = specification.weekdays; + value.startTime = specification.startTime; + value.endTime = specification.endTime; + value.flags = specification.enabled ? 1 : 0; + value.priority = specification.priority; + value.lifetime = specification.lifetime; + value.enabled = specification.enabled; + value.vps = specification.vps; + return value; +} + +NativeTimerBinding createBinding( + NativeTimerBindingRepository& repository, + const std::string& suffix) +{ + NativeTimerBinding binding; + binding.nativeTimerBindingId = "binding:modify:" + suffix; + binding.backendId = "backend:1"; + binding.backendGeneration = 7; + binding.backendNativeTimerId = "native:" + suffix; + binding.timerAssignmentId = "assignment:modify:" + suffix; + binding.ownership = NativeTimerBindingOwnership::managed; + const auto before = specification( + NativeTimerModifyKind::toggle, true); + binding.observedState = observedState(before); + binding.observedFingerprint = + nativeTimerObservedStateFingerprint(binding.observedState); + binding.lastObservedAt = 50; + binding.lastVerifiedOperationId = "operation:create:" + suffix; + const auto created = repository.create(binding); + assert(created.ok()); + return created.binding; +} + +NativeTimerModifyOperationPayload payloadFor( + const NativeTimerBinding& binding, + NativeTimerModifyKind kind) +{ + NativeTimerModifyOperationPayload payload; + payload.kind = kind; + payload.timerAssignmentId = binding.timerAssignmentId; + payload.expectedAssignmentRevision = "assignment-revision:1"; + payload.expectedIntentRevision = "intent-revision:1"; + payload.assignmentEpoch = 1; + payload.nativeTimerBindingId = binding.nativeTimerBindingId; + payload.expectedBindingRevision = binding.bindingRevision; + payload.backendId = binding.backendId; + payload.backendGeneration = binding.backendGeneration; + payload.backendNativeTimerId = binding.backendNativeTimerId; + payload.expectedCurrentFingerprint = binding.observedFingerprint; + payload.expectedSpecification = specification( + kind, kind == NativeTimerModifyKind::toggle ? false : true); + return payload; +} + +MutationOperation reserve( + MutationOperationRepository& repository, + const NativeTimerModifyOperationPayload& payload, + const std::string& suffix, + std::int64_t requestedAt) +{ + MutationOperation operation; + operation.operationId = "operation:modify:" + suffix; + operation.idempotencyKey = "idempotency:modify:" + suffix; + operation.actorId = "actor:owner"; + operation.backendId = payload.backendId; + operation.backendGeneration = payload.backendGeneration; + operation.resourceType = "NativeTimerBinding"; + operation.resourceId = payload.nativeTimerBindingId; + operation.expectedRevision = payload.expectedBindingRevision; + operation.expectedResourceFingerprint = + payload.expectedCurrentFingerprint; + operation.actionFamily = + payload.kind == NativeTimerModifyKind::update + ? "timer.update" : "timer.toggle"; + operation.requestFingerprint = "request:modify:" + suffix; + operation.requestedAt = requestedAt; + operation.deadline = requestedAt + 600; + operation.verificationPolicy = + MutationOperationVerificationPolicy::readbackRequired; + operation.state = MutationOperationState::accepted; + operation.updatedAt = requestedAt; + + MutationOperationPayload durable; + durable.operationId = operation.operationId; + durable.payloadType = "native.timer.modify"; + durable.payloadVersion = 1; + durable.payload = serializeNativeTimerModifyOperationPayload(payload); + durable.payloadFingerprint = + nativeTimerModifyOperationPayloadFingerprint(payload); + const auto reserved = repository.reserveWithPayload(operation, durable); + assert(reserved.ok()); + return reserved.operation; +} + +void verifyRoundTrip( + MutationOperationRepository& operations, + NativeTimerBindingRepository& bindings, + NativeTimerModifyKind kind, + NativeTimerModifyExecutorOutcomeCategory category, + const std::string& suffix) +{ + const NativeTimerBinding binding = createBinding(bindings, suffix); + const auto payload = payloadFor(binding, kind); + const MutationOperation operation = + reserve(operations, payload, suffix, 100); + + NativeTimerModifyDispatchService dispatch(operations, bindings); + NativeTimerModifyDispatchClaimRequest request; + request.operationId = operation.operationId; + request.expectedOperationRevision = operation.operationRevision; + + const auto claimed = dispatch.claim(request, 105); + assert(claimed.status == + NativeTimerModifyDispatchClaimStatus::claimed); + assert(claimed.operation.state == MutationOperationState::dispatching); + assert(claimed.claim.payload.nativeTimerBindingId == + binding.nativeTimerBindingId); + assert(dispatch.claim(request, 106).status == + NativeTimerModifyDispatchClaimStatus::alreadyClaimed); + + NativeTimerModifyExecutorOutcome outcome; + outcome.category = category; + outcome.dispatchStartedAt = 110; + outcome.completedAt = 120; + outcome.evidenceReference = "suitebridge:ntmod:" + suffix; + const auto applied = dispatch.applyOutcome(claimed.claim, outcome); + assert(applied.status == + NativeTimerModifyDispatchOutcomeStatus::applied); + assert(applied.expectationPresent); + assert(applied.expectation.readbackNotBefore == 110); + assert(applied.expectation.operationState == + (category == + NativeTimerModifyExecutorOutcomeCategory::acceptedUnverified + ? NativeTimerReadbackOperationState::executedUnverified + : NativeTimerReadbackOperationState::outcomeUnknown)); + + NativeTimerObservation observation; + observation.backendId = binding.backendId; + observation.backendGeneration = binding.backendGeneration; + observation.backendNativeTimerId = binding.backendNativeTimerId; + observation.observedAt = 130; + observation.observedState = + observedState(payload.expectedSpecification); + observation.observedFingerprint = + nativeTimerObservedStateFingerprint(observation.observedState); + + NativeTimerModifyReadbackVerificationService verification(bindings); + const auto verified = + verification.verify(applied.expectation, observation); + assert(verified.status == + NativeTimerModifyReadbackVerificationStatus::verified); + assert(verified.binding.lastVerifiedOperationId == + operation.operationId); + + NativeTimerModifyOperationCompletionService completion( + operations, bindings); + const auto completed = completion.complete(applied.expectation, 140); + assert(completed.status == + NativeTimerModifyOperationCompletionStatus::completed); + assert(completed.operation.state == MutationOperationState::succeeded); + assert(completion.complete(applied.expectation, 140).status == + NativeTimerModifyOperationCompletionStatus::alreadyCompleted); +} + +void verifyRejectedWithoutEffect( + MutationOperationRepository& operations, + NativeTimerBindingRepository& bindings) +{ + const NativeTimerBinding binding = + createBinding(bindings, "rejected"); + const auto payload = payloadFor( + binding, NativeTimerModifyKind::update); + const MutationOperation operation = + reserve(operations, payload, "rejected", 200); + + NativeTimerModifyDispatchService dispatch(operations, bindings); + NativeTimerModifyDispatchClaimRequest request; + request.operationId = operation.operationId; + request.expectedOperationRevision = operation.operationRevision; + const auto claimed = dispatch.claim(request, 205); + assert(claimed.ok()); + + NativeTimerModifyExecutorOutcome outcome; + outcome.category = + NativeTimerModifyExecutorOutcomeCategory::rejectedWithoutEffect; + outcome.completedAt = 210; + outcome.evidenceReference = "suitebridge:ntmod:rejected"; + const auto applied = dispatch.applyOutcome(claimed.claim, outcome); + assert(applied.ok()); + assert(!applied.expectationPresent); + assert(applied.operation.state == + MutationOperationState::failedVerified); +} +} // namespace + +int main() +{ + Database database; + assert(database.open(":memory:")); + + MutationOperationRepository operations(database); + NativeTimerBindingRepository bindings(database); + assert(operations.ensureSchema()); + assert(bindings.ensureSchema()); + + verifyRoundTrip( + operations, + bindings, + NativeTimerModifyKind::update, + NativeTimerModifyExecutorOutcomeCategory::acceptedUnverified, + "update"); + verifyRoundTrip( + operations, + bindings, + NativeTimerModifyKind::toggle, + NativeTimerModifyExecutorOutcomeCategory::outcomeUnknown, + "toggle"); + verifyRejectedWithoutEffect(operations, bindings); + + std::cout << "test_native_timer_modify_dispatch passed\n"; + return 0; +} diff --git a/docs/development/phase-64-native-timer-modify-operation.md b/docs/development/phase-64-native-timer-modify-operation.md index 21b22297b..d96ab0d96 100644 --- a/docs/development/phase-64-native-timer-modify-operation.md +++ b/docs/development/phase-64-native-timer-modify-operation.md @@ -16,3 +16,25 @@ Completion requires an authoritative PRESENT observation at or after the dispatch boundary. The observation must match the stable native identity and the complete desired specification. Only then is the copied binding state and last verified operation advanced under optimistic concurrency. + +## Dispatch, recovery and terminal verification + +The durable modify payload is now the only source for the dispatch handoff after +restart. Claiming reloads and validates that payload, rechecks the current +binding revision, generation, native identity, predecessor fingerprint and safe +non-recording state, then revision-fences the operation from `accepted` to +`dispatching`. Reclaiming the exact dispatch is idempotent; it never authorizes +a second native write. + +Executor outcomes map into the shared mutation lifecycle: + +- `rejected_without_effect` becomes `failed_verified` without readback; +- `accepted_unverified` becomes `executed_unverified`; +- `outcome_unknown` stays reconciliation-only as `outcome_unknown`. + +The latter two produce a PRESENT-readback expectation whose lower time boundary +is the executor's durable dispatch-start timestamp. Verification accepts both +states, updates the binding only from an authoritative matching observation, +and completion advances the operation to `succeeded` only after that persisted +binding names the operation as its last verified mutation. Exact claim, outcome +and completion replays are idempotent. diff --git a/mk/phase64-native-timer-modify-dispatch-tests.mk b/mk/phase64-native-timer-modify-dispatch-tests.mk new file mode 100644 index 000000000..d3957649e --- /dev/null +++ b/mk/phase64-native-timer-modify-dispatch-tests.mk @@ -0,0 +1,27 @@ +.PHONY: test-phase64-native-timer-modify-dispatch-architecture test-phase64-native-timer-modify-dispatch + +test-phase64-native-timer-modify-dispatch-architecture: + python3 tools/check_phase64_native_timer_modify_dispatch.py + +test-phase64-native-timer-modify-dispatch: test-phase64-native-timer-modify-dispatch-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/operations/include -Icore/timers/include \ + $(SQLITE_SRC) \ + core/operations/src/MutationOperation.cpp \ + core/operations/src/MutationOperationRepository.cpp \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerBindingRepository.cpp \ + core/timers/src/NativeTimerBindingReadRepository.cpp \ + core/timers/src/NativeTimerBindingWriteRepository.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/src/NativeTimerObservation.cpp \ + core/timers/src/NativeTimerModifyOperationPayload.cpp \ + core/timers/src/NativeTimerModifyReadbackVerificationService.cpp \ + core/timers/src/NativeTimerModifyDispatchService.cpp \ + core/timers/src/NativeTimerModifyOperationCompletionService.cpp \ + core/timers/tests/test_native_timer_modify_dispatch.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_modify_dispatch + $(BUILD_DIR)/test_native_timer_modify_dispatch + +test-fast: test-phase64-native-timer-modify-dispatch +test-architecture: test-phase64-native-timer-modify-dispatch-architecture diff --git a/tools/check_phase64_native_timer_modify_dispatch.py b/tools/check_phase64_native_timer_modify_dispatch.py new file mode 100755 index 000000000..15fb86b26 --- /dev/null +++ b/tools/check_phase64_native_timer_modify_dispatch.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parent.parent + +files = { + "dispatch header": ROOT / "core/timers/include/NativeTimerModifyDispatchService.h", + "dispatch source": ROOT / "core/timers/src/NativeTimerModifyDispatchService.cpp", + "completion header": ROOT / "core/timers/include/NativeTimerModifyOperationCompletionService.h", + "completion source": ROOT / "core/timers/src/NativeTimerModifyOperationCompletionService.cpp", + "readback header": ROOT / "core/timers/include/NativeTimerModifyReadbackVerificationService.h", + "readback source": ROOT / "core/timers/src/NativeTimerModifyReadbackVerificationService.cpp", + "test": ROOT / "core/timers/tests/test_native_timer_modify_dispatch.cpp", + "fragment": ROOT / "mk/phase64-native-timer-modify-dispatch-tests.mk", + "makefile": ROOT / "Makefile", +} + +failures = [] +for label, path in files.items(): + if not path.is_file(): + failures.append(f"missing {label}: {path.relative_to(ROOT)}") + +def require(label, needles): + path = files[label] + if not path.is_file(): + return + text = path.read_text(encoding="utf-8") + for needle in needles: + if needle not in text: + failures.append(f"{label} missing required contract: {needle}") + +require("dispatch header", [ + "NativeTimerModifyDispatchClaim", + "NativeTimerModifyExecutorOutcomeCategory", + "NativeTimerModifyReadbackExpectation", + "applyOutcome", +]) +require("dispatch source", [ + 'payloadType != "native.timer.modify"', + "findPayloadByOperationId", + "expectedCurrentFingerprint", + "MutationOperationState::dispatching", + "MutationOperationState::executedUnverified", + "MutationOperationState::outcomeUnknown", + "MutationOperationState::failedVerified", + "readbackNotBefore = outcome.dispatchStartedAt", +]) +require("completion source", [ + "lastVerifiedOperationId == expectation.operationId", + "nativeTimerObservationMatchesSpecification", + "MutationOperationState::succeeded", + "native-timer-modify-readback:", +]) +require("readback header", [ + "NativeTimerReadbackOperationState operationState", +]) +require("readback source", [ + "NativeTimerReadbackOperationState::executedUnverified", + "NativeTimerReadbackOperationState::outcomeUnknown", +]) +require("test", [ + "acceptedUnverified", + "outcomeUnknown", + "rejectedWithoutEffect", + "alreadyClaimed", + "alreadyCompleted", +]) +require("fragment", [ + "test-phase64-native-timer-modify-dispatch", + "NativeTimerModifyDispatchService.cpp", + "NativeTimerModifyOperationCompletionService.cpp", + "test-fast: test-phase64-native-timer-modify-dispatch", + "test-architecture: test-phase64-native-timer-modify-dispatch-architecture", +]) +require("makefile", [ + "include mk/phase64-native-timer-modify-dispatch-tests.mk", +]) + +for label in ("dispatch source", "completion source"): + path = files[label] + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + for forbidden in ("BackendAgent", "SuiteBridge", "SVDRP", "system(", "popen("): + if forbidden in text: + failures.append( + f"{label} contains forbidden transport coupling: {forbidden}" + ) + +if failures: + print("Phase-64 native Timer modify dispatch check failed:", file=sys.stderr) + for failure in failures: + print("- " + failure, file=sys.stderr) + raise SystemExit(1) + +print("Phase-64 native Timer modify dispatch check passed") From 959876417620addf21436960e9dafcd9aa4f3373 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 00:01:05 +0200 Subject: [PATCH 62/85] Link Timer modify validation into isolated Agent tests --- mk/phase64-native-timer-create-agent-contract-tests.mk | 2 ++ mk/phase64-native-timer-create-local-state-tests.mk | 2 ++ mk/phase64-suitebridge-native-timer-create-transport-tests.mk | 2 ++ 3 files changed, 6 insertions(+) diff --git a/mk/phase64-native-timer-create-agent-contract-tests.mk b/mk/phase64-native-timer-create-agent-contract-tests.mk index ac1671b3f..1de5fb624 100644 --- a/mk/phase64-native-timer-create-agent-contract-tests.mk +++ b/mk/phase64-native-timer-create-agent-contract-tests.mk @@ -9,6 +9,8 @@ test-phase64-native-timer-create-agent-contract: test-phase64-native-timer-creat core/agent/src/BackendAgentNativeProbe.cpp \ core/agent/src/BackendAgentNativeTimerCreate.cpp \ core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ + core/agent/src/BackendAgentNativeTimerModify.cpp \ + core/agent/src/BackendAgentNativeTimerModifyPayload.cpp \ core/agent/src/BackendAgentCommand.cpp \ core/agent/tests/test_backend_agent_native_timer_create_contract.cpp \ $(LDFLAGS) \ diff --git a/mk/phase64-native-timer-create-local-state-tests.mk b/mk/phase64-native-timer-create-local-state-tests.mk index 6664b2e8d..d77f8288c 100644 --- a/mk/phase64-native-timer-create-local-state-tests.mk +++ b/mk/phase64-native-timer-create-local-state-tests.mk @@ -9,6 +9,8 @@ test-phase64-native-timer-create-local-state: test-phase64-native-timer-create-l core/agent/src/BackendAgentNativeProbe.cpp \ core/agent/src/BackendAgentNativeTimerCreate.cpp \ core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ + core/agent/src/BackendAgentNativeTimerModify.cpp \ + core/agent/src/BackendAgentNativeTimerModifyPayload.cpp \ core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ core/agent/src/BackendAgentCommand.cpp \ diff --git a/mk/phase64-suitebridge-native-timer-create-transport-tests.mk b/mk/phase64-suitebridge-native-timer-create-transport-tests.mk index b87289778..2831f128a 100644 --- a/mk/phase64-suitebridge-native-timer-create-transport-tests.mk +++ b/mk/phase64-suitebridge-native-timer-create-transport-tests.mk @@ -12,6 +12,8 @@ test-phase64-suitebridge-native-timer-create-transport: \ core/agent/src/BackendAgentNativeProbe.cpp \ core/agent/src/BackendAgentNativeTimerCreate.cpp \ core/agent/src/BackendAgentNativeTimerCreatePayload.cpp \ + core/agent/src/BackendAgentNativeTimerModify.cpp \ + core/agent/src/BackendAgentNativeTimerModifyPayload.cpp \ $(AGENT_SVDRP_TRANSPORT_STANDALONE_SRC) \ $(AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC) \ core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp \ From eb609e10ad700adf907ff2a55049f1cb3d333574 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 00:01:55 +0200 Subject: [PATCH 63/85] Complete native Timer CREATE outcome and binding flow --- Makefile | 1 + .../NativeTimerCreateDispatchService.h | 52 ++++ ...iveTimerCreateOperationCompletionService.h | 74 +++++ .../src/NativeTimerCreateDispatchService.cpp | 262 +++++++++++++++++ ...eTimerCreateOperationCompletionService.cpp | 273 ++++++++++++++++++ ...native_timer_create_outcome_completion.cpp | 231 +++++++++++++++ ...tive-timer-create-operation-preparation.md | 18 ++ ...64-native-timer-create-activation-tests.mk | 1 + ...ative-timer-create-dispatch-state-tests.mk | 1 + ...e-timer-create-outcome-completion-tests.mk | 32 ++ ..._native_timer_create_outcome_completion.py | 86 ++++++ 11 files changed, 1031 insertions(+) create mode 100644 core/timers/include/NativeTimerCreateOperationCompletionService.h create mode 100644 core/timers/src/NativeTimerCreateOperationCompletionService.cpp create mode 100644 core/timers/tests/test_native_timer_create_outcome_completion.cpp create mode 100644 mk/phase64-native-timer-create-outcome-completion-tests.mk create mode 100755 tools/check_phase64_native_timer_create_outcome_completion.py diff --git a/Makefile b/Makefile index 4e7eefb05..82871b1f6 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ include mk/phase64-backend-agent-command-reservation-tests.mk include mk/phase64-native-timer-create-agent-contract-tests.mk include mk/phase64-native-timer-create-reservation-tests.mk include mk/phase64-native-timer-create-dispatch-state-tests.mk +include mk/phase64-native-timer-create-outcome-completion-tests.mk include mk/phase64-native-timer-create-delivery-tests.mk include mk/phase64-native-timer-create-activation-tests.mk include mk/phase64-native-timer-create-local-state-tests.mk diff --git a/core/timers/include/NativeTimerCreateDispatchService.h b/core/timers/include/NativeTimerCreateDispatchService.h index e11aaed4e..ce618cb9c 100644 --- a/core/timers/include/NativeTimerCreateDispatchService.h +++ b/core/timers/include/NativeTimerCreateDispatchService.h @@ -1,6 +1,7 @@ #pragma once #include "MutationOperation.h" +#include "NativeTimerCreateReadbackExpectation.h" #include #include @@ -68,6 +69,54 @@ struct NativeTimerCreateDispatchClaimResult } }; +enum class NativeTimerCreateExecutorOutcomeCategory +{ + rejectedWithoutEffect, + acceptedUnverified, + outcomeUnknown, +}; + +struct NativeTimerCreateExecutorOutcome +{ + std::string operationId; + std::string operationRevision; + NativeTimerCreateCommandReservationReference reservation; + NativeTimerCreateExecutorOutcomeCategory category = + NativeTimerCreateExecutorOutcomeCategory::outcomeUnknown; + std::int64_t dispatchStartedAt = 0; + std::int64_t completedAt = 0; + std::string evidenceReference; +}; + +enum class NativeTimerCreateDispatchOutcomeStatus +{ + applied, + alreadyApplied, + operationNotFound, + payloadNotFound, + identityConflict, + payloadConflict, + operationStateConflict, + operationRevisionConflict, + operationRepositoryError, + invalid, +}; + +struct NativeTimerCreateDispatchOutcomeResult +{ + NativeTimerCreateDispatchOutcomeStatus status = + NativeTimerCreateDispatchOutcomeStatus::operationRepositoryError; + vdrsuite::operations::MutationOperation operation; + bool expectationPresent = false; + NativeTimerCreateReadbackExpectation expectation; + + bool ok() const + { + return status == NativeTimerCreateDispatchOutcomeStatus::applied || + status == NativeTimerCreateDispatchOutcomeStatus::alreadyApplied; + } +}; + class NativeTimerCreateDispatchService { public: @@ -78,6 +127,9 @@ class NativeTimerCreateDispatchService const NativeTimerCreateDispatchClaimRequest& request, std::int64_t claimedAt); + NativeTimerCreateDispatchOutcomeResult applyOutcome( + const NativeTimerCreateExecutorOutcome& outcome); + private: vdrsuite::operations::MutationOperationRepository& operationRepository_; }; diff --git a/core/timers/include/NativeTimerCreateOperationCompletionService.h b/core/timers/include/NativeTimerCreateOperationCompletionService.h new file mode 100644 index 000000000..72863c5d1 --- /dev/null +++ b/core/timers/include/NativeTimerCreateOperationCompletionService.h @@ -0,0 +1,74 @@ +#pragma once + +#include "MutationOperation.h" +#include "NativeTimerBinding.h" +#include "NativeTimerCreateReadbackExpectation.h" +#include "TimerAssignment.h" + +#include + +namespace vdrsuite::operations +{ +class MutationOperationRepository; +} + +namespace vdrsuite::timers +{ + +class NativeTimerBindingRepository; +class TimerAssignmentRepository; + +enum class NativeTimerCreateOperationCompletionStatus +{ + completed, + alreadyCompleted, + operationNotFound, + assignmentNotFound, + bindingNotFound, + identityConflict, + verificationPolicyConflict, + operationStateConflict, + verificationEvidenceMissing, + operationRevisionConflict, + operationRepositoryError, + assignmentRepositoryError, + bindingRepositoryError, + invalid, +}; + +struct NativeTimerCreateOperationCompletionResult +{ + NativeTimerCreateOperationCompletionStatus status = + NativeTimerCreateOperationCompletionStatus::operationRepositoryError; + vdrsuite::operations::MutationOperation operation; + TimerAssignment assignment; + NativeTimerBinding binding; + + bool ok() const + { + return status == + NativeTimerCreateOperationCompletionStatus::completed || + status == + NativeTimerCreateOperationCompletionStatus::alreadyCompleted; + } +}; + +class NativeTimerCreateOperationCompletionService +{ +public: + NativeTimerCreateOperationCompletionService( + vdrsuite::operations::MutationOperationRepository& operationRepository, + TimerAssignmentRepository& assignmentRepository, + NativeTimerBindingRepository& bindingRepository); + + NativeTimerCreateOperationCompletionResult complete( + const NativeTimerCreateReadbackExpectation& expectation, + std::int64_t completedAt); + +private: + vdrsuite::operations::MutationOperationRepository& operationRepository_; + TimerAssignmentRepository& assignmentRepository_; + NativeTimerBindingRepository& bindingRepository_; +}; + +} // namespace vdrsuite::timers diff --git a/core/timers/src/NativeTimerCreateDispatchService.cpp b/core/timers/src/NativeTimerCreateDispatchService.cpp index 1a9245265..d228546d7 100644 --- a/core/timers/src/NativeTimerCreateDispatchService.cpp +++ b/core/timers/src/NativeTimerCreateDispatchService.cpp @@ -18,6 +18,7 @@ constexpr const char* kReferencePrefix = constexpr std::size_t kMaxIdentityLength = 160; constexpr std::size_t kMaxFingerprintLength = 512; constexpr std::size_t kMaxResourceFingerprintLength = 4096; +constexpr std::size_t kMaxEvidenceReferenceLength = 512; bool boundedIdentity(const std::string& value) { @@ -277,4 +278,265 @@ NativeTimerCreateDispatchService::claimAfterReservation( return result(NativeTimerCreateDispatchClaimStatus::operationRepositoryError); } + +namespace +{ +NativeTimerCreateDispatchOutcomeResult outcomeResult( + NativeTimerCreateDispatchOutcomeStatus status, + const vdrsuite::operations::MutationOperation& operation = {}, + const NativeTimerCreateReadbackExpectation* expectation = nullptr) +{ + NativeTimerCreateDispatchOutcomeResult value; + value.status = status; + value.operation = operation; + if (expectation != nullptr) + { + value.expectationPresent = true; + value.expectation = *expectation; + } + return value; +} + +bool outcomeShapeValid(const NativeTimerCreateExecutorOutcome& outcome) +{ + if (!boundedIdentity(outcome.operationId) || + !boundedIdentity(outcome.operationRevision) || + !nativeTimerCreateCommandReservationReferenceValid( + outcome.reservation) || + outcome.completedAt <= 0 || + outcome.evidenceReference.empty() || + outcome.evidenceReference.size() > kMaxEvidenceReferenceLength) + return false; + + switch (outcome.category) + { + case NativeTimerCreateExecutorOutcomeCategory::rejectedWithoutEffect: + return outcome.dispatchStartedAt == 0; + case NativeTimerCreateExecutorOutcomeCategory::acceptedUnverified: + case NativeTimerCreateExecutorOutcomeCategory::outcomeUnknown: + return outcome.dispatchStartedAt > 0 && + outcome.dispatchStartedAt <= outcome.completedAt; + } + return false; +} + +vdrsuite::operations::MutationOperationState outcomeState( + NativeTimerCreateExecutorOutcomeCategory category) +{ + using vdrsuite::operations::MutationOperationState; + switch (category) + { + case NativeTimerCreateExecutorOutcomeCategory::rejectedWithoutEffect: + return MutationOperationState::failedVerified; + case NativeTimerCreateExecutorOutcomeCategory::acceptedUnverified: + return MutationOperationState::executedUnverified; + case NativeTimerCreateExecutorOutcomeCategory::outcomeUnknown: + return MutationOperationState::outcomeUnknown; + } + return MutationOperationState::outcomeUnknown; +} + +bool operationMatchesPayload( + const vdrsuite::operations::MutationOperation& operation, + const NativeTimerCreateOperationPayload& payload) +{ + const std::string fingerprint = + nativeTimerSpecificationFingerprint(payload.expectedSpecification); + return operation.backendId == payload.backendId && + operation.backendGeneration == payload.backendGeneration && + operation.resourceType == "TimerAssignment" && + operation.resourceId == payload.timerAssignmentId && + operation.expectedRevision == payload.expectedAssignmentRevision && + operation.expectedResourceFingerprint == fingerprint && + operation.actionFamily == "timer.create" && + operation.verificationPolicy == + vdrsuite::operations::MutationOperationVerificationPolicy:: + readbackRequired; +} + +NativeTimerCreateReadbackExpectation expectationFor( + const NativeTimerCreateExecutorOutcome& outcome, + const NativeTimerCreateOperationPayload& payload) +{ + NativeTimerCreateReadbackExpectation expectation; + expectation.operationId = outcome.operationId; + expectation.operationState = + outcome.category == + NativeTimerCreateExecutorOutcomeCategory::acceptedUnverified + ? NativeTimerReadbackOperationState::executedUnverified + : NativeTimerReadbackOperationState::outcomeUnknown; + expectation.timerAssignmentId = payload.timerAssignmentId; + expectation.nativeTimerBindingId = payload.nativeTimerBindingId; + expectation.backendId = payload.backendId; + expectation.backendGeneration = payload.backendGeneration; + expectation.readbackNotBefore = outcome.dispatchStartedAt; + expectation.expectedSpecification = payload.expectedSpecification; + expectation.expectedSpecificationFingerprint = + nativeTimerSpecificationFingerprint(payload.expectedSpecification); + return expectation; +} +} // namespace + +NativeTimerCreateDispatchOutcomeResult +NativeTimerCreateDispatchService::applyOutcome( + const NativeTimerCreateExecutorOutcome& outcome) +{ + using namespace vdrsuite::operations; + if (!outcomeShapeValid(outcome)) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::invalid); + + const auto found = operationRepository_.findById(outcome.operationId); + if (found.status == MutationOperationRepositoryStatus::notFound) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::operationNotFound); + if (!found.ok()) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus:: + operationRepositoryError); + const MutationOperation& operation = found.operation; + + const MutationOperationState target = outcomeState(outcome.category); + if (operation.state != MutationOperationState::dispatching && + operation.state != target) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::operationStateConflict, + operation); + if (operation.state == MutationOperationState::dispatching && + operation.operationRevision != outcome.operationRevision) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus:: + operationRevisionConflict, + operation); + if (outcome.completedAt < operation.updatedAt) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::invalid, + operation); + if (operation.state == MutationOperationState::dispatching && + outcome.category != + NativeTimerCreateExecutorOutcomeCategory::rejectedWithoutEffect && + outcome.dispatchStartedAt < operation.updatedAt) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::invalid, + operation); + + if (operation.state == MutationOperationState::dispatching) + { + NativeTimerCreateCommandReservationReference reserved; + if (!parseNativeTimerCreateCommandReservationReference( + operation.resultReference, reserved) || + reserved.commandId != outcome.reservation.commandId || + reserved.requestFingerprint != + outcome.reservation.requestFingerprint) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::identityConflict, + operation); + } + else if (operation.resultReference != outcome.evidenceReference) + { + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::operationStateConflict, + operation); + } + + const auto durable = + operationRepository_.findPayloadByOperationId(outcome.operationId); + if (durable.status == MutationOperationRepositoryStatus::notFound) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::payloadNotFound, + operation); + if (!durable.ok()) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus:: + operationRepositoryError, + operation); + if (durable.payload.payloadType != "native.timer.create" || + durable.payload.payloadVersion != 1) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::payloadConflict, + operation); + + NativeTimerCreateOperationPayload payload; + if (!parseNativeTimerCreateOperationPayload( + durable.payload.payload, payload) || + durable.payload.payload != + serializeNativeTimerCreateOperationPayload(payload) || + durable.payload.payloadFingerprint != + nativeTimerCreateOperationPayloadFingerprint(payload)) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::payloadConflict, + operation); + if (!operationMatchesPayload(operation, payload)) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::identityConflict, + operation); + + NativeTimerCreateReadbackExpectation expectation; + const bool needsReadback = + outcome.category != + NativeTimerCreateExecutorOutcomeCategory::rejectedWithoutEffect; + if (needsReadback) + { + expectation = expectationFor(outcome, payload); + if (!nativeTimerCreateReadbackExpectationValid(expectation)) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::invalid, + operation); + } + + if (operation.state == target) + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::alreadyApplied, + operation, + needsReadback ? &expectation : nullptr); + + const auto transitioned = operationRepository_.transition( + operation.operationId, + outcome.operationRevision, + MutationOperationState::dispatching, + target, + outcome.evidenceReference, + outcome.completedAt); + switch (transitioned.status) + { + case MutationOperationRepositoryStatus::ok: + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::applied, + transitioned.operation, + needsReadback ? &expectation : nullptr); + case MutationOperationRepositoryStatus::idempotentReplay: + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::alreadyApplied, + transitioned.operation, + needsReadback ? &expectation : nullptr); + case MutationOperationRepositoryStatus::notFound: + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::operationNotFound); + case MutationOperationRepositoryStatus::revisionConflict: + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus:: + operationRevisionConflict, + transitioned.operation); + case MutationOperationRepositoryStatus::stateConflict: + case MutationOperationRepositoryStatus::idempotencyConflict: + case MutationOperationRepositoryStatus::operationConflict: + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus:: + operationStateConflict, + transitioned.operation); + case MutationOperationRepositoryStatus::invalid: + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::invalid, + transitioned.operation); + case MutationOperationRepositoryStatus::storageError: + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus:: + operationRepositoryError, + transitioned.operation); + } + return outcomeResult( + NativeTimerCreateDispatchOutcomeStatus::operationRepositoryError, + operation); +} + } // namespace vdrsuite::timers diff --git a/core/timers/src/NativeTimerCreateOperationCompletionService.cpp b/core/timers/src/NativeTimerCreateOperationCompletionService.cpp new file mode 100644 index 000000000..e0bd9c2d5 --- /dev/null +++ b/core/timers/src/NativeTimerCreateOperationCompletionService.cpp @@ -0,0 +1,273 @@ +#include "NativeTimerCreateOperationCompletionService.h" + +#include "MutationOperationRepository.h" +#include "NativeTimerBindingRepository.h" +#include "NativeTimerSpecification.h" +#include "TimerAssignmentRepository.h" + +#include + +namespace vdrsuite::timers +{ +namespace +{ +using vdrsuite::operations::MutationOperation; +using vdrsuite::operations::MutationOperationRepositoryStatus; +using vdrsuite::operations::MutationOperationState; +using vdrsuite::operations::MutationOperationVerificationPolicy; + +NativeTimerCreateOperationCompletionResult result( + NativeTimerCreateOperationCompletionStatus status, + const MutationOperation& operation = {}, + const TimerAssignment& assignment = {}, + const NativeTimerBinding& binding = {}) +{ + NativeTimerCreateOperationCompletionResult value; + value.status = status; + value.operation = operation; + value.assignment = assignment; + value.binding = binding; + return value; +} + +MutationOperationState operationState( + NativeTimerReadbackOperationState state) +{ + switch (state) + { + case NativeTimerReadbackOperationState::executedUnverified: + return MutationOperationState::executedUnverified; + case NativeTimerReadbackOperationState::outcomeUnknown: + return MutationOperationState::outcomeUnknown; + } + return MutationOperationState::conflict; +} + +std::string completionReference( + const NativeTimerCreateReadbackExpectation& expectation) +{ + return "native-timer-create-readback:" + + expectation.nativeTimerBindingId + + ":operation:" + expectation.operationId; +} + +bool operationIdentityMatches( + const MutationOperation& operation, + const NativeTimerCreateReadbackExpectation& expectation) +{ + return operation.operationId == expectation.operationId && + operation.backendId == expectation.backendId && + operation.backendGeneration == expectation.backendGeneration && + operation.resourceType == "TimerAssignment" && + operation.resourceId == expectation.timerAssignmentId && + operation.expectedResourceFingerprint == + expectation.expectedSpecificationFingerprint && + operation.actionFamily == "timer.create"; +} + +bool assignmentEvidenceMatches( + const TimerAssignment& assignment, + const NativeTimerCreateReadbackExpectation& expectation) +{ + return assignment.timerAssignmentId == expectation.timerAssignmentId && + assignment.backendId == expectation.backendId && + assignment.backendGeneration == expectation.backendGeneration && + assignment.nativeTimerBindingId == + expectation.nativeTimerBindingId && + assignment.state == TimerAssignmentState::bound; +} + +bool bindingEvidenceMatches( + const NativeTimerBinding& binding, + const NativeTimerCreateReadbackExpectation& expectation) +{ + return binding.nativeTimerBindingId == + expectation.nativeTimerBindingId && + binding.timerAssignmentId == expectation.timerAssignmentId && + binding.backendId == expectation.backendId && + binding.backendGeneration == expectation.backendGeneration && + !binding.backendNativeTimerId.empty() && + binding.ownership == NativeTimerBindingOwnership::managed && + binding.lastVerifiedOperationId == expectation.operationId && + binding.lastObservedAt >= expectation.readbackNotBefore && + binding.missingSince == 0 && + binding.driftState == NativeTimerBindingDriftState::none && + nativeTimerObservationMatchesSpecification( + expectation.expectedSpecification, + binding.observedState); +} +} // namespace + +NativeTimerCreateOperationCompletionService:: +NativeTimerCreateOperationCompletionService( + vdrsuite::operations::MutationOperationRepository& operationRepository, + TimerAssignmentRepository& assignmentRepository, + NativeTimerBindingRepository& bindingRepository) + : operationRepository_(operationRepository), + assignmentRepository_(assignmentRepository), + bindingRepository_(bindingRepository) +{ +} + +NativeTimerCreateOperationCompletionResult +NativeTimerCreateOperationCompletionService::complete( + const NativeTimerCreateReadbackExpectation& expectation, + std::int64_t completedAt) +{ + if (!nativeTimerCreateReadbackExpectationValid(expectation) || + completedAt <= 0) + return result( + NativeTimerCreateOperationCompletionStatus::invalid); + + const auto foundOperation = + operationRepository_.findById(expectation.operationId); + if (foundOperation.status == MutationOperationRepositoryStatus::notFound) + return result( + NativeTimerCreateOperationCompletionStatus::operationNotFound); + if (!foundOperation.ok()) + return result( + NativeTimerCreateOperationCompletionStatus:: + operationRepositoryError); + + const MutationOperation& operation = foundOperation.operation; + if (!operationIdentityMatches(operation, expectation)) + return result( + NativeTimerCreateOperationCompletionStatus::identityConflict, + operation); + if (operation.verificationPolicy != + MutationOperationVerificationPolicy::readbackRequired) + return result( + NativeTimerCreateOperationCompletionStatus:: + verificationPolicyConflict, + operation); + + const std::string resultReference = completionReference(expectation); + if (operation.state == MutationOperationState::succeeded) + { + return result( + operation.resultReference == resultReference + ? NativeTimerCreateOperationCompletionStatus::alreadyCompleted + : NativeTimerCreateOperationCompletionStatus:: + operationStateConflict, + operation); + } + if (operation.state != operationState(expectation.operationState)) + return result( + NativeTimerCreateOperationCompletionStatus:: + operationStateConflict, + operation); + if (completedAt < operation.updatedAt) + return result( + NativeTimerCreateOperationCompletionStatus::invalid, + operation); + + const auto foundAssignment = + assignmentRepository_.findById(expectation.timerAssignmentId); + if (foundAssignment.status == TimerAssignmentRepositoryStatus::notFound) + return result( + NativeTimerCreateOperationCompletionStatus::assignmentNotFound, + operation); + if (!foundAssignment.ok()) + return result( + NativeTimerCreateOperationCompletionStatus:: + assignmentRepositoryError, + operation); + + const TimerAssignment& assignment = foundAssignment.assignment; + if (!assignmentEvidenceMatches(assignment, expectation)) + return result( + NativeTimerCreateOperationCompletionStatus:: + verificationEvidenceMissing, + operation, + assignment); + + const auto foundBinding = + bindingRepository_.findById(expectation.nativeTimerBindingId); + if (foundBinding.status == NativeTimerBindingRepositoryStatus::notFound) + return result( + NativeTimerCreateOperationCompletionStatus::bindingNotFound, + operation, + assignment); + if (!foundBinding.ok()) + return result( + NativeTimerCreateOperationCompletionStatus:: + bindingRepositoryError, + operation, + assignment); + + const NativeTimerBinding& binding = foundBinding.binding; + if (!bindingEvidenceMatches(binding, expectation)) + return result( + NativeTimerCreateOperationCompletionStatus:: + verificationEvidenceMissing, + operation, + assignment, + binding); + if (completedAt < binding.lastObservedAt || + completedAt < assignment.updatedAt) + return result( + NativeTimerCreateOperationCompletionStatus::invalid, + operation, + assignment, + binding); + + const auto transitioned = operationRepository_.transition( + operation.operationId, + operation.operationRevision, + operation.state, + MutationOperationState::succeeded, + resultReference, + completedAt); + switch (transitioned.status) + { + case MutationOperationRepositoryStatus::ok: + return result( + NativeTimerCreateOperationCompletionStatus::completed, + transitioned.operation, + assignment, + binding); + case MutationOperationRepositoryStatus::idempotentReplay: + return result( + NativeTimerCreateOperationCompletionStatus::alreadyCompleted, + transitioned.operation, + assignment, + binding); + case MutationOperationRepositoryStatus::notFound: + return result( + NativeTimerCreateOperationCompletionStatus::operationNotFound, + operation, + assignment, + binding); + case MutationOperationRepositoryStatus::revisionConflict: + return result( + NativeTimerCreateOperationCompletionStatus:: + operationRevisionConflict, + transitioned.operation, + assignment, + binding); + case MutationOperationRepositoryStatus::stateConflict: + return result( + NativeTimerCreateOperationCompletionStatus:: + operationStateConflict, + transitioned.operation, + assignment, + binding); + case MutationOperationRepositoryStatus::invalid: + case MutationOperationRepositoryStatus::idempotencyConflict: + case MutationOperationRepositoryStatus::operationConflict: + case MutationOperationRepositoryStatus::storageError: + return result( + NativeTimerCreateOperationCompletionStatus:: + operationRepositoryError, + transitioned.operation, + assignment, + binding); + } + return result( + NativeTimerCreateOperationCompletionStatus::operationRepositoryError, + operation, + assignment, + binding); +} + +} // namespace vdrsuite::timers diff --git a/core/timers/tests/test_native_timer_create_outcome_completion.cpp b/core/timers/tests/test_native_timer_create_outcome_completion.cpp new file mode 100644 index 000000000..6d5355aae --- /dev/null +++ b/core/timers/tests/test_native_timer_create_outcome_completion.cpp @@ -0,0 +1,231 @@ +#include "Database.h" +#include "MutationOperationRepository.h" +#include "NativeTimerBindingRepository.h" +#include "NativeTimerCreateDispatchService.h" +#include "NativeTimerCreateOperationCompletionService.h" +#include "NativeTimerCreateOperationPayload.h" +#include "NativeTimerCreateReadbackEvidence.h" +#include "NativeTimerCreateReadbackVerificationService.h" +#include "NativeTimerSpecification.h" +#include "TimerAssignmentFulfillmentService.h" +#include "TimerAssignmentRepository.h" + +#include +#include + +using namespace vdrsuite::operations; +using namespace vdrsuite::timers; + +namespace +{ +NativeTimerSpecification specification() +{ + NativeTimerSpecification value; + value.channelId = "S19.2E-1-1019-10301"; + value.title = "Managed create"; + value.directory = "VDR-Suite"; + value.day = "2026-08-18"; + value.weekdays = "-------"; + value.startTime = "0930"; + value.endTime = "1015"; + value.priority = 50; + value.lifetime = 99; + value.enabled = true; + value.vps = false; + return value; +} + +TimerAssignment provisioningAssignment( + TimerAssignmentRepository& repository) +{ + TimerAssignment assignment; + assignment.timerAssignmentId = "assignment:create:outcome"; + assignment.timerIntentId = "intent:create:outcome"; + assignment.intentRevision = "intent-revision:1"; + assignment.assignmentEpoch = 1; + assignment.backendId = "backend:1"; + assignment.backendGeneration = 7; + assignment.state = TimerAssignmentState::provisioning; + assignment.role = TimerAssignmentRole::primary; + assignment.channelBinding.canonicalChannelId = "channel:ard"; + assignment.channelBinding.backendChannelId = specification().channelId; + assignment.channelBinding.mappingSource = "map"; + assignment.channelBinding.mappingRevision = "map:1"; + assignment.capabilityRevision = "cap:1"; + assignment.backendHealthRevision = "health:1"; + assignment.decisionPolicyVersion = "policy:1"; + assignment.decisionEvidence.reasons = {"selected"}; + assignment.createdAt = 90; + assignment.updatedAt = 90; + const auto created = repository.create(assignment); + assert(created.ok()); + return created.assignment; +} + +NativeTimerObservedState observedState() +{ + const auto expected = specification(); + NativeTimerObservedState value; + value.channelId = expected.channelId; + value.title = expected.title; + value.directory = expected.directory; + value.day = expected.day; + value.weekdays = expected.weekdays; + value.startTime = expected.startTime; + value.endTime = expected.endTime; + value.flags = 1; + value.priority = expected.priority; + value.lifetime = expected.lifetime; + value.enabled = true; + value.vps = false; + return value; +} +} // namespace + +int main() +{ + Database database; + assert(database.open(":memory:")); + + MutationOperationRepository operations(database); + TimerAssignmentRepository assignments(database); + NativeTimerBindingRepository bindings(database); + assert(operations.ensureSchema()); + assert(assignments.ensureSchema()); + assert(bindings.ensureSchema()); + + const TimerAssignment assignment = + provisioningAssignment(assignments); + + NativeTimerCreateOperationPayload payload; + payload.timerAssignmentId = assignment.timerAssignmentId; + payload.expectedAssignmentRevision = assignment.assignmentRevision; + payload.expectedIntentRevision = assignment.intentRevision; + payload.assignmentEpoch = assignment.assignmentEpoch; + payload.nativeTimerBindingId = "binding:create:outcome"; + payload.backendId = assignment.backendId; + payload.backendGeneration = assignment.backendGeneration; + payload.expectedSpecification = specification(); + + MutationOperation operation; + operation.operationId = "operation:create:outcome"; + operation.idempotencyKey = "idempotency:create:outcome"; + operation.actorId = "actor:owner"; + operation.backendId = assignment.backendId; + operation.backendGeneration = assignment.backendGeneration; + operation.resourceType = "TimerAssignment"; + operation.resourceId = assignment.timerAssignmentId; + operation.expectedRevision = assignment.assignmentRevision; + operation.expectedResourceFingerprint = + nativeTimerSpecificationFingerprint(specification()); + operation.actionFamily = "timer.create"; + operation.requestFingerprint = "request:create:outcome"; + operation.requestedAt = 100; + operation.deadline = 700; + operation.verificationPolicy = + MutationOperationVerificationPolicy::readbackRequired; + operation.state = MutationOperationState::accepted; + operation.updatedAt = 100; + + MutationOperationPayload durable; + durable.operationId = operation.operationId; + durable.payloadType = "native.timer.create"; + durable.payloadVersion = 1; + durable.payload = serializeNativeTimerCreateOperationPayload(payload); + durable.payloadFingerprint = + nativeTimerCreateOperationPayloadFingerprint(payload); + const auto reserved = operations.reserveWithPayload(operation, durable); + assert(reserved.ok()); + + NativeTimerCreateDispatchClaimRequest claimRequest; + claimRequest.operationId = operation.operationId; + claimRequest.expectedOperationRevision = + reserved.operation.operationRevision; + claimRequest.timerAssignmentId = assignment.timerAssignmentId; + claimRequest.nativeTimerBindingId = payload.nativeTimerBindingId; + claimRequest.backendId = assignment.backendId; + claimRequest.backendGeneration = assignment.backendGeneration; + claimRequest.expectedSpecificationFingerprint = + operation.expectedResourceFingerprint; + claimRequest.reservation.commandId = "command:create:outcome"; + claimRequest.reservation.requestFingerprint = + "fingerprint:create:outcome"; + + NativeTimerCreateDispatchService dispatch(operations); + const auto claimed = + dispatch.claimAfterReservation(claimRequest, 110); + assert(claimed.status == + NativeTimerCreateDispatchClaimStatus::claimed); + + NativeTimerCreateExecutorOutcome outcome; + outcome.operationId = operation.operationId; + outcome.operationRevision = claimed.operation.operationRevision; + outcome.reservation = claimRequest.reservation; + outcome.category = + NativeTimerCreateExecutorOutcomeCategory::acceptedUnverified; + outcome.dispatchStartedAt = 115; + outcome.completedAt = 120; + outcome.evidenceReference = "suitebridge:ntcreate:outcome"; + const auto applied = dispatch.applyOutcome(outcome); + assert(applied.status == + NativeTimerCreateDispatchOutcomeStatus::applied); + assert(applied.expectationPresent); + assert(applied.expectation.operationState == + NativeTimerReadbackOperationState::executedUnverified); + assert(applied.expectation.readbackNotBefore == 115); + assert(dispatch.applyOutcome(outcome).status == + NativeTimerCreateDispatchOutcomeStatus::alreadyApplied); + + NativeTimerCreateReadbackCandidate candidate; + candidate.timerAssignmentId = assignment.timerAssignmentId; + candidate.nativeTimerBindingId = payload.nativeTimerBindingId; + candidate.observation.backendId = assignment.backendId; + candidate.observation.backendGeneration = + assignment.backendGeneration; + candidate.observation.backendNativeTimerId = "native:42"; + candidate.observation.observedAt = 130; + candidate.observation.observedState = observedState(); + candidate.observation.observedFingerprint = + nativeTimerObservedStateFingerprint( + candidate.observation.observedState); + + NativeTimerCreateReadbackEvidence evidence; + evidence.backendId = assignment.backendId; + evidence.backendGeneration = assignment.backendGeneration; + evidence.observedAt = candidate.observation.observedAt; + evidence.completeness = + NativeTimerCreateReadbackCompleteness::complete; + evidence.candidates = {candidate}; + + NativeTimerCreateReadbackVerificationService verification(bindings); + const auto verified = + verification.verify(applied.expectation, evidence); + assert(verified.status == + NativeTimerCreateReadbackVerificationStatus::verified); + + TimerAssignmentFulfillmentService fulfillment( + assignments, bindings); + const auto bound = fulfillment.bindVerified( + assignment.timerAssignmentId, + assignment.assignmentRevision, + assignment.intentRevision, + assignment.backendGeneration, + verified.binding.nativeTimerBindingId, + verified.binding.bindingRevision, + 140); + assert(bound.status == TimerAssignmentFulfillmentStatus::bound); + assert(bound.assignment.state == TimerAssignmentState::bound); + + NativeTimerCreateOperationCompletionService completion( + operations, assignments, bindings); + const auto completed = + completion.complete(applied.expectation, 150); + assert(completed.status == + NativeTimerCreateOperationCompletionStatus::completed); + assert(completed.operation.state == MutationOperationState::succeeded); + assert(completion.complete(applied.expectation, 150).status == + NativeTimerCreateOperationCompletionStatus::alreadyCompleted); + + std::cout << "test_native_timer_create_outcome_completion passed\n"; + return 0; +} diff --git a/docs/development/phase-64-native-timer-create-operation-preparation.md b/docs/development/phase-64-native-timer-create-operation-preparation.md index bac47af17..4cb98aca8 100644 --- a/docs/development/phase-64-native-timer-create-operation-preparation.md +++ b/docs/development/phase-64-native-timer-create-operation-preparation.md @@ -94,3 +94,21 @@ This component does not dispatch, call Agent, SuiteBridge or VDR, advertise a write command, create a native Timer, or mark an assignment bound. It only produces durable pre-dispatch authority. Real runtime mutation remains closed until the later execution path and exact-candidate yaVDR acceptance are ready. + +## Executor outcome and verified completion + +After the reserved command is activated, its exact reservation identity remains +fenced into the dispatching operation. The executor outcome must name that same +command and request fingerprint. Rejection without effect terminates as +`failed_verified`; accepted-unverified and outcome-unknown results transition +to their shared mutation states and create a PRESENT-readback expectation from +the immutable durable CREATE payload. Its lower observation boundary is the +durable dispatch-start timestamp, so pre-dispatch inventory cannot verify the +write. + +Terminal completion is separate from dispatch acknowledgement. It requires the +authoritative correlated readback to have created the managed binding, the +assignment fulfillment service to have persisted the assignment as `bound`, and +both records to retain the exact backend generation and stable Suite identities. +Only then may the mutation operation advance to `succeeded`. Exact outcome and +completion replays are idempotent. diff --git a/mk/phase64-native-timer-create-activation-tests.mk b/mk/phase64-native-timer-create-activation-tests.mk index 69ecb7a79..16a5a5ff9 100644 --- a/mk/phase64-native-timer-create-activation-tests.mk +++ b/mk/phase64-native-timer-create-activation-tests.mk @@ -18,6 +18,7 @@ test-phase64-native-timer-create-activation: test-phase64-native-timer-create-ac core/timers/src/NativeTimerBinding.cpp \ core/timers/src/NativeTimerSpecification.cpp \ core/timers/src/NativeTimerCreateOperationPayload.cpp \ + core/timers/src/NativeTimerCreateReadbackExpectation.cpp \ core/timers/src/NativeTimerCreateDispatchService.cpp \ $(AGENT_CONTROL_PLANE_DOMAIN_SRC) \ core/agent/src/BackendAgentNativeTimerDelete.cpp \ diff --git a/mk/phase64-native-timer-create-dispatch-state-tests.mk b/mk/phase64-native-timer-create-dispatch-state-tests.mk index 927769aee..ab2bc8849 100644 --- a/mk/phase64-native-timer-create-dispatch-state-tests.mk +++ b/mk/phase64-native-timer-create-dispatch-state-tests.mk @@ -11,6 +11,7 @@ test-phase64-native-timer-create-dispatch-state: test-phase64-native-timer-creat core/timers/src/NativeTimerBinding.cpp \ core/timers/src/NativeTimerSpecification.cpp \ core/timers/src/NativeTimerCreateOperationPayload.cpp \ + core/timers/src/NativeTimerCreateReadbackExpectation.cpp \ core/timers/src/NativeTimerCreateDispatchService.cpp \ core/timers/tests/test_native_timer_create_dispatch_service.cpp \ $(LDFLAGS) \ diff --git a/mk/phase64-native-timer-create-outcome-completion-tests.mk b/mk/phase64-native-timer-create-outcome-completion-tests.mk new file mode 100644 index 000000000..3b02e16c5 --- /dev/null +++ b/mk/phase64-native-timer-create-outcome-completion-tests.mk @@ -0,0 +1,32 @@ +.PHONY: test-phase64-native-timer-create-outcome-completion-architecture test-phase64-native-timer-create-outcome-completion + +test-phase64-native-timer-create-outcome-completion-architecture: + python3 tools/check_phase64_native_timer_create_outcome_completion.py + +test-phase64-native-timer-create-outcome-completion: test-phase64-native-timer-create-outcome-completion-architecture + $(BUILD_CXX) $(CXXFLAGS) -Icore/sqlite/include -Icore/operations/include -Icore/timers/include \ + $(SQLITE_SRC) \ + core/operations/src/MutationOperation.cpp \ + core/operations/src/MutationOperationRepository.cpp \ + core/timers/src/TimerAssignment.cpp \ + core/timers/src/TimerAssignmentRepository.cpp \ + core/timers/src/NativeTimerBinding.cpp \ + core/timers/src/NativeTimerBindingRepository.cpp \ + core/timers/src/NativeTimerBindingReadRepository.cpp \ + core/timers/src/NativeTimerBindingWriteRepository.cpp \ + core/timers/src/NativeTimerSpecification.cpp \ + core/timers/src/NativeTimerObservation.cpp \ + core/timers/src/NativeTimerCreateOperationPayload.cpp \ + core/timers/src/NativeTimerCreateReadbackExpectation.cpp \ + core/timers/src/NativeTimerCreateReadbackEvidence.cpp \ + core/timers/src/NativeTimerCreateReadbackVerificationService.cpp \ + core/timers/src/NativeTimerCreateDispatchService.cpp \ + core/timers/src/TimerAssignmentFulfillmentService.cpp \ + core/timers/src/NativeTimerCreateOperationCompletionService.cpp \ + core/timers/tests/test_native_timer_create_outcome_completion.cpp \ + $(LDFLAGS) \ + -o $(BUILD_DIR)/test_native_timer_create_outcome_completion + $(BUILD_DIR)/test_native_timer_create_outcome_completion + +test-fast: test-phase64-native-timer-create-outcome-completion +test-architecture: test-phase64-native-timer-create-outcome-completion-architecture diff --git a/tools/check_phase64_native_timer_create_outcome_completion.py b/tools/check_phase64_native_timer_create_outcome_completion.py new file mode 100755 index 000000000..c9e72e8e0 --- /dev/null +++ b/tools/check_phase64_native_timer_create_outcome_completion.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parent.parent + +files = { + "dispatch header": ROOT / "core/timers/include/NativeTimerCreateDispatchService.h", + "dispatch source": ROOT / "core/timers/src/NativeTimerCreateDispatchService.cpp", + "completion header": ROOT / "core/timers/include/NativeTimerCreateOperationCompletionService.h", + "completion source": ROOT / "core/timers/src/NativeTimerCreateOperationCompletionService.cpp", + "test": ROOT / "core/timers/tests/test_native_timer_create_outcome_completion.cpp", + "fragment": ROOT / "mk/phase64-native-timer-create-outcome-completion-tests.mk", + "makefile": ROOT / "Makefile", +} + +failures = [] +for label, path in files.items(): + if not path.is_file(): + failures.append(f"missing {label}: {path.relative_to(ROOT)}") + +def require(label, needles): + path = files[label] + if not path.is_file(): + return + text = path.read_text(encoding="utf-8") + for needle in needles: + if needle not in text: + failures.append(f"{label} missing required contract: {needle}") + +require("dispatch header", [ + "NativeTimerCreateExecutorOutcomeCategory", + "NativeTimerCreateDispatchOutcomeResult", + "NativeTimerCreateReadbackExpectation", + "applyOutcome", +]) +require("dispatch source", [ + "NativeTimerCreateExecutorOutcomeCategory::rejectedWithoutEffect", + "NativeTimerCreateExecutorOutcomeCategory::acceptedUnverified", + "NativeTimerCreateExecutorOutcomeCategory::outcomeUnknown", + "MutationOperationState::failedVerified", + "MutationOperationState::executedUnverified", + "MutationOperationState::outcomeUnknown", + "readbackNotBefore = outcome.dispatchStartedAt", + "findPayloadByOperationId", +]) +require("completion source", [ + "TimerAssignmentState::bound", + "lastVerifiedOperationId == expectation.operationId", + "nativeTimerObservationMatchesSpecification", + "MutationOperationState::succeeded", +]) +require("test", [ + "acceptedUnverified", + "alreadyApplied", + "TimerAssignmentFulfillmentStatus::bound", + "alreadyCompleted", +]) +require("fragment", [ + "test-phase64-native-timer-create-outcome-completion", + "NativeTimerCreateOperationCompletionService.cpp", + "test-fast: test-phase64-native-timer-create-outcome-completion", + "test-architecture: test-phase64-native-timer-create-outcome-completion-architecture", +]) +require("makefile", [ + "include mk/phase64-native-timer-create-outcome-completion-tests.mk", +]) + +for label in ("dispatch source", "completion source"): + path = files[label] + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + for forbidden in ("BackendAgent", "SuiteBridge", "SVDRP", "system(", "popen("): + if forbidden in text: + failures.append( + f"{label} contains forbidden transport coupling: {forbidden}" + ) + +if failures: + print("Phase-64 native Timer CREATE outcome/completion check failed:", file=sys.stderr) + for failure in failures: + print("- " + failure, file=sys.stderr) + raise SystemExit(1) + +print("Phase-64 native Timer CREATE outcome/completion check passed") From e11436fa646e00fe86fd4bd43804b01fc07e0948 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 00:06:50 +0200 Subject: [PATCH 64/85] Enter CREATE completion test through provisioning --- ...native_timer_create_outcome_completion.cpp | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/core/timers/tests/test_native_timer_create_outcome_completion.cpp b/core/timers/tests/test_native_timer_create_outcome_completion.cpp index 6d5355aae..99467ceaf 100644 --- a/core/timers/tests/test_native_timer_create_outcome_completion.cpp +++ b/core/timers/tests/test_native_timer_create_outcome_completion.cpp @@ -35,7 +35,7 @@ NativeTimerSpecification specification() return value; } -TimerAssignment provisioningAssignment( +TimerAssignment selectedAssignment( TimerAssignmentRepository& repository) { TimerAssignment assignment; @@ -45,7 +45,7 @@ TimerAssignment provisioningAssignment( assignment.assignmentEpoch = 1; assignment.backendId = "backend:1"; assignment.backendGeneration = 7; - assignment.state = TimerAssignmentState::provisioning; + assignment.state = TimerAssignmentState::selected; assignment.role = TimerAssignmentRole::primary; assignment.channelBinding.canonicalChannelId = "channel:ard"; assignment.channelBinding.backendChannelId = specification().channelId; @@ -94,8 +94,19 @@ int main() assert(assignments.ensureSchema()); assert(bindings.ensureSchema()); - const TimerAssignment assignment = - provisioningAssignment(assignments); + const TimerAssignment selected = + selectedAssignment(assignments); + TimerAssignmentFulfillmentService fulfillment( + assignments, bindings); + const auto started = fulfillment.beginProvisioning( + selected.timerAssignmentId, + selected.assignmentRevision, + selected.intentRevision, + selected.backendGeneration, + 95); + assert(started.status == + TimerAssignmentFulfillmentStatus::provisioningStarted); + const TimerAssignment assignment = started.assignment; NativeTimerCreateOperationPayload payload; payload.timerAssignmentId = assignment.timerAssignmentId; @@ -203,8 +214,6 @@ int main() assert(verified.status == NativeTimerCreateReadbackVerificationStatus::verified); - TimerAssignmentFulfillmentService fulfillment( - assignments, bindings); const auto bound = fulfillment.bindVerified( assignment.timerAssignmentId, assignment.assignmentRevision, From 662e16f22f569e6de7fa6b58806e9df2d24377ce Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 00:07:20 +0200 Subject: [PATCH 65/85] Let repository issue CREATE assignment epoch --- .../timers/tests/test_native_timer_create_outcome_completion.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/core/timers/tests/test_native_timer_create_outcome_completion.cpp b/core/timers/tests/test_native_timer_create_outcome_completion.cpp index 99467ceaf..ce3ef492e 100644 --- a/core/timers/tests/test_native_timer_create_outcome_completion.cpp +++ b/core/timers/tests/test_native_timer_create_outcome_completion.cpp @@ -42,7 +42,6 @@ TimerAssignment selectedAssignment( assignment.timerAssignmentId = "assignment:create:outcome"; assignment.timerIntentId = "intent:create:outcome"; assignment.intentRevision = "intent-revision:1"; - assignment.assignmentEpoch = 1; assignment.backendId = "backend:1"; assignment.backendGeneration = 7; assignment.state = TimerAssignmentState::selected; From c66c2f65812fed8d0d257e76cafdc31de8a4f0d6 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 00:08:08 +0200 Subject: [PATCH 66/85] Add exact-candidate Phase 64 yaVDR acceptance --- Makefile | 1 + ...64-managed-timer-fulfillment-acceptance.mk | 9 + ...64_managed_timer_fulfillment_acceptance.py | 71 ++++++++ ...64_managed_timer_fulfillment_acceptance.sh | 154 ++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 mk/phase64-managed-timer-fulfillment-acceptance.mk create mode 100755 tools/check_phase64_managed_timer_fulfillment_acceptance.py create mode 100755 tools/run_phase64_managed_timer_fulfillment_acceptance.sh diff --git a/Makefile b/Makefile index 82871b1f6..fb2c1cdde 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,7 @@ include mk/phase64-mutation-operation-repository-tests.mk include mk/phase64-native-timer-create-operation-preparation-tests.mk include mk/phase64-native-timer-modify-operation-tests.mk include mk/phase64-native-timer-modify-dispatch-tests.mk +include mk/phase64-managed-timer-fulfillment-acceptance.mk include mk/phase64-native-timer-delete-operation-completion-tests.mk include mk/phase64-native-timer-delete-operation-preparation-tests.mk include mk/phase64-native-timer-delete-dispatch-tests.mk diff --git a/mk/phase64-managed-timer-fulfillment-acceptance.mk b/mk/phase64-managed-timer-fulfillment-acceptance.mk new file mode 100644 index 000000000..f82359dbf --- /dev/null +++ b/mk/phase64-managed-timer-fulfillment-acceptance.mk @@ -0,0 +1,9 @@ +.PHONY: phase64-managed-timer-fulfillment-acceptance test-phase64-managed-timer-fulfillment-acceptance-architecture + +phase64-managed-timer-fulfillment-acceptance: + tools/run_phase64_managed_timer_fulfillment_acceptance.sh + +test-phase64-managed-timer-fulfillment-acceptance-architecture: + python3 tools/check_phase64_managed_timer_fulfillment_acceptance.py + +test-architecture: test-phase64-managed-timer-fulfillment-acceptance-architecture diff --git a/tools/check_phase64_managed_timer_fulfillment_acceptance.py b/tools/check_phase64_managed_timer_fulfillment_acceptance.py new file mode 100755 index 000000000..d1c29380e --- /dev/null +++ b/tools/check_phase64_managed_timer_fulfillment_acceptance.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parent.parent +RUNNER = ROOT / "tools/run_phase64_managed_timer_fulfillment_acceptance.sh" +FRAGMENT = ROOT / "mk/phase64-managed-timer-fulfillment-acceptance.mk" +MAKEFILE = ROOT / "Makefile" + +failures = [] +for path in (RUNNER, FRAGMENT, MAKEFILE): + if not path.is_file(): + failures.append(f"missing {path.relative_to(ROOT)}") + +if RUNNER.is_file(): + text = RUNNER.read_text(encoding="utf-8") + required = [ + "PHASE64_EXPECTED_HEAD", + "exact_candidate_head_mismatch", + "tracked_worktree_not_clean", + "make -j2 test-architecture", + "make -j2 test-ci-fast", + "backend-agent-command-admin", + "make -C vdr-plugin-suite-bridge -j2 check", + "candidates.sha256", + "COMMAND_TYPES=\\n", + 'for command in ("NTCREATE", "NTMOD", "NTDELETE")', + "ADVERTISEMENT=closed", + "PHASE_64_MANAGED_TIMER_FULFILLMENT_ACCEPTANCE=PASS", + ] + for token in required: + if token not in text: + failures.append(f"acceptance runner missing contract: {token}") + for forbidden in ( + "git pull", + "git fetch", + "git push", + "scp ", + "curl ", + "wget ", + "systemctl ", + "timers.conf", + "sqlite3 ", + ): + if forbidden in text: + failures.append( + f"acceptance runner crosses bounded no-transfer/read-only scope: {forbidden}" + ) + +if FRAGMENT.is_file(): + text = FRAGMENT.read_text(encoding="utf-8") + for token in ( + "phase64-managed-timer-fulfillment-acceptance", + "run_phase64_managed_timer_fulfillment_acceptance.sh", + ): + if token not in text: + failures.append(f"acceptance make fragment missing: {token}") + +if MAKEFILE.is_file() and ( + "include mk/phase64-managed-timer-fulfillment-acceptance.mk" + not in MAKEFILE.read_text(encoding="utf-8") +): + failures.append("Makefile missing Phase-64 acceptance include") + +if failures: + print("Phase-64 managed Timer acceptance guard failed:", file=sys.stderr) + for failure in failures: + print("- " + failure, file=sys.stderr) + raise SystemExit(1) + +print("Phase-64 managed Timer acceptance guard passed") diff --git a/tools/run_phase64_managed_timer_fulfillment_acceptance.sh b/tools/run_phase64_managed_timer_fulfillment_acceptance.sh new file mode 100755 index 000000000..6fe872e07 --- /dev/null +++ b/tools/run_phase64_managed_timer_fulfillment_acceptance.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +umask 077 + +fail() { + printf 'PHASE_64_MANAGED_TIMER_FULFILLMENT_ACCEPTANCE=FAIL\n' >&2 + printf 'REASON=%s\n' "$1" >&2 + if [[ -n "${EVIDENCE_DIR:-}" ]]; then + printf 'EVIDENCE=%s\n' "$EVIDENCE_DIR" >&2 + fi + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || + fail "missing_command_$1" +} + +run_logged() { + local name="$1" + shift + "$@" >"$EVIDENCE_DIR/$name.log" 2>&1 || { + tail -n 120 "$EVIDENCE_DIR/$name.log" >&2 || true + fail "${name}_failed" + } +} + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || + fail repository_root_unavailable +cd "$REPO_ROOT" || fail repository_unavailable + +EXPECTED_HEAD="${PHASE64_EXPECTED_HEAD:-}" +if [[ ! "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]]; then + fail expected_head_missing_or_invalid +fi + +for command in git make python3 g++ sha256sum readelf pkg-config; do + require_command "$command" +done + +ACTUAL_HEAD="$(git rev-parse HEAD 2>/dev/null)" || + fail head_unavailable +if [[ "$ACTUAL_HEAD" != "$EXPECTED_HEAD" ]]; then + fail exact_candidate_head_mismatch +fi + +BRANCH="$(git branch --show-current 2>/dev/null)" || + fail branch_unavailable +if [[ "$BRANCH" != "work/phase64-managed-timer-fulfillment" ]]; then + fail unexpected_branch +fi + +if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + fail tracked_worktree_not_clean +fi + +if ! git diff --check; then + fail diff_check_failed +fi + +EVIDENCE_DIR="/tmp/vdr-suite-phase64-${ACTUAL_HEAD}" +if [[ -e "$EVIDENCE_DIR" ]]; then + fail evidence_directory_already_exists +fi +mkdir -m 700 "$EVIDENCE_DIR" || + fail evidence_directory_create_failed + +{ + printf 'HEAD=%s\n' "$ACTUAL_HEAD" + printf 'BRANCH=%s\n' "$BRANCH" + printf 'UTC_STARTED=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'HOST=%s\n' "$(hostname)" + printf 'KERNEL=%s\n' "$(uname -sr)" + printf 'VDR_PC=%s\n' "$(pkg-config --modversion vdr 2>/dev/null || printf unavailable)" +} >"$EVIDENCE_DIR/context.txt" || + fail context_capture_failed + +run_logged clean make clean +run_logged plugin-clean make -C vdr-plugin-suite-bridge clean +run_logged architecture make -j2 test-architecture +run_logged fast-regression make -j2 test-ci-fast +run_logged production-build make -j2 \ + daemon \ + backend-agent \ + backend-agent-enrollment \ + backend-agent-admin \ + backend-agent-command-admin +run_logged suitebridge-check make -C vdr-plugin-suite-bridge -j2 check + +for candidate in \ + .build/vdr-suite-daemon \ + .build/vdr-suite-backend-agent \ + .build/vdr-suite-backend-agent-enroll \ + .build/vdr-suite-backend-agent-admin \ + .build/vdr-suite-backend-agent-command-admin \ + vdr-plugin-suite-bridge/libvdr-suitebridge.so +do + if [[ ! -f "$candidate" ]]; then + fail "candidate_missing_$(printf '%s' "$candidate" | tr '/.' '__')" + fi + sha256sum "$candidate" >>"$EVIDENCE_DIR/candidates.sha256" || + fail candidate_hash_failed +done + +readelf -h vdr-plugin-suite-bridge/libvdr-suitebridge.so \ + >"$EVIDENCE_DIR/suitebridge.elf.txt" 2>&1 || + fail suitebridge_elf_check_failed + +python3 - \ + packaging/systemd/backend-agent.conf \ + vdr-plugin-suite-bridge/suitebridge_svdrp.cpp \ + "$EVIDENCE_DIR/closed-advertisement.txt" <<'PY_CLOSED_ADVERTISEMENT' || + fail closed_advertisement_check_failed +from pathlib import Path +import sys + +config = Path(sys.argv[1]).read_text(encoding="utf-8") +svdrp = Path(sys.argv[2]).read_text(encoding="utf-8") +output = Path(sys.argv[3]) + +if "COMMAND_TYPES=\n" not in config: + raise SystemExit("packaged command advertisement is not closed") + +help_start = svdrp.find("SVDRPHelpPages") +command_start = svdrp.find("SVDRPCommand", help_start) +if help_start < 0 or command_start < 0: + raise SystemExit("SuiteBridge help boundary missing") +help_text = svdrp[help_start:command_start] +for command in ("NTCREATE", "NTMOD", "NTDELETE"): + if command in help_text: + raise SystemExit(f"private write advertised in help: {command}") + +output.write_text( + "PACKAGED_COMMAND_TYPES=closed\n" + "PUBLIC_SVDRP_TIMER_WRITES=closed\n", + encoding="utf-8", +) +PY_CLOSED_ADVERTISEMENT + +if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + fail tracked_worktree_changed_by_acceptance +fi +git diff --check || + fail final_diff_check_failed + +{ + printf 'UTC_COMPLETED=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'RESULT=PASS\n' +} >>"$EVIDENCE_DIR/context.txt" || + fail completion_capture_failed + +printf 'PHASE_64_MANAGED_TIMER_FULFILLMENT_ACCEPTANCE=PASS\n' +printf 'HEAD=%s\n' "$ACTUAL_HEAD" +printf 'ADVERTISEMENT=closed\n' +printf 'EVIDENCE=%s\n' "$EVIDENCE_DIR" From 9d4ef534c86b08f51a10db7c99d624a0303d13a2 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 03:56:51 +0200 Subject: [PATCH 67/85] Fix CREATE completion intent fixture --- .../test_native_timer_create_outcome_completion.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/core/timers/tests/test_native_timer_create_outcome_completion.cpp b/core/timers/tests/test_native_timer_create_outcome_completion.cpp index ce3ef492e..d5d0b3369 100644 --- a/core/timers/tests/test_native_timer_create_outcome_completion.cpp +++ b/core/timers/tests/test_native_timer_create_outcome_completion.cpp @@ -41,7 +41,7 @@ TimerAssignment selectedAssignment( TimerAssignment assignment; assignment.timerAssignmentId = "assignment:create:outcome"; assignment.timerIntentId = "intent:create:outcome"; - assignment.intentRevision = "intent-revision:1"; + assignment.intentRevision = "1"; assignment.backendId = "backend:1"; assignment.backendGeneration = 7; assignment.state = TimerAssignmentState::selected; @@ -85,6 +85,15 @@ int main() { Database database; assert(database.open(":memory:")); + assert(database.execute( + "CREATE TABLE timer_intents (" + "timer_intent_id TEXT PRIMARY KEY NOT NULL," + "intent_revision INTEGER NOT NULL CHECK(intent_revision > 0)" + ");")); + assert(database.execute( + "INSERT INTO timer_intents " + "(timer_intent_id,intent_revision) " + "VALUES ('intent:create:outcome',1);")); MutationOperationRepository operations(database); TimerAssignmentRepository assignments(database); From 46677154a5d01d93ca83a8432bac384448671783 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 04:01:07 +0200 Subject: [PATCH 68/85] Validate repository-owned TimerIntent revisions --- core/timers/src/TimerIntentApplicationService.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/timers/src/TimerIntentApplicationService.cpp b/core/timers/src/TimerIntentApplicationService.cpp index 4635d56c2..f2397bf9d 100644 --- a/core/timers/src/TimerIntentApplicationService.cpp +++ b/core/timers/src/TimerIntentApplicationService.cpp @@ -22,9 +22,12 @@ TimerIntentApplicationResult result( bool requestValid(const TimerIntentApplicationRequest& request) { - return timerIntentValid(request.intent) + TimerIntent revisionValidated = request.intent; + revisionValidated.intentRevision = "1"; + + return request.intent.intentRevision.empty() + && timerIntentValid(revisionValidated) && request.intent.state == TimerIntentState::draft - && request.intent.intentRevision.empty() && !request.timerAssignmentId.empty() && request.timerAssignmentId.size() <= 160 && request.activatedAt > request.intent.updatedAt From 9cc37cdd14470e3b6aa4688fe4dc47ebc613e06d Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 04:07:59 +0200 Subject: [PATCH 69/85] Add native Timer modify failure diagnostics --- core/timers/tests/test_native_timer_modify_dispatch.cpp | 3 +++ core/timers/tests/test_native_timer_modify_operation.cpp | 2 ++ 2 files changed, 5 insertions(+) diff --git a/core/timers/tests/test_native_timer_modify_dispatch.cpp b/core/timers/tests/test_native_timer_modify_dispatch.cpp index 4df34e0b6..3c849dcea 100644 --- a/core/timers/tests/test_native_timer_modify_dispatch.cpp +++ b/core/timers/tests/test_native_timer_modify_dispatch.cpp @@ -156,6 +156,9 @@ void verifyRoundTrip( request.expectedOperationRevision = operation.operationRevision; const auto claimed = dispatch.claim(request, 105); + if (claimed.status != NativeTimerModifyDispatchClaimStatus::claimed) + std::cerr << "modify claim failed: " + << static_cast(claimed.status) << '\n'; assert(claimed.status == NativeTimerModifyDispatchClaimStatus::claimed); assert(claimed.operation.state == MutationOperationState::dispatching); diff --git a/core/timers/tests/test_native_timer_modify_operation.cpp b/core/timers/tests/test_native_timer_modify_operation.cpp index 9584836ca..18e436329 100644 --- a/core/timers/tests/test_native_timer_modify_operation.cpp +++ b/core/timers/tests/test_native_timer_modify_operation.cpp @@ -194,6 +194,8 @@ int main() serializeNativeTimerModifyOperationPayload(codec); assert(!serialized.empty()); NativeTimerModifyOperationPayload parsed; + if (!parseNativeTimerModifyOperationPayload(serialized, parsed)) + std::cerr << "modify payload parse failed: " << serialized << '\n'; assert(parseNativeTimerModifyOperationPayload(serialized, parsed)); assert(parsed.kind == NativeTimerModifyKind::toggle); assert(parsed.expectedSpecification.startTime == "0930"); From 3b296e0dc05761c03373c2d446470aadc7ffe866 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 04:13:00 +0200 Subject: [PATCH 70/85] Serialize native Timer modify kind canonically --- core/timers/src/NativeTimerModifyOperationPayload.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/timers/src/NativeTimerModifyOperationPayload.cpp b/core/timers/src/NativeTimerModifyOperationPayload.cpp index 9ca26173b..166fdb679 100644 --- a/core/timers/src/NativeTimerModifyOperationPayload.cpp +++ b/core/timers/src/NativeTimerModifyOperationPayload.cpp @@ -55,7 +55,7 @@ std::string canonical(const NativeTimerModifyOperationPayload& payload) { if (!nativeTimerModifyOperationPayloadValid(payload)) return {}; std::string output; - append(output, nativeTimerModifyKindName(payload.kind)); + append(output, std::string(nativeTimerModifyKindName(payload.kind))); append(output, payload.timerAssignmentId); append(output, payload.expectedAssignmentRevision); append(output, payload.expectedIntentRevision); From e5b3f30012fd680d93214094df5878a9cb42b2fc Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 04:17:46 +0200 Subject: [PATCH 71/85] Let repository issue modify assignment epochs --- core/timers/tests/test_native_timer_modify_operation.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/core/timers/tests/test_native_timer_modify_operation.cpp b/core/timers/tests/test_native_timer_modify_operation.cpp index 18e436329..ed0dc0833 100644 --- a/core/timers/tests/test_native_timer_modify_operation.cpp +++ b/core/timers/tests/test_native_timer_modify_operation.cpp @@ -83,7 +83,6 @@ TimerAssignment boundAssignment( assignment.timerAssignmentId = "assignment:modify:1"; assignment.timerIntentId = intent.timerIntentId; assignment.intentRevision = intent.intentRevision; - assignment.assignmentEpoch = 1; assignment.backendId = "backend:1"; assignment.backendGeneration = 7; assignment.state = TimerAssignmentState::bound; From 53bbfab32a8d90e171c15d986b42a507af3f8e06 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 04:22:57 +0200 Subject: [PATCH 72/85] Link modify local state into extension tests --- mk/phase64-command-state-extension-tests.mk | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mk/phase64-command-state-extension-tests.mk b/mk/phase64-command-state-extension-tests.mk index 1e28f1360..cb785c381 100644 --- a/mk/phase64-command-state-extension-tests.mk +++ b/mk/phase64-command-state-extension-tests.mk @@ -10,6 +10,7 @@ test-phase64-command-state-extension: test-phase64-command-state-extension-archi core/agent/src/BackendAgentNativeTimerDeleteLocalState.cpp \ core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ + core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp \ core/agent/src/BackendAgentCommandStateExtension.cpp \ core/agent/tests/test_backend_agent_command_state_extension.cpp \ $(LDFLAGS) \ @@ -21,6 +22,7 @@ test-phase64-command-state-extension: test-phase64-command-state-extension-archi core/agent/src/BackendAgentNativeTimerDeleteLocalState.cpp \ core/agent/src/BackendAgentNativeTimerCreateLocalState.cpp \ core/agent/src/BackendAgentNativeTimerCreateRecovery.cpp \ + core/agent/src/BackendAgentNativeTimerModifyLocalState.cpp \ core/agent/src/BackendAgentCommandStateExtension.cpp \ core/agent/tests/test_backend_agent_native_timer_create_state_extension.cpp \ $(LDFLAGS) \ From 7ea3ce7184d3f1b62bea113a0944bf5fef90b83e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 07:15:38 +0200 Subject: [PATCH 73/85] Align SuiteBridge foundation contract version --- vdr-plugin-suite-bridge/tests/check_foundation_contract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vdr-plugin-suite-bridge/tests/check_foundation_contract.py b/vdr-plugin-suite-bridge/tests/check_foundation_contract.py index 5787f5164..597fadac2 100755 --- a/vdr-plugin-suite-bridge/tests/check_foundation_contract.py +++ b/vdr-plugin-suite-bridge/tests/check_foundation_contract.py @@ -56,14 +56,14 @@ "test-epg-type-snapshot-contract:", "test-recording-identity:", "test-recording-metadata-contract:", - 'test "$(VERSION)" = "0.13.2"', + 'test "$(VERSION)" = "0.13.3"', ) for fragment in make_fragments: if fragment not in makefile: errors.append(f"missing Makefile contract: {fragment}") source_fragments = ( - 'inline constexpr const char *Version = "0.13.2";', + 'inline constexpr const char *Version = "0.13.3";', "bool cPluginSuiteBridge::Initialize(void)", "bool cPluginSuiteBridge::Start(void)", "void cPluginSuiteBridge::Stop(void)", From 7466b17c5a9282089d96a144bef8c7f77cfee8dd Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 07:44:17 +0200 Subject: [PATCH 74/85] Repair SuiteBridge capability contract guard --- .../check_capability_discovery_contract.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/vdr-plugin-suite-bridge/tests/check_capability_discovery_contract.py b/vdr-plugin-suite-bridge/tests/check_capability_discovery_contract.py index 3e3e93c9f..2c0b4a742 100755 --- a/vdr-plugin-suite-bridge/tests/check_capability_discovery_contract.py +++ b/vdr-plugin-suite-bridge/tests/check_capability_discovery_contract.py @@ -79,7 +79,7 @@ "strcasecmp(command, CommandName())", "ParseRequestedSchema(option)", 'inline constexpr const char *Name = "suitebridge";', - 'inline constexpr const char *Version = "0.13.2";', + 'inline constexpr const char *Version = "0.13.3";', "SuiteBridgeCapabilityDiscoveryReply capabilityReply(", "svdrp command=CAPS result=served reply=%d bytes=%zu schema=%u", "svdrp command=CAPS result=rejected reply=%d", @@ -114,13 +114,25 @@ "SuiteBridgeCapabilityDiscoveryReply capabilityReply(" ) discovery_handled = plugin_source.find("if (capabilityReply.Handled())") -snapshot_capture = plugin_source.find("statusMonitor_.CaptureSnapshot()") +snapshot_reply = plugin_source.find( + "const SuiteBridgeSvdrpReply snapshotReply(" +) +snapshot_capture = plugin_source.find( + "statusMonitor_.CaptureSnapshot()", + max(snapshot_reply, 0), +) if ( discovery_dispatch < 0 or discovery_handled < 0 + or snapshot_reply < 0 or snapshot_capture < 0 - or not discovery_dispatch < discovery_handled < snapshot_capture + or not ( + discovery_dispatch + < discovery_handled + < snapshot_reply + <= snapshot_capture + ) ): errors.append( "CAPS must be dispatched and returned before SNAP captures a snapshot" From 692ac34f3aee1ead70cab17425a4f9e74f091792 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 08:11:02 +0200 Subject: [PATCH 75/85] Make Phase 64 acceptance fail closed --- ...k_phase64_managed_timer_fulfillment_acceptance.py | 9 +++++++++ ...n_phase64_managed_timer_fulfillment_acceptance.sh | 12 +++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tools/check_phase64_managed_timer_fulfillment_acceptance.py b/tools/check_phase64_managed_timer_fulfillment_acceptance.py index d1c29380e..c4cc39b9e 100755 --- a/tools/check_phase64_managed_timer_fulfillment_acceptance.py +++ b/tools/check_phase64_managed_timer_fulfillment_acceptance.py @@ -24,6 +24,14 @@ "make -C vdr-plugin-suite-bridge -j2 check", "candidates.sha256", "COMMAND_TYPES=\\n", + "if ! python3 -", + ( + "PY_CLOSED_ADVERTISEMENT\n" + "then\n" + " fail closed_advertisement_check_failed\n" + "fi" + ), + "closed_advertisement_evidence_missing", 'for command in ("NTCREATE", "NTMOD", "NTDELETE")', "ADVERTISEMENT=closed", "PHASE_64_MANAGED_TIMER_FULFILLMENT_ACCEPTANCE=PASS", @@ -38,6 +46,7 @@ "scp ", "curl ", "wget ", + "<<'PY_CLOSED_ADVERTISEMENT' ||", "systemctl ", "timers.conf", "sqlite3 ", diff --git a/tools/run_phase64_managed_timer_fulfillment_acceptance.sh b/tools/run_phase64_managed_timer_fulfillment_acceptance.sh index 6fe872e07..3b055ce9d 100755 --- a/tools/run_phase64_managed_timer_fulfillment_acceptance.sh +++ b/tools/run_phase64_managed_timer_fulfillment_acceptance.sh @@ -105,11 +105,10 @@ readelf -h vdr-plugin-suite-bridge/libvdr-suitebridge.so \ >"$EVIDENCE_DIR/suitebridge.elf.txt" 2>&1 || fail suitebridge_elf_check_failed -python3 - \ +if ! python3 - \ packaging/systemd/backend-agent.conf \ vdr-plugin-suite-bridge/suitebridge_svdrp.cpp \ - "$EVIDENCE_DIR/closed-advertisement.txt" <<'PY_CLOSED_ADVERTISEMENT' || - fail closed_advertisement_check_failed + "$EVIDENCE_DIR/closed-advertisement.txt" <<'PY_CLOSED_ADVERTISEMENT' from pathlib import Path import sys @@ -135,6 +134,13 @@ output.write_text( encoding="utf-8", ) PY_CLOSED_ADVERTISEMENT +then + fail closed_advertisement_check_failed +fi + +if [[ ! -s "$EVIDENCE_DIR/closed-advertisement.txt" ]]; then + fail closed_advertisement_evidence_missing +fi if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then fail tracked_worktree_changed_by_acceptance From 312b80963e10e3bbe2bf788f2c263ada463eac10 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 09:23:31 +0200 Subject: [PATCH 76/85] Activate accepted SuiteBridge Timer commands Open the packaged Timer command set only behind enabled, coherent SuiteBridge provider discovery. Preserve private SVDRP help, provider-fence delivery checks, and the bounded yaVDR acceptance gate. --- ...ckendAgentNativeTimerDeleteAdvertisement.h | 93 ++++++++-- core/agent/src/BackendAgentClient.cpp | 41 +++- core/agent/src/BackendAgentCommandClient.cpp | 162 +++++++++++++++- .../agent/src/BackendAgentCommandDelivery.cpp | 8 +- ...eBridgeSvdrpNativeTimerCreateTransport.cpp | 2 +- ...eBridgeSvdrpNativeTimerDeleteTransport.cpp | 2 +- ...eBridgeSvdrpNativeTimerModifyTransport.cpp | 17 +- .../agent/tests/test_backend_agent_client.cpp | 4 + .../test_backend_agent_command_client.cpp | 175 ++++++++++++++++++ ...end_agent_native_timer_create_delivery.cpp | 3 +- ...ge_svdrp_native_timer_create_transport.cpp | 2 +- ...ge_svdrp_native_timer_delete_transport.cpp | 2 +- ...bridge-native-timer-command-path-wiring.md | 35 ++-- packaging/systemd/backend-agent.conf | 6 +- ...phase63_fenced_native_operation_runtime.py | 4 +- .../check_phase64_command_state_extension.py | 3 +- ...heck_phase64_command_state_v3_extension.py | 20 +- ...64_managed_timer_fulfillment_acceptance.py | 13 +- ...ck_phase64_native_timer_create_delivery.py | 51 +++-- ...ck_phase64_native_timer_delete_delivery.py | 14 +- ...se64_native_timer_delete_local_starting.py | 5 +- ...hase64_native_timer_modify_command_path.py | 53 ++++-- ...bridge_native_timer_command_path_wiring.py | 143 +++++++++----- ...dge_native_timer_create_command_service.py | 3 +- ...ridge_native_timer_create_real_mutation.py | 5 +- ...itebridge_native_timer_create_transport.py | 17 +- ..._native_timer_delete_disabled_transport.py | 14 +- ...ridge_native_timer_delete_real_mutation.py | 10 +- ...ive_timer_delete_replay_ledger_callback.py | 2 +- ...4_timer_delete_durable_executor_outcome.py | 11 +- ...ck_phase64_timer_delete_fenced_executor.py | 5 +- ...e64_timer_delete_fresh_durable_starting.py | 11 +- ...se64_timer_delete_local_state_lifecycle.py | 5 +- ...64_managed_timer_fulfillment_acceptance.sh | 23 ++- 34 files changed, 748 insertions(+), 216 deletions(-) mode change 100755 => 100644 tools/check_phase63_fenced_native_operation_runtime.py mode change 100755 => 100644 tools/check_phase64_command_state_extension.py mode change 100755 => 100644 tools/check_phase64_managed_timer_fulfillment_acceptance.py mode change 100755 => 100644 tools/check_phase64_native_timer_create_delivery.py mode change 100755 => 100644 tools/check_phase64_native_timer_delete_delivery.py mode change 100755 => 100644 tools/check_phase64_native_timer_modify_command_path.py mode change 100755 => 100644 tools/check_phase64_suitebridge_native_timer_command_path_wiring.py mode change 100755 => 100644 tools/check_phase64_suitebridge_native_timer_create_command_service.py mode change 100755 => 100644 tools/check_phase64_suitebridge_native_timer_create_real_mutation.py mode change 100755 => 100644 tools/check_phase64_suitebridge_native_timer_create_transport.py mode change 100755 => 100644 tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py mode change 100755 => 100644 tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py mode change 100755 => 100644 tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py mode change 100755 => 100644 tools/check_phase64_timer_delete_durable_executor_outcome.py mode change 100755 => 100644 tools/check_phase64_timer_delete_fenced_executor.py mode change 100755 => 100644 tools/check_phase64_timer_delete_fresh_durable_starting.py mode change 100755 => 100644 tools/check_phase64_timer_delete_local_state_lifecycle.py mode change 100755 => 100644 tools/run_phase64_managed_timer_fulfillment_acceptance.sh diff --git a/core/agent/include/BackendAgentNativeTimerDeleteAdvertisement.h b/core/agent/include/BackendAgentNativeTimerDeleteAdvertisement.h index f949dffa6..e25027045 100644 --- a/core/agent/include/BackendAgentNativeTimerDeleteAdvertisement.h +++ b/core/agent/include/BackendAgentNativeTimerDeleteAdvertisement.h @@ -1,54 +1,111 @@ #pragma once #include "BackendAgentCommand.h" +#include "BackendAgentLocalProvider.h" +#include "BackendAgentNativeTimerCreate.h" #include "BackendAgentNativeTimerDelete.h" +#include "BackendAgentNativeTimerModify.h" #include -#include #include namespace vdrsuite::agent { - -inline bool backendAgentNativeTimerDeleteAdvertisementValid( +namespace detail +{ +inline bool timerAdvertisementRequirement( const BackendAgentCommandPollRequest& request, + const char* commandType, + const char* providerId, + const char* providerKind, + const char* capability, + const char* duplicateReason, + const char* providerReason, std::string& reasonCode) { const auto advertised = std::count( request.supportedCommandTypes.begin(), request.supportedCommandTypes.end(), - std::string(kBackendAgentNativeTimerDeleteCommandType)); - if (advertised == 0) - { - reasonCode.clear(); - return true; - } + std::string(commandType)); + if (advertised == 0) return true; if (advertised != 1) { - reasonCode = "invalid_native_timer_delete_advertisement"; + reasonCode = duplicateReason; return false; } const auto provider = std::find_if( request.localProviders.begin(), request.localProviders.end(), - [](const BackendAgentLocalProviderFacts& facts) { - return facts.providerId == kBackendAgentNativeTimerDeleteProviderId; + [&](const BackendAgentLocalProviderFacts& facts) { + return facts.providerId == providerId; }); if (provider == request.localProviders.end() || !backendAgentLocalProviderValidFacts(*provider) || - provider->providerKind != kBackendAgentNativeTimerDeleteProviderKind || + provider->providerKind != providerKind || !provider->available || std::find( - provider->capabilities.begin(), provider->capabilities.end(), - kBackendAgentNativeTimerDeleteCapability) == - provider->capabilities.end()) + provider->capabilities.begin(), + provider->capabilities.end(), + capability) == provider->capabilities.end()) { - reasonCode = "native_timer_delete_provider_advertisement_required"; + reasonCode = providerReason; return false; } + return true; +} +} // namespace detail + +inline bool backendAgentNativeTimerAdvertisementValid( + const BackendAgentCommandPollRequest& request, + std::string& reasonCode) +{ + if (!detail::timerAdvertisementRequirement( + request, + kBackendAgentNativeTimerCreateCommandType, + kBackendAgentNativeTimerCreateProviderId, + kBackendAgentNativeTimerCreateProviderKind, + kBackendAgentNativeTimerCreateCapability, + "invalid_native_timer_create_advertisement", + "native_timer_create_provider_advertisement_required", + reasonCode) || + !detail::timerAdvertisementRequirement( + request, + kBackendAgentNativeTimerUpdateCommandType, + kBackendAgentNativeTimerModifyProviderId, + kBackendAgentNativeTimerModifyProviderKind, + kBackendAgentNativeTimerUpdateCapability, + "invalid_native_timer_update_advertisement", + "native_timer_update_provider_advertisement_required", + reasonCode) || + !detail::timerAdvertisementRequirement( + request, + kBackendAgentNativeTimerToggleCommandType, + kBackendAgentNativeTimerModifyProviderId, + kBackendAgentNativeTimerModifyProviderKind, + kBackendAgentNativeTimerToggleCapability, + "invalid_native_timer_toggle_advertisement", + "native_timer_toggle_provider_advertisement_required", + reasonCode) || + !detail::timerAdvertisementRequirement( + request, + kBackendAgentNativeTimerDeleteCommandType, + kBackendAgentNativeTimerDeleteProviderId, + kBackendAgentNativeTimerDeleteProviderKind, + kBackendAgentNativeTimerDeleteCapability, + "invalid_native_timer_delete_advertisement", + "native_timer_delete_provider_advertisement_required", + reasonCode)) + return false; - reasonCode = "native_timer_delete_advertisement_accepted"; + reasonCode = "native_timer_advertisement_accepted"; return true; } +inline bool backendAgentNativeTimerDeleteAdvertisementValid( + const BackendAgentCommandPollRequest& request, + std::string& reasonCode) +{ + return backendAgentNativeTimerAdvertisementValid(request, reasonCode); +} + } // namespace vdrsuite::agent diff --git a/core/agent/src/BackendAgentClient.cpp b/core/agent/src/BackendAgentClient.cpp index 5283847a9..380488acf 100644 --- a/core/agent/src/BackendAgentClient.cpp +++ b/core/agent/src/BackendAgentClient.cpp @@ -3,6 +3,9 @@ #include "BackendAgentChannelObservationJson.h" #include "BackendAgentCommandClient.h" #include "BackendAgentLifecycle.h" +#include "BackendAgentNativeTimerCreate.h" +#include "BackendAgentNativeTimerDelete.h" +#include "BackendAgentNativeTimerModify.h" #include @@ -776,8 +779,42 @@ bool BackendAgentClientRuntime::loadConfig( reasonCode = "invalid_capability_configuration"; return false; } - if (config.commandTypes.size() > 1 || - (!config.commandTypes.empty() && config.commandTypes.front() != "probe.noop")) + const std::vector allowedCommandTypes = { + "probe.noop", + vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType, + vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType, + vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType, + vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType, + }; + std::vector uniqueCommandTypes; + for (const std::string& type : config.commandTypes) + { + if (std::find( + allowedCommandTypes.begin(), + allowedCommandTypes.end(), + type) == allowedCommandTypes.end() || + std::find( + uniqueCommandTypes.begin(), + uniqueCommandTypes.end(), + type) != uniqueCommandTypes.end()) + { + reasonCode = "invalid_command_type_configuration"; + return false; + } + uniqueCommandTypes.push_back(type); + } + const bool probeNoopConfigured = std::find( + config.commandTypes.begin(), + config.commandTypes.end(), + "probe.noop") != config.commandTypes.end(); + const bool timerCommandsConfigured = std::any_of( + config.commandTypes.begin(), + config.commandTypes.end(), + [](const std::string& type) { + return type.rfind("vdr.timer.", 0) == 0; + }); + if ((probeNoopConfigured && config.commandTypes.size() != 1) || + (timerCommandsConfigured && config.suiteBridgeHost.empty())) { reasonCode = "invalid_command_type_configuration"; return false; diff --git a/core/agent/src/BackendAgentCommandClient.cpp b/core/agent/src/BackendAgentCommandClient.cpp index 0fdbed20b..c9310610e 100644 --- a/core/agent/src/BackendAgentCommandClient.cpp +++ b/core/agent/src/BackendAgentCommandClient.cpp @@ -6,11 +6,17 @@ #include "BackendAgentNativeProbeCommandHandler.h" #include "BackendAgentNativeTimerCreate.h" #include "BackendAgentNativeTimerCreateCommandHandler.h" +#include "BackendAgentNativeTimerCreateExecutor.h" #include "BackendAgentNativeTimerDeleteCommandHandler.h" +#include "BackendAgentNativeTimerDeleteExecutor.h" #include "BackendAgentNativeTimerModifyCommandHandler.h" +#include "BackendAgentNativeTimerModifyExecutor.h" +#include #include +#include #include +#include #include namespace @@ -255,17 +261,143 @@ struct CommandAvailability std::vector localProviders; }; +bool hasCommandType( + const BackendAgentCommandClientConfig& config, + const char* commandType) +{ + return std::find( + config.commandTypes.begin(), config.commandTypes.end(), commandType) != + config.commandTypes.end(); +} + +bool hasCapability( + const vdrsuite::agent::BackendAgentLocalProviderFacts& facts, + const char* capability) +{ + return std::find( + facts.capabilities.begin(), facts.capabilities.end(), capability) != + facts.capabilities.end(); +} + +bool mergeProviderFacts( + std::vector& providers, + const vdrsuite::agent::BackendAgentLocalProviderFacts& facts) +{ + auto existing = std::find_if( + providers.begin(), providers.end(), + [&](const auto& value) { return value.providerId == facts.providerId; }); + if (existing == providers.end()) + { + providers.push_back(facts); + return true; + } + if (existing->providerKind != facts.providerKind || + existing->providerInstanceEpoch != facts.providerInstanceEpoch || + existing->providerGeneration != facts.providerGeneration || + existing->capabilityRevision != facts.capabilityRevision || + existing->available != facts.available) + return false; + for (const std::string& capability : facts.capabilities) + if (!hasCapability(*existing, capability)) + existing->capabilities.push_back(capability); + return true; +} + CommandAvailability availableCommands( const BackendAgentCommandClientConfig& config) { CommandAvailability availability; + std::vector timerProviders; + std::vector supportedTimerTypes; + bool timerSnapshotCoherent = true; + + const auto discoverTimer = + [&](auto* transport, + const std::initializer_list>& + requirements) { + bool requested = false; + for (const auto& requirement : requirements) + requested = requested || + hasCommandType(config, requirement.first); + if (!requested || transport == nullptr) return; + + vdrsuite::agent::BackendAgentLocalProviderFacts facts; + std::string reason; + try + { + if (!transport->discoverProvider(facts, reason)) + { + timerSnapshotCoherent = false; + return; + } + } + catch (...) + { + timerSnapshotCoherent = false; + return; + } + if (!vdrsuite::agent::backendAgentLocalProviderValidFacts(facts) || + facts.providerId != + vdrsuite::agent::kBackendAgentNativeTimerCreateProviderId || + facts.providerKind != + vdrsuite::agent::kBackendAgentNativeTimerCreateProviderKind) + { + timerSnapshotCoherent = false; + return; + } + if (!facts.available) return; + + std::vector supported; + for (const auto& requirement : requirements) + if (hasCommandType(config, requirement.first) && + hasCapability(facts, requirement.second)) + supported.push_back(requirement.first); + if (supported.empty()) + { + timerSnapshotCoherent = false; + return; + } + if (!mergeProviderFacts(timerProviders, facts)) + { + timerSnapshotCoherent = false; + return; + } + supportedTimerTypes.insert( + supportedTimerTypes.end(), supported.begin(), supported.end()); + }; + + discoverTimer( + config.nativeTimerCreateTransport, + {{vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType, + vdrsuite::agent::kBackendAgentNativeTimerCreateCapability}}); + discoverTimer( + config.nativeTimerDeleteTransport, + {{vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType, + vdrsuite::agent::kBackendAgentNativeTimerDeleteCapability}}); + discoverTimer( + config.nativeTimerModifyTransport, + {{vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType, + vdrsuite::agent::kBackendAgentNativeTimerUpdateCapability}, + {vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType, + vdrsuite::agent::kBackendAgentNativeTimerToggleCapability}}); + for (const std::string& type : config.commandTypes) { - if (type == vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType || + const bool timerType = + type == vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType || type == vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType || type == vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType || - type == vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType) + type == vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType; + if (timerType) + { + if (timerSnapshotCoherent && + std::find( + supportedTimerTypes.begin(), + supportedTimerTypes.end(), + type) != supportedTimerTypes.end()) + availability.commandTypes.push_back(type); continue; + } if (type != "vdr.native.probe") { availability.commandTypes.push_back(type); @@ -274,11 +406,33 @@ CommandAvailability availableCommands( vdrsuite::agent::BackendAgentLocalProviderFacts facts; std::string reason; if (!vdrsuite::agent::backendAgentNativeProbeCommandAvailability( - config.nativeProbeTransport, facts, reason)) + config.nativeProbeTransport, facts, reason) || + !mergeProviderFacts(availability.localProviders, facts)) continue; availability.commandTypes.push_back(type); - availability.localProviders.push_back(facts); } + + if (timerSnapshotCoherent && + std::any_of( + availability.commandTypes.begin(), + availability.commandTypes.end(), + [](const std::string& type) { + return type.rfind("vdr.timer.", 0) == 0; + })) + for (const auto& facts : timerProviders) + if (!mergeProviderFacts(availability.localProviders, facts)) + { + availability.commandTypes.erase( + std::remove_if( + availability.commandTypes.begin(), + availability.commandTypes.end(), + [](const std::string& type) { + return type.rfind("vdr.timer.", 0) == 0; + }), + availability.commandTypes.end()); + availability.localProviders.clear(); + break; + } return availability; } } diff --git a/core/agent/src/BackendAgentCommandDelivery.cpp b/core/agent/src/BackendAgentCommandDelivery.cpp index b4aaeb7c1..1dea92c15 100644 --- a/core/agent/src/BackendAgentCommandDelivery.cpp +++ b/core/agent/src/BackendAgentCommandDelivery.cpp @@ -454,7 +454,7 @@ BackendAgentCommandPollResult BackendAgentCommandRepository::poll(const BackendA auto transactionLease = database_.acquireTransactionLease(); BackendAgentCommandPollResult result; result.accepted=true; result.reasonCode="no_command_available"; std::string advertisementReason; - if(!backendAgentNativeTimerDeleteAdvertisementValid(request,advertisementReason)) + if(!backendAgentNativeTimerAdvertisementValid(request,advertisementReason)) {result.accepted=false;result.reasonCode=advertisementReason;return result;} if (!database_.execute("BEGIN IMMEDIATE;")) { result.accepted=false; result.reasonCode="command_database_unavailable"; return result; } bool ok=database_.execute( @@ -498,7 +498,7 @@ BackendAgentCommandPollResult BackendAgentCommandRepository::poll(const BackendA if (ok) { sqlite3_stmt* select=nullptr; - const std::string sql=std::string("SELECT ")+AssignmentColumns+" FROM backend_agent_commands c WHERE c.backend_id=? AND c.agent_id=? AND c.agent_instance_id=? AND c.backend_generation=? AND c.deadline>=? AND ((c.state='assigned' AND NOT EXISTS(SELECT 1 FROM backend_agent_command_receipts r WHERE r.command_id=c.command_id)) OR c.replay_requested=1) AND EXISTS(SELECT 1 FROM backend_agent_command_capabilities x WHERE x.backend_id=c.backend_id AND x.agent_id=c.agent_id AND x.agent_instance_id=c.agent_instance_id AND x.backend_generation=c.backend_generation AND x.command_type=c.command_type) AND (c.command_type!='vdr.native.probe' OR EXISTS(SELECT 1 FROM backend_agent_command_provider_selections s JOIN backend_agent_local_provider_ownership o ON o.backend_id=s.backend_id AND o.authority_domain=s.authority_domain JOIN backend_agent_local_provider_facts f ON f.backend_id=s.backend_id AND f.provider_id=s.provider_id WHERE s.command_id=c.command_id AND o.active=1 AND o.provider_id=s.provider_id AND o.provider_kind=s.provider_kind AND o.ownership_generation=s.ownership_generation AND f.agent_id=c.agent_id AND f.agent_instance_id=c.agent_instance_id AND f.backend_generation=c.backend_generation AND f.provider_kind=s.provider_kind AND f.provider_instance_epoch=s.provider_instance_epoch AND f.provider_generation=s.provider_generation AND f.capability_revision=s.capability_revision AND f.available=1)) ORDER BY c.assigned_at LIMIT 1;"; + const std::string sql=std::string("SELECT ")+AssignmentColumns+" FROM backend_agent_commands c WHERE c.backend_id=? AND c.agent_id=? AND c.agent_instance_id=? AND c.backend_generation=? AND c.deadline>=? AND ((c.state='assigned' AND NOT EXISTS(SELECT 1 FROM backend_agent_command_receipts r WHERE r.command_id=c.command_id)) OR c.replay_requested=1) AND EXISTS(SELECT 1 FROM backend_agent_command_capabilities x WHERE x.backend_id=c.backend_id AND x.agent_id=c.agent_id AND x.agent_instance_id=c.agent_instance_id AND x.backend_generation=c.backend_generation AND x.command_type=c.command_type) AND (c.command_type NOT IN('vdr.native.probe','vdr.timer.create','vdr.timer.update','vdr.timer.toggle','vdr.timer.delete') OR EXISTS(SELECT 1 FROM backend_agent_command_provider_selections s JOIN backend_agent_local_provider_ownership o ON o.backend_id=s.backend_id AND o.authority_domain=s.authority_domain JOIN backend_agent_local_provider_facts f ON f.backend_id=s.backend_id AND f.provider_id=s.provider_id WHERE s.command_id=c.command_id AND o.active=1 AND o.provider_id=s.provider_id AND o.provider_kind=s.provider_kind AND o.ownership_generation=s.ownership_generation AND f.agent_id=c.agent_id AND f.agent_instance_id=c.agent_instance_id AND f.backend_generation=c.backend_generation AND f.provider_kind=s.provider_kind AND f.provider_instance_epoch=s.provider_instance_epoch AND f.provider_generation=s.provider_generation AND f.capability_revision=s.capability_revision AND f.available=1)) ORDER BY c.assigned_at LIMIT 1;"; if (sqlite3_prepare_v2(database_.handle(),sql.c_str(),-1,&select,nullptr)!=SQLITE_OK||!bindText(select,1,request.backendId)||!bindText(select,2,agentId)||!bindText(select,3,request.agentInstanceId)||!bindInt(select,4,static_cast(request.backendGeneration))||!bindInt(select,5,now)) ok=false; else { @@ -508,7 +508,9 @@ BackendAgentCommandPollResult BackendAgentCommandRepository::poll(const BackendA } if(ok&&result.assignment.present&& (result.assignment.commandType==kBackendAgentNativeTimerCreateCommandType|| - result.assignment.commandType==kBackendAgentNativeTimerDeleteCommandType)) + result.assignment.commandType==kBackendAgentNativeTimerDeleteCommandType|| + result.assignment.commandType==kBackendAgentNativeTimerUpdateCommandType|| + result.assignment.commandType==kBackendAgentNativeTimerToggleCommandType)) { std::string providerReason; if(!localProviderSelectionCurrent(result.assignment.commandId,providerReason)) diff --git a/core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp b/core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp index 9c7878f34..6d1526e38 100644 --- a/core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp +++ b/core/agent/src/SuiteBridgeSvdrpNativeTimerCreateTransport.cpp @@ -325,7 +325,7 @@ bool SuiteBridgeNativeTimerCreateTransport::discoverProvider( facts.providerInstanceEpoch = values[6]; facts.providerGeneration = providerGeneration; facts.capabilityRevision = capabilityRevision; - facts.available = true; + facts.available = values[4] == EnabledState; facts.capabilities = { kBackendAgentNativeTimerCreateCapability }; diff --git a/core/agent/src/SuiteBridgeSvdrpNativeTimerDeleteTransport.cpp b/core/agent/src/SuiteBridgeSvdrpNativeTimerDeleteTransport.cpp index 32dfa59ea..79bf339f8 100644 --- a/core/agent/src/SuiteBridgeSvdrpNativeTimerDeleteTransport.cpp +++ b/core/agent/src/SuiteBridgeSvdrpNativeTimerDeleteTransport.cpp @@ -208,7 +208,7 @@ bool SuiteBridgeNativeTimerDeleteTransport::discoverProvider( facts.providerInstanceEpoch = values[6]; facts.providerGeneration = providerGeneration; facts.capabilityRevision = capabilityRevision; - facts.available = true; + facts.available = values[4] == EnabledState; facts.capabilities = {kBackendAgentNativeTimerDeleteCapability}; if (!backendAgentLocalProviderValidFacts(facts)) { diff --git a/core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp b/core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp index 0b4247c39..241ec75a6 100644 --- a/core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp +++ b/core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp @@ -32,13 +32,14 @@ std::string hexText(const std::string& value){if(value.empty())return "-";std::s BackendAgentNativeTimerModifyTransportReply result( BackendAgentNativeTimerModifyTransportDisposition d,std::string e){return {d,std::move(e)};} bool parseCapability(const SuiteBridgeCommandReply& reply,const char* capability, - std::string& epoch,std::uint64_t& generation,std::uint64_t& revision,std::string& reason){ + std::string& epoch,std::uint64_t& generation,std::uint64_t& revision, + bool& enabled,std::string& reason){ if(!reply.transportSucceeded()||reply.replyCode!=CapabilityReplyCode){reason="ntmod_capability_unavailable";return false;} auto v=split(reply.payload,10);if(v.size()!=10||v[0]!=CapabilityProtocol||v[1]!=capability|| v[2]!="1"||v[3]!="timer-modify"||(v[4]!="enabled"&&v[4]!="disabled")||v[9]!=v[4]|| v[5]!=kBackendAgentNativeTimerModifyProviderKind||!safeToken(v[6],192)|| !number(v[7],generation)||!number(v[8],revision)){reason="ntmod_capability_invalid";return false;} - epoch=v[6];return true;} + epoch=v[6];enabled=v[4]=="enabled";return true;} } SuiteBridgeCommandReply SuiteBridgeSvdrpTransport::discoverNativeTimerModifyContract( @@ -76,21 +77,25 @@ bool SuiteBridgeNativeTimerModifyTransport::discoverProvider( BackendAgentLocalProviderFacts& facts,std::string& reason) { facts={};std::string updateEpoch,toggleEpoch;std::uint64_t ug=0,ur=0,tg=0,tr=0; + bool updateEnabled=false,toggleEnabled=false; if(!parseCapability(transport_.discoverNativeTimerModifyContract( BackendAgentNativeTimerModifyKind::update),kBackendAgentNativeTimerUpdateCapability, - updateEpoch,ug,ur,reason)|| + updateEpoch,ug,ur,updateEnabled,reason)|| !parseCapability(transport_.discoverNativeTimerModifyContract( BackendAgentNativeTimerModifyKind::toggle),kBackendAgentNativeTimerToggleCapability, - toggleEpoch,tg,tr,reason)||updateEpoch!=toggleEpoch||ug!=tg||ur!=tr){ + toggleEpoch,tg,tr,toggleEnabled,reason)||updateEpoch!=toggleEpoch||ug!=tg||ur!=tr|| + updateEnabled!=toggleEnabled){ reason="native_timer_modify_suitebridge_capability_invalid";return false;} facts.providerId=kBackendAgentNativeTimerModifyProviderId; facts.providerKind=kBackendAgentNativeTimerModifyProviderKind; facts.providerInstanceEpoch=updateEpoch;facts.providerGeneration=ug; - facts.capabilityRevision=ur;facts.available=true; + facts.capabilityRevision=ur;facts.available=updateEnabled; facts.capabilities={kBackendAgentNativeTimerUpdateCapability, kBackendAgentNativeTimerToggleCapability}; if(!backendAgentLocalProviderValidFacts(facts)){facts={};reason="native_timer_modify_provider_invalid";return false;} - reason="native_timer_modify_suitebridge_provider_discovered";return true; + reason=updateEnabled?"native_timer_modify_suitebridge_provider_discovered": + "native_timer_modify_suitebridge_provider_discovered_disabled"; + return true; } BackendAgentNativeTimerModifyTransportReply diff --git a/core/agent/tests/test_backend_agent_client.cpp b/core/agent/tests/test_backend_agent_client.cpp index e2f947a39..ca0f9bf8b 100644 --- a/core/agent/tests/test_backend_agent_client.cpp +++ b/core/agent/tests/test_backend_agent_client.cpp @@ -133,6 +133,7 @@ void test_protected_url_and_configuration() << "OBSERVATION_DOMAINS=backend-health\n" << "SUITEBRIDGE_HOST=127.0.0.1\n" << "SUITEBRIDGE_PORT=6419\n" + << "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete\n" << "HEARTBEAT_INTERVAL_SECONDS=30\n"; output.close(); BackendAgentClientConfig config; @@ -143,6 +144,9 @@ void test_protected_url_and_configuration() assert(config.observationDomains.size() == 1); assert(config.suiteBridgeHost == "127.0.0.1"); assert(config.suiteBridgePort == 6419); + assert(config.commandTypes == std::vector({ + "vdr.timer.create", "vdr.timer.update", + "vdr.timer.toggle", "vdr.timer.delete"})); assert(config.nativeTimerCreateTransport == nullptr); assert(config.nativeTimerDeleteTransport == nullptr); diff --git a/core/agent/tests/test_backend_agent_command_client.cpp b/core/agent/tests/test_backend_agent_command_client.cpp index 3163ae9f3..750612f02 100644 --- a/core/agent/tests/test_backend_agent_command_client.cpp +++ b/core/agent/tests/test_backend_agent_command_client.cpp @@ -1,13 +1,20 @@ #include "BackendAgentClient.h" #include "BackendAgentCommand.h" #include "BackendAgentCommandClient.h" +#include "BackendAgentCommandJson.h" +#include "BackendAgentNativeTimerCreateExecutor.h" +#include "BackendAgentNativeTimerDeleteExecutor.h" +#include "BackendAgentNativeTimerModifyExecutor.h" +#include #include #include #include #include #include #include +#include +#include namespace { @@ -64,10 +71,178 @@ void writeState(const std::string& path,bool resultAcknowledged) out.close(); assert(chmod(path.c_str(),0600)==0); } + +using vdrsuite::agent::BackendAgentLocalProviderFacts; + +BackendAgentLocalProviderFacts timerFacts( + std::vector capabilities, + const std::string& epoch = "pie_timer_activation", + bool available = true) +{ + BackendAgentLocalProviderFacts facts; + facts.providerId = "suitebridge:local"; + facts.providerKind = "suitebridge"; + facts.providerInstanceEpoch = epoch; + facts.providerGeneration = 1; + facts.capabilityRevision = 1; + facts.available = available; + facts.capabilities = std::move(capabilities); + return facts; +} + +class PollTransport final : public IBackendAgentControlPlaneTransport +{ +public: + std::string requestBody; + BackendAgentTransportResponse postEnrollment( + const std::string&, const std::string&, const std::string&, + const std::string&) override + { + return {}; + } + BackendAgentTransportResponse postAuthenticated( + const std::string&, const std::string&, const std::string& path, + const std::string& body) override + { + assert(path == "/api/agent/v1/commands/poll"); + requestBody = body; + return { + true, 200, + "{\"hasAssignment\":false,\"reasonCode\":\"no_command_available\"}", + ""}; + } +}; + +class CreateTransport final + : public vdrsuite::agent::IBackendAgentNativeTimerCreateTransport +{ +public: + BackendAgentLocalProviderFacts facts = timerFacts({"vdr.timer.create"}); + bool discoverProvider( + BackendAgentLocalProviderFacts& value, std::string& reason) override + { + value = facts; reason = facts.available ? "enabled" : "disabled"; + return true; + } + vdrsuite::agent::BackendAgentNativeTimerCreateTransportReply createTimer( + const vdrsuite::agent::BackendAgentNativeTimerCreateTransportRequest&) + override + { + return {}; + } +}; + +class DeleteTransport final + : public vdrsuite::agent::IBackendAgentNativeTimerDeleteTransport +{ +public: + BackendAgentLocalProviderFacts facts = timerFacts({"vdr.timer.delete"}); + bool discoverProvider( + BackendAgentLocalProviderFacts& value, std::string& reason) override + { + value = facts; reason = facts.available ? "enabled" : "disabled"; + return true; + } + vdrsuite::agent::BackendAgentNativeTimerDeleteTransportReply deleteTimer( + const vdrsuite::agent::BackendAgentNativeTimerDeleteTransportRequest&) + override + { + return {}; + } +}; + +class ModifyTransport final + : public vdrsuite::agent::IBackendAgentNativeTimerModifyTransport +{ +public: + BackendAgentLocalProviderFacts facts = + timerFacts({"vdr.timer.update", "vdr.timer.toggle"}); + bool discoverProvider( + BackendAgentLocalProviderFacts& value, std::string& reason) override + { + value = facts; reason = facts.available ? "enabled" : "disabled"; + return true; + } + vdrsuite::agent::BackendAgentNativeTimerModifyTransportReply modifyTimer( + const vdrsuite::agent::BackendAgentNativeTimerModifyTransportRequest&) + override + { + return {}; + } +}; + +BackendAgentCommandPollRequest capturePoll( + const BackendAgentCommandClientConfig& config, + const BackendAgentCommandClientContext& context, + PollTransport& transport, + std::string& reason) +{ + assert(pollBackendAgentCommand(config, context, transport, reason)); + BackendAgentCommandPollRequest request; + assert(parseBackendAgentCommandPollRequestJson( + transport.requestBody, request, reason)); + return request; +} + +void testTimerAdvertisementActivation() +{ + const std::string path = "/tmp/vdr-suite-command-activation-state"; + std::remove(path.c_str()); + + CreateTransport create; + DeleteTransport remove; + ModifyTransport modify; + BackendAgentCommandClientConfig config; + config.statePath = path; + config.commandTypes = { + "vdr.timer.create", "vdr.timer.update", + "vdr.timer.toggle", "vdr.timer.delete"}; + config.nativeTimerCreateTransport = &create; + config.nativeTimerDeleteTransport = &remove; + config.nativeTimerModifyTransport = &modify; + const BackendAgentCommandClientContext context{ + "agt_client", "secret-material-at-least-thirty-two-bytes", + "default", "agi_current", 8}; + std::string reason; + + PollTransport enabledTransport; + const auto enabled = capturePoll(config, context, enabledTransport, reason); + assert(enabled.supportedCommandTypes == config.commandTypes); + assert(enabled.localProviders.size() == 1); + const auto& provider = enabled.localProviders.front(); + for (const std::string& capability : config.commandTypes) + assert(std::find( + provider.capabilities.begin(), provider.capabilities.end(), + capability) != provider.capabilities.end()); + + create.facts.available = false; + PollTransport partialTransport; + const auto partial = capturePoll(config, context, partialTransport, reason); + assert(partial.supportedCommandTypes == + std::vector({ + "vdr.timer.update", "vdr.timer.toggle", "vdr.timer.delete"})); + assert(partial.localProviders.size() == 1); + assert(std::find( + partial.localProviders.front().capabilities.begin(), + partial.localProviders.front().capabilities.end(), + "vdr.timer.create") == + partial.localProviders.front().capabilities.end()); + + create.facts.available = true; + modify.facts.providerInstanceEpoch = "pie_timer_activation_replaced"; + PollTransport incoherentTransport; + const auto incoherent = + capturePoll(config, context, incoherentTransport, reason); + assert(incoherent.supportedCommandTypes.empty()); + assert(incoherent.localProviders.empty()); + + std::remove(path.c_str()); +} } int main() { + testTimerAdvertisementActivation(); const std::string path="/tmp/vdr-suite-command-restart-state"; std::remove(path.c_str()); BackendAgentCommandClientConfig config{path,{"probe.noop"}}; diff --git a/core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp b/core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp index 4f7c0487c..68127e81a 100644 --- a/core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp +++ b/core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp @@ -194,8 +194,7 @@ int main() assert(advertisementClosed.accepted); assert(!advertisementClosed.assignment.present); - // Repository-level fixture can explicitly advertise CREATE to exercise the - // delivery boundary. Production Agent advertisement remains closed elsewhere. + // An enabled, fenced provider advertisement makes CREATE deliverable. const auto delivered = commands.poll( pollRequest({kBackendAgentNativeTimerCreateCommandType}, {firstFacts}), "agt_create_delivery", 120); diff --git a/core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp b/core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp index 4fd134e2a..224790ba7 100644 --- a/core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp +++ b/core/agent/tests/test_suite_bridge_svdrp_native_timer_create_transport.cpp @@ -262,7 +262,7 @@ int main() "pie_1"); assert(facts.providerGeneration == 1); assert(facts.capabilityRevision == 1); - assert(facts.available); + assert(!facts.available); assert(facts.capabilities.size() == 1); assert(facts.capabilities.front() == "vdr.timer.create"); diff --git a/core/agent/tests/test_suite_bridge_svdrp_native_timer_delete_transport.cpp b/core/agent/tests/test_suite_bridge_svdrp_native_timer_delete_transport.cpp index 7e4372a69..22b904071 100644 --- a/core/agent/tests/test_suite_bridge_svdrp_native_timer_delete_transport.cpp +++ b/core/agent/tests/test_suite_bridge_svdrp_native_timer_delete_transport.cpp @@ -143,7 +143,7 @@ int main() assert(facts.providerInstanceEpoch == "pie_1"); assert(facts.providerGeneration == 1); assert(facts.capabilityRevision == 1); - assert(facts.available); + assert(!facts.available); assert(facts.capabilities.size() == 1); assert(facts.capabilities.front() == "vdr.timer.delete"); assert(server.request() == "PLUG suitebridge NTDEL CAP 1\r\n"); diff --git a/docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md b/docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md index a67c99271..7dae530e3 100644 --- a/docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md +++ b/docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md @@ -1,18 +1,31 @@ # Phase 64 SuiteBridge native Timer command-path wiring -This slice installs the already fenced native Timer CREATE and DELETE adapters -in the normal Backend Agent process. The packaged process constructs both -adapters against the explicit loopback SuiteBridge endpoint and injects them -into the durable command client. A claimed Control Plane command can therefore -flow through the Agent executor to the private SuiteBridge command service -without an alternate transport or manual handoff. +This slice installs the fenced native Timer CREATE, UPDATE, TOGGLE and DELETE +adapters in the normal Backend Agent process. The packaged process constructs +the adapters against the explicit loopback SuiteBridge endpoint and injects +them into the durable command client. A claimed Control Plane command can flow +through the Agent executor to the private SuiteBridge command service without +an alternate transport or manual handoff. The transport endpoint is restricted to loopback and both host and port must be configured together. Adapter construction performs no mutation and opens no connection by itself. -The shipped Timer advertisement remains closed: the packaged -`COMMAND_TYPES` value is empty and the command client still suppresses -`vdr.timer.create` and `vdr.timer.delete` from its availability request. -Opening that advertisement is a separate acceptance-gated action after the -exact candidate has passed the bounded real yaVDR test. +The exact closed candidate +`692ac34f3aee1ead70cab17425a4f9e74f091792` passed the bounded real-yaVDR +acceptance before activation. The shipped `COMMAND_TYPES` value now contains +exactly `vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete`. + +Advertisement remains fail-closed at runtime. Every poll rediscovers the private +SuiteBridge capabilities. A command type is included only when its service +reports `enabled`, the provider facts are valid and the required capability is +present. CREATE, UPDATE/TOGGLE and DELETE facts are merged into one +`suitebridge:local` record only when instance epoch, provider generation and +capability revision agree. A conflicting discovery snapshot suppresses all +Timer command types for that poll. + +The Control Plane validates that every advertised Timer type has the matching +provider capability and rechecks the persisted provider selection before +delivery and receipt. The public SuiteBridge SVDRP Help advertisement remains +closed for `NTCREATE`, `NTMOD` and `NTDELETE`; these commands remain a +private loopback transport contract. diff --git a/packaging/systemd/backend-agent.conf b/packaging/systemd/backend-agent.conf index fdd8dc7a0..e3bddc82c 100644 --- a/packaging/systemd/backend-agent.conf +++ b/packaging/systemd/backend-agent.conf @@ -14,11 +14,11 @@ OBSERVATION_DOMAINS=backend-health CHANNELS_CONF_PATH=/var/lib/vdr/channels.conf COMMAND_STATE_PATH=/var/lib/vdr-suite/backend-agent/commands.state -# Native Timer transports are constructed against loopback SuiteBridge, but -# command advertisement remains closed until the Phase-64 acceptance gate. +# Native Timer commands are advertised only after each loopback SuiteBridge +# capability is discovered as enabled with one coherent provider fence. SUITEBRIDGE_HOST=127.0.0.1 SUITEBRIDGE_PORT=6419 -COMMAND_TYPES= +COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete HEARTBEAT_INTERVAL_SECONDS=30 RECONNECT_INITIAL_SECONDS=1 diff --git a/tools/check_phase63_fenced_native_operation_runtime.py b/tools/check_phase63_fenced_native_operation_runtime.py old mode 100755 new mode 100644 index 31b2dcacd..302ba1f33 --- a/tools/check_phase63_fenced_native_operation_runtime.py +++ b/tools/check_phase63_fenced_native_operation_runtime.py @@ -371,8 +371,8 @@ def text(relative: str) -> str: if "--native-probe" not in agent_main or "loopbackHost" not in agent_main or \ 'config.commandTypes = {"vdr.native.probe"}' not in agent_main: errors.append("runtime activation must be explicit and loopback-only") -if "COMMAND_TYPES=\n" not in packaged_config: - errors.append("packaged command types must remain disabled by default") +if "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete\n" not in packaged_config: + errors.append("packaged Timer command activation must remain exact") scoped_runtime = "\n".join([ agent_protocol, diff --git a/tools/check_phase64_command_state_extension.py b/tools/check_phase64_command_state_extension.py old mode 100755 new mode 100644 index ddb4f14eb..ad2f82177 --- a/tools/check_phase64_command_state_extension.py +++ b/tools/check_phase64_command_state_extension.py @@ -132,8 +132,7 @@ def forbid(text: str, needle: str, label: str) -> None: ) forbid(command_client, '"vdr.timer.delete"', "Timer-delete command-client literal execution") forbid(agent_client, '"vdr.timer.delete"', "Timer-delete Agent literal advertisement") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete advertisement") -forbid(packaged_config, "vdr.timer.create", "packaged Timer-create advertisement") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") for token in ( "SuiteBridgeSvdrp", diff --git a/tools/check_phase64_command_state_v3_extension.py b/tools/check_phase64_command_state_v3_extension.py index 4b924b0a3..c0c442473 100644 --- a/tools/check_phase64_command_state_v3_extension.py +++ b/tools/check_phase64_command_state_v3_extension.py @@ -161,21 +161,23 @@ def forbid(text: str, needle: str, label: str) -> None: ): require(state_owner, token, label) -# Timer-delete remains unavailable to the installed Agent runtime despite the -# ability to preserve and validate its typed local state. A later SuiteBridge -# plugin mutation callback does not change this command-advertisement fence. +# The historical state slice remains default-closed without an injected +# transport. Its accepted successor advertises DELETE only after enabled +# SuiteBridge discovery and an exact provider fence. require( command_client, "kBackendAgentNativeTimerDeleteCommandType", - "explicit Timer-delete availability fence", + "typed Timer-delete availability", ) +require(command_client, "discoverProvider", "Timer-delete provider discovery") +require(command_client, "facts.available", "Timer-delete availability fence") require( - command_client, - "continue;", - "Timer-delete availability suppression", + agent_client, + "kBackendAgentNativeTimerDeleteCommandType", + "Timer-delete Agent configuration allowlist", ) -forbid(agent_client, "vdr.timer.delete", "Timer-delete Agent configuration") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete configuration") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", + "accepted packaged Timer activation") runtime_state_sources = "\n".join( (extension_source, local_state_source, timer_delete_source, state_store, command_client) diff --git a/tools/check_phase64_managed_timer_fulfillment_acceptance.py b/tools/check_phase64_managed_timer_fulfillment_acceptance.py old mode 100755 new mode 100644 index c4cc39b9e..0e9c8fda3 --- a/tools/check_phase64_managed_timer_fulfillment_acceptance.py +++ b/tools/check_phase64_managed_timer_fulfillment_acceptance.py @@ -23,17 +23,18 @@ "backend-agent-command-admin", "make -C vdr-plugin-suite-bridge -j2 check", "candidates.sha256", - "COMMAND_TYPES=\\n", + "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,", + "vdr.timer.toggle,vdr.timer.delete\\n", "if ! python3 -", ( - "PY_CLOSED_ADVERTISEMENT\n" + "PY_ACTIVATED_ADVERTISEMENT\n" "then\n" - " fail closed_advertisement_check_failed\n" + " fail activated_advertisement_check_failed\n" "fi" ), - "closed_advertisement_evidence_missing", + "activated_advertisement_evidence_missing", 'for command in ("NTCREATE", "NTMOD", "NTDELETE")', - "ADVERTISEMENT=closed", + "ADVERTISEMENT=timer-commands-activated", "PHASE_64_MANAGED_TIMER_FULFILLMENT_ACCEPTANCE=PASS", ] for token in required: @@ -46,7 +47,7 @@ "scp ", "curl ", "wget ", - "<<'PY_CLOSED_ADVERTISEMENT' ||", + "<<'PY_ACTIVATED_ADVERTISEMENT' ||", "systemctl ", "timers.conf", "sqlite3 ", diff --git a/tools/check_phase64_native_timer_create_delivery.py b/tools/check_phase64_native_timer_create_delivery.py old mode 100755 new mode 100644 index 88b6f60d6..49bf17321 --- a/tools/check_phase64_native_timer_create_delivery.py +++ b/tools/check_phase64_native_timer_create_delivery.py @@ -1,54 +1,44 @@ #!/usr/bin/env python3 from pathlib import Path +import sys ROOT = Path(__file__).resolve().parents[1] required = { - "core/agent/src/BackendAgentCommandDelivery.cpp": [ - 'BackendAgentNativeTimerCreatePayload.h', - 'const bool timerCreate=commandType==kBackendAgentNativeTimerCreateCommandType;', - 'native_timer_create_provider_selection_mismatch', - 'result.assignment.commandType==kBackendAgentNativeTimerCreateCommandType', - 'commandType==kBackendAgentNativeTimerCreateCommandType||', - 'backendAgentNativeTimerCreateParsePayload', + "core/agent/include/BackendAgentNativeTimerCreate.h": [ + 'kBackendAgentNativeTimerCreateCommandType = "vdr.timer.create"', ], "core/agent/src/BackendAgentCommandClient.cpp": [ - 'BackendAgentNativeTimerCreate.h', - 'type == vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType ||', + "kBackendAgentNativeTimerCreateCommandType", + "nativeTimerCreateTransport", + "discoverProvider", + "mergeProviderFacts", + "facts.available", ], "core/agent/tests/test_backend_agent_native_timer_create_delivery.cpp": [ - 'Production Agent advertisement remains closed elsewhere', - 'local_provider_selection_stale', - 'persisted provider selection', - 'local_provider_selection_required', + "An enabled, fenced provider advertisement makes CREATE deliverable", + "local_provider_selection_stale", + "persisted provider selection", + "local_provider_selection_required", ], } errors = [] for relative, markers in required.items(): path = ROOT / relative - if not path.exists(): + if not path.is_file(): errors.append(f"missing {relative}") continue - text = path.read_text(encoding="utf-8") + content = path.read_text(encoding="utf-8") for marker in markers: - if marker not in text: + if marker not in content: errors.append(f"{relative}: missing marker {marker}") -client = (ROOT / "core/agent/src/BackendAgentCommandClient.cpp").read_text( +packaged = (ROOT / "packaging/systemd/backend-agent.conf").read_text( encoding="utf-8" ) -availability_start = client.find("CommandAvailability availableCommands") -availability_end = client.find("\n}", availability_start) -availability = client[availability_start:availability_end] -closed = ( - availability_start >= 0 - and "kBackendAgentNativeTimerCreateCommandType" in availability - and "kBackendAgentNativeTimerDeleteCommandType" in availability - and "continue;" in availability -) -if not closed: - errors.append("productive vdr.timer.create Agent advertisement must remain closed") +if "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete\n" not in packaged: + errors.append("productive vdr.timer.create activation is not exact") makefile = (ROOT / "Makefile").read_text(encoding="utf-8") if "include mk/phase64-native-timer-create-delivery-tests.mk" not in makefile: @@ -56,6 +46,7 @@ if errors: for error in errors: - print(error) + print(error, file=sys.stderr) raise SystemExit(1) -print("Phase 64 native Timer CREATE delivery architecture guard passed") + +print("Phase 64 native Timer CREATE delivery guard passed") diff --git a/tools/check_phase64_native_timer_delete_delivery.py b/tools/check_phase64_native_timer_delete_delivery.py old mode 100755 new mode 100644 index 2cd730af5..a2e0f2219 --- a/tools/check_phase64_native_timer_delete_delivery.py +++ b/tools/check_phase64_native_timer_delete_delivery.py @@ -75,7 +75,7 @@ def forbid(text: str, needle: str, label: str) -> None: ) require( delivery, - "backendAgentNativeTimerDeleteAdvertisementValid(request,advertisementReason)", + "backendAgentNativeTimerAdvertisementValid(request,advertisementReason)", "server-side advertisement validation", ) require( @@ -112,11 +112,13 @@ def forbid(text: str, needle: str, label: str) -> None: require(doc, "Availability is not authority", "Slice 26 authority boundary documentation") require(doc, "no local Timer-delete executor", "executor scope boundary documentation") -# Slice 26 deliberately opens only the Control-Plane protocol/delivery boundary. -# The shipped Agent must still be unable to advertise or execute the mutation. -forbid(command_client, "vdr.timer.delete", "Timer-delete Agent command-client execution") -forbid(agent_client, "vdr.timer.delete", "Timer-delete Agent configuration advertisement") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete advertisement") +# The historical protocol boundary is preserved by the accepted successor: +# production delivery requires enabled provider discovery and an exact package opt-in. +require(command_client, "discoverProvider", "Timer-delete provider discovery") +require(command_client, "facts.available", "Timer-delete availability fence") +require(agent_client, "kBackendAgentNativeTimerDeleteCommandType", + "Timer-delete Agent configuration allowlist") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") forbid( agent_sources, "BackendAgentNativeTimerDeleteAssignment.cpp", diff --git a/tools/check_phase64_native_timer_delete_local_starting.py b/tools/check_phase64_native_timer_delete_local_starting.py index c87090092..dbd4fddec 100644 --- a/tools/check_phase64_native_timer_delete_local_starting.py +++ b/tools/check_phase64_native_timer_delete_local_starting.py @@ -152,8 +152,9 @@ def forbid(text: str, needle: str, label: str) -> None: "v3 state-owner local-state validation wiring", ) forbid(command_client, "vdr.timer.delete", "Timer-delete command-client execution") -forbid(agent_client, "vdr.timer.delete", "Timer-delete Agent advertisement") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete advertisement") +require(agent_client, "kBackendAgentNativeTimerDeleteCommandType", + "Timer-delete Agent configuration allowlist") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") for token in ( "SuiteBridgeSvdrp", diff --git a/tools/check_phase64_native_timer_modify_command_path.py b/tools/check_phase64_native_timer_modify_command_path.py old mode 100755 new mode 100644 index 11f7987c0..93930c238 --- a/tools/check_phase64_native_timer_modify_command_path.py +++ b/tools/check_phase64_native_timer_modify_command_path.py @@ -1,18 +1,41 @@ #!/usr/bin/env python3 from pathlib import Path -ROOT=Path(__file__).resolve().parents[1] -def read(p):return (ROOT/p).read_text(encoding="utf-8") -for p in ("core/agent/include/SuiteBridgeNativeTimerModifyTransport.h", -"core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp", -"core/agent/include/BackendAgentNativeTimerModifyCommandHandler.h", -"core/agent/src/BackendAgentNativeTimerModifyCommandHandler.cpp"): - if not (ROOT/p).is_file():raise SystemExit("missing productive modify path: "+p) -client=read("core/agent/src/BackendAgentCommandClient.cpp") -for token in ("nativeTimerModifyTransport","timerModifyCommand", -"kBackendAgentNativeTimerUpdateCommandType","kBackendAgentNativeTimerToggleCommandType"): - if token not in client:raise SystemExit("missing modify command client integration: "+token) -main=read("apps/agent/main.cpp") -if "SuiteBridgeNativeTimerModifyTransport" not in main:raise SystemExit("modify transport not constructed") -if 'type == vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType' not in client: - raise SystemExit("UPDATE advertisement not closed") + +ROOT = Path(__file__).resolve().parents[1] + +def read(path): + target = ROOT / path + if not target.is_file(): + raise SystemExit("missing productive modify path: " + path) + return target.read_text(encoding="utf-8") + +for path in ( + "core/agent/include/SuiteBridgeNativeTimerModifyTransport.h", + "core/agent/src/SuiteBridgeSvdrpNativeTimerModifyTransport.cpp", + "core/agent/include/BackendAgentNativeTimerModifyCommandHandler.h", + "core/agent/src/BackendAgentNativeTimerModifyCommandHandler.cpp", +): + read(path) + +client = read("core/agent/src/BackendAgentCommandClient.cpp") +for token in ( + "nativeTimerModifyTransport", + "timerModifyCommand", + "kBackendAgentNativeTimerUpdateCommandType", + "kBackendAgentNativeTimerToggleCommandType", + "discoverProvider", + "facts.available", + "mergeProviderFacts", +): + if token not in client: + raise SystemExit("missing modify command client integration: " + token) + +main = read("apps/agent/main.cpp") +if "SuiteBridgeNativeTimerModifyTransport" not in main: + raise SystemExit("modify transport not constructed") + +packaged = read("packaging/systemd/backend-agent.conf") +if "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete\n" not in packaged: + raise SystemExit("modify command activation is not exact") + print("phase64 native Timer modify command path guard passed") diff --git a/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py old mode 100755 new mode 100644 index a9aa41bcc..826cfae06 --- a/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py +++ b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py @@ -2,6 +2,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +ACTIVATED = "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete" def read(path: str) -> str: target = ROOT / path @@ -21,10 +22,16 @@ def forbid(text: str, needle: str, label: str) -> None: header = read("core/agent/include/BackendAgentClient.h") client = read("core/agent/src/BackendAgentClient.cpp") command_client = read("core/agent/src/BackendAgentCommandClient.cpp") +delivery = read("core/agent/src/BackendAgentCommandDelivery.cpp") +advertisement = read( + "core/agent/include/BackendAgentNativeTimerDeleteAdvertisement.h" +) main = read("apps/agent/main.cpp") runtime_mk = read("mk/backend-agent-runtime.mk") packaged = read("packaging/systemd/backend-agent.conf") -test = read("core/agent/tests/test_backend_agent_client.cpp") +client_test = read("core/agent/tests/test_backend_agent_command_client.cpp") +config_test = read("core/agent/tests/test_backend_agent_client.cpp") +svdrp = read("vdr-plugin-suite-bridge/suitebridge_svdrp.cpp") doc = read("docs/development/phase-64-suitebridge-native-timer-command-path-wiring.md") include = "include mk/phase64-suitebridge-native-timer-command-path-wiring-tests.mk" @@ -32,68 +39,108 @@ def forbid(text: str, needle: str, label: str) -> None: if makefile.count(include) != 1: raise SystemExit("command-path wiring Make include must occur exactly once") -for needle, label in ( - ("suiteBridgeHost", "loopback SuiteBridge host configuration"), - ("suiteBridgePort", "SuiteBridge port configuration"), - ("nativeTimerCreateTransport = nullptr", "default-closed CREATE injection"), - ("nativeTimerDeleteTransport = nullptr", "default-closed DELETE injection"), -): - require(header, needle, label) - -for needle, label in ( - ('"SUITEBRIDGE_HOST"', "SuiteBridge host parser"), - ('"SUITEBRIDGE_PORT"', "SuiteBridge port parser"), - ("loopbackHost(config.suiteBridgeHost)", "loopback-only SuiteBridge fence"), - ("config_.nativeTimerCreateTransport", "CREATE CommandClient injection"), - ("config_.nativeTimerDeleteTransport", "DELETE CommandClient injection"), +for needle in ( + "suiteBridgeHost", + "suiteBridgePort", + "nativeTimerCreateTransport = nullptr", + "nativeTimerDeleteTransport = nullptr", + "nativeTimerModifyTransport = nullptr", ): - require(client, needle, label) - -for needle, label in ( - ('#include "SuiteBridgeNativeTimerCreateTransport.h"', "CREATE adapter include"), - ('#include "SuiteBridgeNativeTimerDeleteTransport.h"', "DELETE adapter include"), - ("SuiteBridgeNativeTimerCreateTransport", "CREATE adapter construction"), - ("SuiteBridgeNativeTimerDeleteTransport", "DELETE adapter construction"), - ("config.nativeTimerCreateTransport =", "CREATE runtime injection"), - ("config.nativeTimerDeleteTransport =", "DELETE runtime injection"), + require(header, needle, "default-closed transport injection") + +for needle in ( + '"SUITEBRIDGE_HOST"', + '"SUITEBRIDGE_PORT"', + "loopbackHost(config.suiteBridgeHost)", + "kBackendAgentNativeTimerCreateCommandType", + "kBackendAgentNativeTimerUpdateCommandType", + "kBackendAgentNativeTimerToggleCommandType", + "kBackendAgentNativeTimerDeleteCommandType", + "timerCommandsConfigured && config.suiteBridgeHost.empty()", ): - require(main, needle, label) + require(client, needle, "Timer configuration fence") -for needle, label in ( - ("$(AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC)", "CREATE adapter binary link"), - ("$(AGENT_NATIVE_TIMER_DELETE_TRANSPORT_SRC)", "DELETE adapter binary link"), +for needle in ( + "SuiteBridgeNativeTimerCreateTransport", + "SuiteBridgeNativeTimerDeleteTransport", + "SuiteBridgeNativeTimerModifyTransport", + "config.nativeTimerCreateTransport =", + "config.nativeTimerDeleteTransport =", + "config.nativeTimerModifyTransport =", ): - require(runtime_mk, needle, label) + require(main, needle, "installed Timer adapter wiring") -for needle, label in ( - ("SUITEBRIDGE_HOST=127.0.0.1", "packaged loopback host"), - ("SUITEBRIDGE_PORT=6419", "packaged SuiteBridge port"), - ("COMMAND_TYPES=", "closed packaged command advertisement"), +for needle in ( + "$(AGENT_NATIVE_TIMER_CREATE_TRANSPORT_SRC)", + "$(AGENT_NATIVE_TIMER_DELETE_TRANSPORT_SRC)", + "$(AGENT_NATIVE_TIMER_MODIFY_TRANSPORT_SRC)", ): - require(packaged, needle, label) -for token in ("vdr.timer.create", "vdr.timer.delete"): - forbid(packaged, token, "premature packaged Timer command advertisement") + require(runtime_mk, needle, "Timer adapter binary link") + +if packaged.count("COMMAND_TYPES=") != 1: + raise SystemExit("packaged command activation must occur exactly once") +require(packaged, ACTIVATED + "\n", "exact packaged Timer activation") availability = command_client[ command_client.find("CommandAvailability availableCommands"): command_client.find("bool reconcileBackendAgentCommandState") ] for needle in ( + "discoverProvider", + "facts.available", + "mergeProviderFacts", + "timerSnapshotCoherent", + "providerInstanceEpoch", + "providerGeneration", + "capabilityRevision", +): + require(availability, needle, "runtime availability fence") +for token in ( "kBackendAgentNativeTimerCreateCommandType", + "kBackendAgentNativeTimerUpdateCommandType", + "kBackendAgentNativeTimerToggleCommandType", "kBackendAgentNativeTimerDeleteCommandType", - "continue;", ): - require(availability, needle, "closed Timer advertisement fence") + require(availability, token, "activated Timer command type") -for needle in ( - "SUITEBRIDGE_HOST=127.0.0.1", - "SUITEBRIDGE_PORT=6419", - 'config.suiteBridgeHost == "127.0.0.1"', - "config.suiteBridgePort == 6419", - "invalid_suitebridge_configuration", +for token in ( + "backendAgentNativeTimerAdvertisementValid", + "native_timer_create_provider_advertisement_required", + "native_timer_update_provider_advertisement_required", + "native_timer_toggle_provider_advertisement_required", + "native_timer_delete_provider_advertisement_required", +): + require(advertisement + delivery, token, "Control Plane advertisement fence") + +for token in ( + "kBackendAgentNativeTimerUpdateCommandType", + "kBackendAgentNativeTimerToggleCommandType", + "localProviderSelectionCurrent", +): + require(delivery, token, "pre-delivery provider fence") + +for token in ( + "testTimerAdvertisementActivation", + "pie_timer_activation_replaced", + "supportedCommandTypes.empty()", + "localProviders.size() == 1", +): + require(client_test, token, "activation regression") +require(config_test, ACTIVATED, "configuration activation regression") + +help_start = svdrp.find("SVDRPHelpPages") +command_start = svdrp.find("SVDRPCommand", help_start) +if help_start < 0 or command_start < 0: + raise SystemExit("SuiteBridge help boundary missing") +for command in ("NTCREATE", "NTMOD", "NTDELETE"): + forbid(svdrp[help_start:command_start], command, "public Timer write help") + +for token in ( + "passed the bounded real-yaVDR", + "acceptance before activation", + "Advertisement remains fail-closed at runtime", + "public SuiteBridge SVDRP Help advertisement remains", ): - require(test, needle, "SuiteBridge configuration regression") + require(doc, token, "activation documentation") -require(doc, "advertisement remains closed", "closed advertisement documentation") -require(doc, "Control Plane", "productive command path documentation") -print("Phase 64 SuiteBridge native Timer command-path wiring guard passed") +print("Phase 64 SuiteBridge native Timer command-path activation guard passed") diff --git a/tools/check_phase64_suitebridge_native_timer_create_command_service.py b/tools/check_phase64_suitebridge_native_timer_create_command_service.py old mode 100755 new mode 100644 index e289f99c0..1334d5175 --- a/tools/check_phase64_suitebridge_native_timer_create_command_service.py +++ b/tools/check_phase64_suitebridge_native_timer_create_command_service.py @@ -73,7 +73,8 @@ def forbid(text: str, needle: str, label: str) -> None: ] require(availability, "kBackendAgentNativeTimerCreateCommandType", "CREATE advertisement fence") -require(availability, "continue;", "CREATE advertisement suppression") +require(availability, "discoverProvider", "CREATE provider discovery") +require(availability, "facts.available", "CREATE availability fence") require(agent_main, "SuiteBridgeNativeTimerCreateTransport", "production CREATE adapter construction successor") require(agent_client, "config_.nativeTimerCreateTransport", diff --git a/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py old mode 100755 new mode 100644 index 178089b83..8a6b730f0 --- a/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py +++ b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py @@ -63,10 +63,11 @@ def forbid(text: str, needle: str, label: str) -> None: ] require(available, "kBackendAgentNativeTimerCreateCommandType", "CREATE advertisement fence") -require(available, "continue;", "CREATE advertisement suppression") +require(available, "discoverProvider", "CREATE provider discovery") +require(available, "facts.available", "CREATE availability fence") require(agent_main, "SuiteBridgeNativeTimerCreateTransport", "production CREATE adapter construction successor") require(agent_client, "config_.nativeTimerCreateTransport", "production CREATE transport injection successor") -forbid(packaged, "vdr.timer.create", "packaged CREATE advertisement") +require(packaged, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") print("Phase 64 SuiteBridge native Timer CREATE real-mutation guard passed") diff --git a/tools/check_phase64_suitebridge_native_timer_create_transport.py b/tools/check_phase64_suitebridge_native_timer_create_transport.py old mode 100755 new mode 100644 index 49e5cb064..26066e580 --- a/tools/check_phase64_suitebridge_native_timer_create_transport.py +++ b/tools/check_phase64_suitebridge_native_timer_create_transport.py @@ -278,9 +278,10 @@ def forbid(text: str, needle: str, label: str) -> None: require( availability, - "continue;", - "CREATE advertisement suppression", + "discoverProvider", + "CREATE provider discovery", ) +require(availability, "facts.available", "CREATE availability fence") require( agent_main, @@ -294,16 +295,16 @@ def forbid(text: str, needle: str, label: str) -> None: "production CREATE transport injection successor", ) -forbid( +require( agent_client, - "vdr.timer.create", - "production CREATE Agent configuration", + "kBackendAgentNativeTimerCreateCommandType", + "production CREATE Agent configuration allowlist", ) -forbid( +require( packaged, - "vdr.timer.create", - "packaged CREATE configuration", + "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", + "accepted packaged Timer activation", ) # The private command-service successor may now own the parser/replay layer. diff --git a/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py b/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py old mode 100755 new mode 100644 index a83feae7d..4361ecfa4 --- a/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py +++ b/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py @@ -103,9 +103,9 @@ def forbid(text: str, needle: str, label: str) -> None: for token in ("", "cTimers", "Timers->", '"DELT"', "RESTfulAPI", "restfulapi"): forbid(plugin_source, token, "direct VDR mutation in replay/transport service") -# The original Slice-34 boundary remains useful for the Agent/private transport. -# The explicit successor gate may configure one dedicated VDR callback in the -# plugin while the shipped Agent remains non-advertising and non-injecting. +# The original Slice-34 boundary remains useful for the private transport. +# The accepted activation successor may advertise it only after enabled, +# coherent SuiteBridge provider discovery. require(plugin_main_header, "SuiteBridgeNativeTimerDeleteVdrMutationCallback nativeTimerDeleteVdrMutation_", "dedicated VDR mutation callback owner") require(plugin_main_header, "SuiteBridgeNativeTimerDeleteService nativeTimerDelete_", "plugin service owner") require(plugin_main, "&nativeTimerDeleteVdrMutation_", "explicit production callback wiring") @@ -119,11 +119,13 @@ def forbid(text: str, needle: str, label: str) -> None: require(agent_main, "SuiteBridgeNativeTimerDeleteTransport", "production Timer-delete adapter construction successor") require(agent_client, "config_.nativeTimerDeleteTransport", "production Timer-delete transport injection successor") -forbid(agent_client, "vdr.timer.delete", "production Timer-delete Agent configuration") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete configuration") +require(agent_client, "kBackendAgentNativeTimerDeleteCommandType", + "Timer-delete Agent configuration allowlist") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") available = command_client.split("CommandAvailability availableCommands(", 1)[1].split("\n}\n}\n\nbool reconcileBackendAgentCommandState(", 1)[0] require(available, "kBackendAgentNativeTimerDeleteCommandType", "Timer-delete advertisement fence") -require(available, "continue;", "Timer-delete advertisement suppression") +require(available, "discoverProvider", "Timer-delete provider discovery") +require(available, "facts.available", "Timer-delete availability fence") for needle, label in ( ("native_timer_delete_suitebridge_provider_discovered_enabled", "enabled discovery regression"), diff --git a/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py b/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py old mode 100755 new mode 100644 index 1ddf37776..746541502 --- a/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py +++ b/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py @@ -152,16 +152,18 @@ def forbid(text: str, needle: str, label: str) -> None: ): require(transport_test, needle, label) -# The real callback gate does not silently open the Agent/shipped command path. +# The accepted successor opens only the capability-gated Agent command path. require(agent_main, "SuiteBridgeNativeTimerDeleteTransport", "installed Agent Timer-delete transport construction successor") require(agent_client, "config_.nativeTimerDeleteTransport", "installed Agent Timer-delete transport injection successor") -forbid(agent_client, "vdr.timer.delete", "installed Agent Timer-delete configuration") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete advertisement") +require(agent_client, "kBackendAgentNativeTimerDeleteCommandType", + "installed Agent Timer-delete configuration allowlist") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") available = command_client.split("CommandAvailability availableCommands(", 1)[1].split( "\n}\n}\n\nbool reconcileBackendAgentCommandState(", 1 )[0] require(available, "kBackendAgentNativeTimerDeleteCommandType", "Timer-delete advertisement fence") -require(available, "continue;", "Timer-delete advertisement suppression") +require(available, "discoverProvider", "Timer-delete provider discovery") +require(available, "facts.available", "Timer-delete availability fence") for needle, label in ( ("first Phase-64 slice that contains and wires a real native VDR", "real-mutation scope documentation"), diff --git a/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py b/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py old mode 100755 new mode 100644 index 418ad3074..7ca8b2a57 --- a/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py +++ b/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py @@ -100,7 +100,7 @@ def forbid(text: str, needle: str, label: str) -> None: ) require(agent_main, "SuiteBridgeNativeTimerDeleteTransport", "installed Agent Timer-delete transport wiring successor") require(agent_client, "config_.nativeTimerDeleteTransport", "installed Agent Timer-delete transport injection successor") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete advertisement") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") for needle, label in ( ("callback.calls == 1", "exactly-once callback assertions"), diff --git a/tools/check_phase64_timer_delete_durable_executor_outcome.py b/tools/check_phase64_timer_delete_durable_executor_outcome.py old mode 100755 new mode 100644 index 6ad79e08a..afd9e56e8 --- a/tools/check_phase64_timer_delete_durable_executor_outcome.py +++ b/tools/check_phase64_timer_delete_durable_executor_outcome.py @@ -174,7 +174,8 @@ def forbid(text: str, needle: str, label: str) -> None: ): require(executor_source, needle, "preserved provider fence") -# Still no concrete mutation transport or normal availability/configuration. +# The generic SVDRP transport remains free of Timer-delete coupling; the +# accepted successor uses the dedicated typed transport and gated availability. for text, label in ( (transport_header, "SuiteBridge transport header"), (transport_source, "SuiteBridge transport source"), @@ -196,13 +197,15 @@ def forbid(text: str, needle: str, label: str) -> None: forbid(command_client, token, "concrete Timer-delete CommandClient coupling") forbid(timer_delete_handler, token, "concrete Timer-delete handler coupling") -forbid(agent_client, "vdr.timer.delete", "Timer-delete Agent configuration") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete configuration") +require(agent_client, "kBackendAgentNativeTimerDeleteCommandType", + "Timer-delete Agent configuration allowlist") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") available = command_client.split("CommandAvailability availableCommands(", 1)[1].split( "\n}\n}\n\nbool reconcileBackendAgentCommandState(", 1 )[0] require(available, "kBackendAgentNativeTimerDeleteCommandType", "Timer-delete availability fence") -require(available, "continue;", "Timer-delete advertisement suppression") +require(available, "discoverProvider", "Timer-delete provider discovery") +require(available, "facts.available", "Timer-delete availability fence") # Focused integration regressions lock the no-blind-retry and evidence-ordering # semantics without a real SuiteBridge or VDR mutation transport. diff --git a/tools/check_phase64_timer_delete_fenced_executor.py b/tools/check_phase64_timer_delete_fenced_executor.py old mode 100755 new mode 100644 index 0122fb622..8bed6db5a --- a/tools/check_phase64_timer_delete_fenced_executor.py +++ b/tools/check_phase64_timer_delete_fenced_executor.py @@ -210,8 +210,9 @@ def forbid(text: str, needle: str, label: str) -> None: ): forbid(timer_delete_handler, token, "control-plane authority in Timer-delete handler") -forbid(agent_client, "vdr.timer.delete", "Timer-delete Agent configuration") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete configuration") +require(agent_client, "kBackendAgentNativeTimerDeleteCommandType", + "Timer-delete Agent configuration allowlist") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") require( command_client, "kBackendAgentNativeTimerDeleteCommandType", diff --git a/tools/check_phase64_timer_delete_fresh_durable_starting.py b/tools/check_phase64_timer_delete_fresh_durable_starting.py old mode 100755 new mode 100644 index 1e7bd2d3e..320e3cce3 --- a/tools/check_phase64_timer_delete_fresh_durable_starting.py +++ b/tools/check_phase64_timer_delete_fresh_durable_starting.py @@ -123,7 +123,8 @@ def forbid(text: str, needle: str, label: str) -> None: "Slice 30 successor allowance", ) -# There is still no Timer-delete availability or concrete execution transport. +# The historical slice remains null-transport closed; the accepted successor +# adds dedicated transport discovery without weakening durable starting. available = command_client.split("CommandAvailability availableCommands(", 1)[1].split( "\n}\n}\n\nbool reconcileBackendAgentCommandState(", 1 )[0] @@ -132,9 +133,11 @@ def forbid(text: str, needle: str, label: str) -> None: "kBackendAgentNativeTimerDeleteCommandType", "Timer-delete availability fence", ) -require(available, "continue;", "Timer-delete advertisement suppression") -forbid(agent_client, "vdr.timer.delete", "Timer-delete Agent configuration") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete configuration") +require(available, "discoverProvider", "Timer-delete provider discovery") +require(available, "facts.available", "Timer-delete availability fence") +require(agent_client, "kBackendAgentNativeTimerDeleteCommandType", + "Timer-delete Agent configuration allowlist") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") for token in ( "SuiteBridgeSvdrp", diff --git a/tools/check_phase64_timer_delete_local_state_lifecycle.py b/tools/check_phase64_timer_delete_local_state_lifecycle.py old mode 100755 new mode 100644 index 18c841b01..208d3c044 --- a/tools/check_phase64_timer_delete_local_state_lifecycle.py +++ b/tools/check_phase64_timer_delete_local_state_lifecycle.py @@ -112,8 +112,9 @@ def forbid(text: str, needle: str, label: str) -> None: "kBackendAgentNativeTimerDeleteCommandType", "Timer-delete type fence", ) -forbid(agent_client, "vdr.timer.delete", "Timer-delete Agent configuration") -forbid(packaged_config, "vdr.timer.delete", "packaged Timer-delete configuration") +require(agent_client, "kBackendAgentNativeTimerDeleteCommandType", + "Timer-delete Agent configuration allowlist") +require(packaged_config, "COMMAND_TYPES=vdr.timer.create,vdr.timer.update,vdr.timer.toggle,vdr.timer.delete", "accepted packaged Timer activation") for token in ( "SuiteBridgeSvdrp", diff --git a/tools/run_phase64_managed_timer_fulfillment_acceptance.sh b/tools/run_phase64_managed_timer_fulfillment_acceptance.sh old mode 100755 new mode 100644 index 3b055ce9d..2386034d7 --- a/tools/run_phase64_managed_timer_fulfillment_acceptance.sh +++ b/tools/run_phase64_managed_timer_fulfillment_acceptance.sh @@ -108,16 +108,20 @@ readelf -h vdr-plugin-suite-bridge/libvdr-suitebridge.so \ if ! python3 - \ packaging/systemd/backend-agent.conf \ vdr-plugin-suite-bridge/suitebridge_svdrp.cpp \ - "$EVIDENCE_DIR/closed-advertisement.txt" <<'PY_CLOSED_ADVERTISEMENT' + "$EVIDENCE_DIR/activated-advertisement.txt" <<'PY_ACTIVATED_ADVERTISEMENT' from pathlib import Path import sys config = Path(sys.argv[1]).read_text(encoding="utf-8") svdrp = Path(sys.argv[2]).read_text(encoding="utf-8") output = Path(sys.argv[3]) +expected = ( + "COMMAND_TYPES=vdr.timer.create,vdr.timer.update," + "vdr.timer.toggle,vdr.timer.delete\n" +) -if "COMMAND_TYPES=\n" not in config: - raise SystemExit("packaged command advertisement is not closed") +if config.count("COMMAND_TYPES=") != 1 or expected not in config: + raise SystemExit("packaged Timer command advertisement is not exact") help_start = svdrp.find("SVDRPHelpPages") command_start = svdrp.find("SVDRPCommand", help_start) @@ -129,17 +133,18 @@ for command in ("NTCREATE", "NTMOD", "NTDELETE"): raise SystemExit(f"private write advertised in help: {command}") output.write_text( - "PACKAGED_COMMAND_TYPES=closed\n" + "PACKAGED_COMMAND_TYPES=vdr.timer.create,vdr.timer.update," + "vdr.timer.toggle,vdr.timer.delete\n" "PUBLIC_SVDRP_TIMER_WRITES=closed\n", encoding="utf-8", ) -PY_CLOSED_ADVERTISEMENT +PY_ACTIVATED_ADVERTISEMENT then - fail closed_advertisement_check_failed + fail activated_advertisement_check_failed fi -if [[ ! -s "$EVIDENCE_DIR/closed-advertisement.txt" ]]; then - fail closed_advertisement_evidence_missing +if [[ ! -s "$EVIDENCE_DIR/activated-advertisement.txt" ]]; then + fail activated_advertisement_evidence_missing fi if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then @@ -156,5 +161,5 @@ git diff --check || printf 'PHASE_64_MANAGED_TIMER_FULFILLMENT_ACCEPTANCE=PASS\n' printf 'HEAD=%s\n' "$ACTUAL_HEAD" -printf 'ADVERTISEMENT=closed\n' +printf 'ADVERTISEMENT=timer-commands-activated\n' printf 'EVIDENCE=%s\n' "$EVIDENCE_DIR" From 5a5b60b22914efbc2743bc1e9cdbfb9e8575baa6 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 09:26:13 +0200 Subject: [PATCH 77/85] Fix Timer activation guard boundary --- ...heck_phase64_suitebridge_native_timer_command_path_wiring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py index 826cfae06..4b6fb0d9a 100644 --- a/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py +++ b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py @@ -82,7 +82,7 @@ def forbid(text: str, needle: str, label: str) -> None: require(packaged, ACTIVATED + "\n", "exact packaged Timer activation") availability = command_client[ - command_client.find("CommandAvailability availableCommands"): + command_client.find("bool mergeProviderFacts"): command_client.find("bool reconcileBackendAgentCommandState") ] for needle in ( From dfca24f1d28c2b7a7513f66a358e17d11bda5b5e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 09:40:18 +0200 Subject: [PATCH 78/85] Allow gated Timer availability in Phase 63 guard --- ...phase63_fenced_native_operation_runtime.py | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/tools/check_phase63_fenced_native_operation_runtime.py b/tools/check_phase63_fenced_native_operation_runtime.py index 302ba1f33..d1c16fac0 100644 --- a/tools/check_phase63_fenced_native_operation_runtime.py +++ b/tools/check_phase63_fenced_native_operation_runtime.py @@ -389,12 +389,11 @@ def text(relative: str) -> str: if "mutations=enabled" in scoped_runtime or '"enabled"' in scoped_runtime: errors.append("mutations=enabled is forbidden") -# Phase 64 may classify already-validated Timer command state in the generic -# Agent command owner. These boolean discriminators carry no execution authority. -# Strip only the exact CREATE/DELETE declarations from the old Phase-63 -# mutation-name heuristic, and only while each remains paired with its bounded -# fresh-starting handoff. Every other timer/recording/channel/etc assignment -# remains forbidden by the original scan below. +# Phase 64 may classify already-validated Timer command state and capability +# availability in the generic Agent command owner. These boolean discriminators +# carry no mutation authority. Strip only their exact declarations and bounded +# fail-closed availability state from the old Phase-63 mutation-name heuristic. +# Every other timer/recording/channel/etc assignment remains forbidden below. allowed_timer_discriminators = ( ( "Timer-create", @@ -435,6 +434,65 @@ def text(relative: str) -> str: scoped_runtime_boundary = scoped_runtime +timer_availability_discriminator = ( + "const bool timerType =\n" + " type == " + "vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType ||\n" + " type == " + "vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType ||\n" + " type == " + "vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType ||\n" + " type == " + "vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType;" +) +timer_snapshot_initial = "bool timerSnapshotCoherent = true;" +timer_snapshot_failed = "timerSnapshotCoherent = false;" +timer_availability_start = agent_client.find( + "CommandAvailability availableCommands(" +) +timer_availability_end = agent_client.find( + "\nbool reconcileBackendAgentCommandState(", + timer_availability_start, +) +timer_availability_runtime = ( + agent_client[timer_availability_start:timer_availability_end] + if timer_availability_start >= 0 + and timer_availability_end > timer_availability_start + else "" +) +timer_availability_tokens = ( + "if (!transport->discoverProvider(facts, reason))", + "catch (...)", + "if (supported.empty())", + "if (!mergeProviderFacts(timerProviders, facts))", + "if (timerSnapshotCoherent &&", + "availability.localProviders.clear();", +) + +if ( + agent_client.count(timer_availability_discriminator) != 1 + or timer_availability_runtime.count(timer_snapshot_initial) != 1 + or timer_availability_runtime.count(timer_snapshot_failed) != 5 + or any( + token not in timer_availability_runtime + for token in timer_availability_tokens + ) +): + errors.append( + "Timer availability discriminator must remain a single bounded " + "fail-closed Phase-64 capability handoff" + ) +else: + scoped_runtime_boundary = scoped_runtime_boundary.replace( + timer_availability_discriminator, "", 1 + ) + scoped_runtime_boundary = scoped_runtime_boundary.replace( + timer_snapshot_initial, "", 1 + ) + scoped_runtime_boundary = scoped_runtime_boundary.replace( + timer_snapshot_failed, "", 5 + ) + for label, discriminator, required_handoffs in allowed_timer_discriminators: if discriminator not in agent_client: continue From 7bbe10bcfa186204592db0ee3a972f0d588e4ff9 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 09:43:04 +0200 Subject: [PATCH 79/85] Make Timer delete assignment guard successor-aware --- ..._phase64_native_timer_delete_assignment.py | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/check_phase64_native_timer_delete_assignment.py b/tools/check_phase64_native_timer_delete_assignment.py index 93a51f4b6..8c709bcc9 100644 --- a/tools/check_phase64_native_timer_delete_assignment.py +++ b/tools/check_phase64_native_timer_delete_assignment.py @@ -17,6 +17,9 @@ FRAGMENT = ROOT / "mk/phase64-native-timer-delete-assignment-tests.mk" MAKEFILE = ROOT / "Makefile" AGENT_SOURCES = ROOT / "mk/agent-sources.mk" +ACTIVATION_GUARD = ROOT / ( + "tools/check_phase64_suitebridge_native_timer_command_path_wiring.py" +) failures: list[str] = [] @@ -135,8 +138,44 @@ def require(path: Path, markers: list[str]) -> None: if forbidden in source: failures.append(f"Slice-25 assignment contains forbidden delivery/write coupling: {forbidden}") -if DELIVERY.is_file() and "vdr.timer.delete" in DELIVERY.read_text(encoding="utf-8"): - failures.append("Slice-25 unexpectedly changes Timer-delete delivery runtime") +delivery = DELIVERY.read_text(encoding="utf-8") if DELIVERY.is_file() else "" +if "vdr.timer.delete" in delivery: + # The accepted command-path successor extends delivery only with the exact + # provider-selection fence. Slice-25 assignment persistence itself remains + # absent from every runtime source manifest below. + activation_guard = ( + ACTIVATION_GUARD.read_text(encoding="utf-8") + if ACTIVATION_GUARD.is_file() + else "" + ) + for marker in [ + 'delivery = read("core/agent/src/BackendAgentCommandDelivery.cpp")', + '"native_timer_delete_provider_advertisement_required"', + '"localProviderSelectionCurrent"', + '"kBackendAgentNativeTimerDeleteCommandType"', + ]: + if marker not in activation_guard: + failures.append( + "Timer-delete delivery requires the bounded command-path " + f"successor guard marker: {marker}" + ) + for marker in [ + ( + "c.command_type NOT IN('vdr.native.probe','vdr.timer.create'," + "'vdr.timer.update','vdr.timer.toggle','vdr.timer.delete')" + ), + "JOIN backend_agent_local_provider_ownership", + "JOIN backend_agent_local_provider_facts", + "f.provider_instance_epoch=s.provider_instance_epoch", + "f.provider_generation=s.provider_generation", + "f.capability_revision=s.capability_revision", + "f.available=1", + ]: + if marker not in delivery: + failures.append( + "Timer-delete delivery successor misses provider fence: " + + marker + ) if CLIENT.is_file() and "vdr.timer.delete" in CLIENT.read_text(encoding="utf-8"): failures.append("Slice-25 unexpectedly advertises/executes Timer delete in Agent client") From 25c825707187680432d2fb7cfee9532fbd093b11 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 09:44:53 +0200 Subject: [PATCH 80/85] Fix Timer capability merge compilation --- core/agent/src/BackendAgentCommandClient.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/agent/src/BackendAgentCommandClient.cpp b/core/agent/src/BackendAgentCommandClient.cpp index c9310610e..c13c7a394 100644 --- a/core/agent/src/BackendAgentCommandClient.cpp +++ b/core/agent/src/BackendAgentCommandClient.cpp @@ -298,7 +298,7 @@ bool mergeProviderFacts( existing->available != facts.available) return false; for (const std::string& capability : facts.capabilities) - if (!hasCapability(*existing, capability)) + if (!hasCapability(*existing, capability.c_str())) existing->capabilities.push_back(capability); return true; } From 4c51d8b05505f1e470af68f342d9f03b7baeef32 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 09:48:37 +0200 Subject: [PATCH 81/85] Make Phase 63 delivery guard Timer-successor aware --- .../check_phase63_command_delivery_runtime.py | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/tools/check_phase63_command_delivery_runtime.py b/tools/check_phase63_command_delivery_runtime.py index b0f05ee23..a7fb07fd0 100644 --- a/tools/check_phase63_command_delivery_runtime.py +++ b/tools/check_phase63_command_delivery_runtime.py @@ -51,6 +51,59 @@ if missing: raise SystemExit(f"{path}: missing {missing}") +delivery_path = "core/agent/src/BackendAgentCommandDelivery.cpp" +delivery_boundary = Path(delivery_path).read_text(encoding="utf-8") +timer_delivery_successor = ( + "c.command_type NOT IN('vdr.native.probe','vdr.timer.create'," + "'vdr.timer.update','vdr.timer.toggle','vdr.timer.delete')" +) +if "timer.create" in delivery_boundary: + activation_guard_path = Path( + "tools/check_phase64_suitebridge_native_timer_command_path_wiring.py" + ) + activation_guard = ( + activation_guard_path.read_text(encoding="utf-8") + if activation_guard_path.is_file() + else "" + ) + packaged = Path("packaging/systemd/backend-agent.conf").read_text( + encoding="utf-8" + ) + if ( + delivery_boundary.count(timer_delivery_successor) != 1 + or packaged.count( + "COMMAND_TYPES=vdr.timer.create,vdr.timer.update," + "vdr.timer.toggle,vdr.timer.delete\n" + ) != 1 + ): + raise SystemExit( + f"{delivery_path}: Timer successor activation is not exact" + ) + for marker in [ + '"native_timer_delete_provider_advertisement_required"', + '"localProviderSelectionCurrent"', + '"kBackendAgentNativeTimerDeleteCommandType"', + ]: + if marker not in activation_guard: + raise SystemExit( + f"{delivery_path}: Timer successor guard misses {marker}" + ) + for marker in [ + "JOIN backend_agent_local_provider_ownership", + "JOIN backend_agent_local_provider_facts", + "f.provider_instance_epoch=s.provider_instance_epoch", + "f.provider_generation=s.provider_generation", + "f.capability_revision=s.capability_revision", + "f.available=1", + ]: + if marker not in delivery_boundary: + raise SystemExit( + f"{delivery_path}: Timer successor fence misses {marker}" + ) + delivery_boundary = delivery_boundary.replace( + timer_delivery_successor, "", 1 + ) + forbidden_by_path = { "core/agent/src/BackendAgentCommandDelivery.cpp": [ "DELETE FROM backend_agent_command_capabilities WHERE backend_id='", @@ -63,7 +116,11 @@ ], } for path, markers in forbidden_by_path.items(): - text = Path(path).read_text(encoding="utf-8") + text = ( + delivery_boundary + if path == delivery_path + else Path(path).read_text(encoding="utf-8") + ) for marker in markers: if marker in text: raise SystemExit(f"{path}: forbidden runtime marker {marker}") @@ -76,7 +133,7 @@ raise SystemExit(f"{path}: public Agent route marker") print("Phase-63 command delivery runtime check passed") -print("Command type: probe.noop only") +print("Command type: probe.noop baseline plus gated Phase-64 Timer successor") print("Native VDR mutation: absent") print("command_capability_required") print("equivalent replay preserves the durable receipt") From 45977b9086ebda268f2796e12bc6415b740f49d4 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 09:54:18 +0200 Subject: [PATCH 82/85] Accept activated Timer types in Agent poll parser --- core/agent/src/BackendAgentCommandJson.cpp | 7 +++++++ ...suitebridge_native_timer_command_path_wiring.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/core/agent/src/BackendAgentCommandJson.cpp b/core/agent/src/BackendAgentCommandJson.cpp index 1dac5e459..f4820dd42 100644 --- a/core/agent/src/BackendAgentCommandJson.cpp +++ b/core/agent/src/BackendAgentCommandJson.cpp @@ -1,4 +1,5 @@ #include "BackendAgentCommandJson.h" +#include "BackendAgentNativeTimerCreate.h" #include "BackendAgentNativeTimerDelete.h" #include "BackendAgentNativeTimerModify.h" @@ -253,6 +254,12 @@ bool parseTypes(const Value& value, std::vector& types) if (item.kind != Kind::String || (item.stringValue != "probe.noop" && item.stringValue != "vdr.native.probe" && + item.stringValue != + vdrsuite::agent::kBackendAgentNativeTimerCreateCommandType && + item.stringValue != + vdrsuite::agent::kBackendAgentNativeTimerUpdateCommandType && + item.stringValue != + vdrsuite::agent::kBackendAgentNativeTimerToggleCommandType && item.stringValue != vdrsuite::agent::kBackendAgentNativeTimerDeleteCommandType) || std::find(types.begin(), types.end(), item.stringValue) != types.end()) diff --git a/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py index 4b6fb0d9a..fa29399c2 100644 --- a/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py +++ b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py @@ -22,6 +22,7 @@ def forbid(text: str, needle: str, label: str) -> None: header = read("core/agent/include/BackendAgentClient.h") client = read("core/agent/src/BackendAgentClient.cpp") command_client = read("core/agent/src/BackendAgentCommandClient.cpp") +command_json = read("core/agent/src/BackendAgentCommandJson.cpp") delivery = read("core/agent/src/BackendAgentCommandDelivery.cpp") advertisement = read( "core/agent/include/BackendAgentNativeTimerDeleteAdvertisement.h" @@ -103,6 +104,18 @@ def forbid(text: str, needle: str, label: str) -> None: ): require(availability, token, "activated Timer command type") +poll_types = command_json[ + command_json.find("bool parseTypes"): + command_json.find("bool parseProviderFacts") +] +for token in ( + "kBackendAgentNativeTimerCreateCommandType", + "kBackendAgentNativeTimerUpdateCommandType", + "kBackendAgentNativeTimerToggleCommandType", + "kBackendAgentNativeTimerDeleteCommandType", +): + require(poll_types, token, "activated Timer poll parser type") + for token in ( "backendAgentNativeTimerAdvertisementValid", "native_timer_create_provider_advertisement_required", @@ -124,6 +137,7 @@ def forbid(text: str, needle: str, label: str) -> None: "pie_timer_activation_replaced", "supportedCommandTypes.empty()", "localProviders.size() == 1", + "parseBackendAgentCommandPollRequestJson", ): require(client_test, token, "activation regression") require(config_test, ACTIVATED, "configuration activation regression") From 9c406feb90fdb2faf23c36123b9634d83215979b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 09:58:13 +0200 Subject: [PATCH 83/85] Preserve Phase 63 delivery contract marker --- tools/check_phase63_command_delivery_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/check_phase63_command_delivery_runtime.py b/tools/check_phase63_command_delivery_runtime.py index a7fb07fd0..8a7f5529f 100644 --- a/tools/check_phase63_command_delivery_runtime.py +++ b/tools/check_phase63_command_delivery_runtime.py @@ -133,7 +133,7 @@ raise SystemExit(f"{path}: public Agent route marker") print("Phase-63 command delivery runtime check passed") -print("Command type: probe.noop baseline plus gated Phase-64 Timer successor") +print("Command type: probe.noop only in Phase-63 baseline; gated Phase-64 Timer successor") print("Native VDR mutation: absent") print("command_capability_required") print("equivalent replay preserves the durable receipt") From 4b1ac2c95b42e430e57abea613bc3ac0ce95e1f5 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 10:02:49 +0200 Subject: [PATCH 84/85] Preserve stale Timer provider diagnostics --- core/agent/src/BackendAgentCommandDelivery.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/agent/src/BackendAgentCommandDelivery.cpp b/core/agent/src/BackendAgentCommandDelivery.cpp index 1dea92c15..cfd4eb62a 100644 --- a/core/agent/src/BackendAgentCommandDelivery.cpp +++ b/core/agent/src/BackendAgentCommandDelivery.cpp @@ -498,7 +498,7 @@ BackendAgentCommandPollResult BackendAgentCommandRepository::poll(const BackendA if (ok) { sqlite3_stmt* select=nullptr; - const std::string sql=std::string("SELECT ")+AssignmentColumns+" FROM backend_agent_commands c WHERE c.backend_id=? AND c.agent_id=? AND c.agent_instance_id=? AND c.backend_generation=? AND c.deadline>=? AND ((c.state='assigned' AND NOT EXISTS(SELECT 1 FROM backend_agent_command_receipts r WHERE r.command_id=c.command_id)) OR c.replay_requested=1) AND EXISTS(SELECT 1 FROM backend_agent_command_capabilities x WHERE x.backend_id=c.backend_id AND x.agent_id=c.agent_id AND x.agent_instance_id=c.agent_instance_id AND x.backend_generation=c.backend_generation AND x.command_type=c.command_type) AND (c.command_type NOT IN('vdr.native.probe','vdr.timer.create','vdr.timer.update','vdr.timer.toggle','vdr.timer.delete') OR EXISTS(SELECT 1 FROM backend_agent_command_provider_selections s JOIN backend_agent_local_provider_ownership o ON o.backend_id=s.backend_id AND o.authority_domain=s.authority_domain JOIN backend_agent_local_provider_facts f ON f.backend_id=s.backend_id AND f.provider_id=s.provider_id WHERE s.command_id=c.command_id AND o.active=1 AND o.provider_id=s.provider_id AND o.provider_kind=s.provider_kind AND o.ownership_generation=s.ownership_generation AND f.agent_id=c.agent_id AND f.agent_instance_id=c.agent_instance_id AND f.backend_generation=c.backend_generation AND f.provider_kind=s.provider_kind AND f.provider_instance_epoch=s.provider_instance_epoch AND f.provider_generation=s.provider_generation AND f.capability_revision=s.capability_revision AND f.available=1)) ORDER BY c.assigned_at LIMIT 1;"; + const std::string sql=std::string("SELECT ")+AssignmentColumns+" FROM backend_agent_commands c WHERE c.backend_id=? AND c.agent_id=? AND c.agent_instance_id=? AND c.backend_generation=? AND c.deadline>=? AND ((c.state='assigned' AND NOT EXISTS(SELECT 1 FROM backend_agent_command_receipts r WHERE r.command_id=c.command_id)) OR c.replay_requested=1) AND EXISTS(SELECT 1 FROM backend_agent_command_capabilities x WHERE x.backend_id=c.backend_id AND x.agent_id=c.agent_id AND x.agent_instance_id=c.agent_instance_id AND x.backend_generation=c.backend_generation AND x.command_type=c.command_type) AND (c.command_type!='vdr.native.probe' OR EXISTS(SELECT 1 FROM backend_agent_command_provider_selections s JOIN backend_agent_local_provider_ownership o ON o.backend_id=s.backend_id AND o.authority_domain=s.authority_domain JOIN backend_agent_local_provider_facts f ON f.backend_id=s.backend_id AND f.provider_id=s.provider_id WHERE s.command_id=c.command_id AND o.active=1 AND o.provider_id=s.provider_id AND o.provider_kind=s.provider_kind AND o.ownership_generation=s.ownership_generation AND f.agent_id=c.agent_id AND f.agent_instance_id=c.agent_instance_id AND f.backend_generation=c.backend_generation AND f.provider_kind=s.provider_kind AND f.provider_instance_epoch=s.provider_instance_epoch AND f.provider_generation=s.provider_generation AND f.capability_revision=s.capability_revision AND f.available=1)) ORDER BY c.assigned_at LIMIT 1;"; if (sqlite3_prepare_v2(database_.handle(),sql.c_str(),-1,&select,nullptr)!=SQLITE_OK||!bindText(select,1,request.backendId)||!bindText(select,2,agentId)||!bindText(select,3,request.agentInstanceId)||!bindInt(select,4,static_cast(request.backendGeneration))||!bindInt(select,5,now)) ok=false; else { From b2f48904ed1301d85de5bc45899bc8396795d57a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 17 Aug 2026 10:11:44 +0200 Subject: [PATCH 85/85] Restore executable modes for Phase 64 tools --- tools/check_phase63_fenced_native_operation_runtime.py | 0 tools/check_phase64_command_state_extension.py | 0 tools/check_phase64_managed_timer_fulfillment_acceptance.py | 0 tools/check_phase64_native_timer_create_delivery.py | 0 tools/check_phase64_native_timer_delete_delivery.py | 0 tools/check_phase64_native_timer_modify_command_path.py | 0 .../check_phase64_suitebridge_native_timer_command_path_wiring.py | 0 ...eck_phase64_suitebridge_native_timer_create_command_service.py | 0 ...check_phase64_suitebridge_native_timer_create_real_mutation.py | 0 tools/check_phase64_suitebridge_native_timer_create_transport.py | 0 ..._phase64_suitebridge_native_timer_delete_disabled_transport.py | 0 ...check_phase64_suitebridge_native_timer_delete_real_mutation.py | 0 ...se64_suitebridge_native_timer_delete_replay_ledger_callback.py | 0 tools/check_phase64_timer_delete_durable_executor_outcome.py | 0 tools/check_phase64_timer_delete_fenced_executor.py | 0 tools/check_phase64_timer_delete_fresh_durable_starting.py | 0 tools/check_phase64_timer_delete_local_state_lifecycle.py | 0 tools/run_phase64_managed_timer_fulfillment_acceptance.sh | 0 18 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 tools/check_phase63_fenced_native_operation_runtime.py mode change 100644 => 100755 tools/check_phase64_command_state_extension.py mode change 100644 => 100755 tools/check_phase64_managed_timer_fulfillment_acceptance.py mode change 100644 => 100755 tools/check_phase64_native_timer_create_delivery.py mode change 100644 => 100755 tools/check_phase64_native_timer_delete_delivery.py mode change 100644 => 100755 tools/check_phase64_native_timer_modify_command_path.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_command_path_wiring.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_create_command_service.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_create_real_mutation.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_create_transport.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py mode change 100644 => 100755 tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py mode change 100644 => 100755 tools/check_phase64_timer_delete_durable_executor_outcome.py mode change 100644 => 100755 tools/check_phase64_timer_delete_fenced_executor.py mode change 100644 => 100755 tools/check_phase64_timer_delete_fresh_durable_starting.py mode change 100644 => 100755 tools/check_phase64_timer_delete_local_state_lifecycle.py mode change 100644 => 100755 tools/run_phase64_managed_timer_fulfillment_acceptance.sh diff --git a/tools/check_phase63_fenced_native_operation_runtime.py b/tools/check_phase63_fenced_native_operation_runtime.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_command_state_extension.py b/tools/check_phase64_command_state_extension.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_managed_timer_fulfillment_acceptance.py b/tools/check_phase64_managed_timer_fulfillment_acceptance.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_native_timer_create_delivery.py b/tools/check_phase64_native_timer_create_delivery.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_native_timer_delete_delivery.py b/tools/check_phase64_native_timer_delete_delivery.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_native_timer_modify_command_path.py b/tools/check_phase64_native_timer_modify_command_path.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py b/tools/check_phase64_suitebridge_native_timer_command_path_wiring.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_suitebridge_native_timer_create_command_service.py b/tools/check_phase64_suitebridge_native_timer_create_command_service.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py b/tools/check_phase64_suitebridge_native_timer_create_real_mutation.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_suitebridge_native_timer_create_transport.py b/tools/check_phase64_suitebridge_native_timer_create_transport.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py b/tools/check_phase64_suitebridge_native_timer_delete_disabled_transport.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py b/tools/check_phase64_suitebridge_native_timer_delete_real_mutation.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py b/tools/check_phase64_suitebridge_native_timer_delete_replay_ledger_callback.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_timer_delete_durable_executor_outcome.py b/tools/check_phase64_timer_delete_durable_executor_outcome.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_timer_delete_fenced_executor.py b/tools/check_phase64_timer_delete_fenced_executor.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_timer_delete_fresh_durable_starting.py b/tools/check_phase64_timer_delete_fresh_durable_starting.py old mode 100644 new mode 100755 diff --git a/tools/check_phase64_timer_delete_local_state_lifecycle.py b/tools/check_phase64_timer_delete_local_state_lifecycle.py old mode 100644 new mode 100755 diff --git a/tools/run_phase64_managed_timer_fulfillment_acceptance.sh b/tools/run_phase64_managed_timer_fulfillment_acceptance.sh old mode 100644 new mode 100755