diff --git a/.gitignore b/.gitignore
index 0c83e6a66..bad3ec60f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -280,3 +280,4 @@ opencode.json
/.claude
/Web/Resgrid.Web/wwwroot/js/ng
/tmp
+/artifacts
diff --git a/Core/Resgrid.Config/NerisConfig.cs b/Core/Resgrid.Config/NerisConfig.cs
index 7338978f3..568900b79 100644
--- a/Core/Resgrid.Config/NerisConfig.cs
+++ b/Core/Resgrid.Config/NerisConfig.cs
@@ -13,7 +13,7 @@ public static class NerisConfig
/// Production API root of the pinned contract.
public static string BaseUrl = "https://api.neris.fsri.org/v1";
- /// Sandbox root used by profiles whose Environment is sandbox; empty means "same as BaseUrl".
+ /// Sandbox root used by sandbox profiles. Empty disables outbound requests; production is never a fallback.
public static string SandboxBaseUrl = "";
/// Contract version the provider was generated against (Providers/Resgrid.Providers.Neris/Contract).
diff --git a/Core/Resgrid.Framework/RecordNarrativeFormatter.cs b/Core/Resgrid.Framework/RecordNarrativeFormatter.cs
new file mode 100644
index 000000000..9a33fa83f
--- /dev/null
+++ b/Core/Resgrid.Framework/RecordNarrativeFormatter.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Text;
+using System.Text.RegularExpressions;
+using HtmlAgilityPack;
+
+namespace Resgrid.Framework
+{
+ /// Records permit basic narrative formatting, without links, media, attributes or active content.
+ public static class RecordNarrativeFormatter
+ {
+ private static readonly HashSet Allowed = new(StringComparer.OrdinalIgnoreCase) { "p", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "blockquote", "h1", "h2", "h3" };
+ private static readonly HashSet Drop = new(StringComparer.OrdinalIgnoreCase) { "script", "style", "iframe", "object", "embed", "svg", "math", "template", "noscript" };
+ private static bool IsHtml(string value) => Regex.IsMatch(value ?? "", @"<\s*/?[a-zA-Z][^>]*>", RegexOptions.None, TimeSpan.FromMilliseconds(250));
+ public static string ForStorage(string value) => IsHtml(value) ? Render(value) : value;
+ public static string Render(string value)
+ {
+ if (!IsHtml(value)) return WebUtility.HtmlEncode(value ?? "");
+ var document = new HtmlDocument(); document.LoadHtml(value); var html = new StringBuilder();
+ void Append(HtmlNode node, int depth)
+ {
+ if (depth > 64) throw new ArgumentException("Narrative formatting is nested too deeply.");
+ if (node.NodeType == HtmlNodeType.Text) { html.Append(WebUtility.HtmlEncode(HtmlEntity.DeEntitize(node.InnerText))); return; }
+ if (node.NodeType == HtmlNodeType.Comment || Drop.Contains(node.Name)) return;
+ var allowed = Allowed.Contains(node.Name);
+ if (allowed) html.Append('<').Append(node.Name).Append('>');
+ foreach (var child in node.ChildNodes) Append(child, depth + 1);
+ if (allowed && node.Name != "br") html.Append("").Append(node.Name).Append('>');
+ }
+ Append(document.DocumentNode, 0); return html.ToString();
+ }
+ public static bool HasText(string value)
+ {
+ var document = new HtmlDocument(); document.LoadHtml(Render(value));
+ return !string.IsNullOrWhiteSpace(HtmlEntity.DeEntitize(document.DocumentNode.InnerText).Replace('\u200b', ' '));
+ }
+ }
+}
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
index f9a6e128d..71b7f6084 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
أوقات الإرسال
إنشاء النداء
الرد على النداء
- أول وصول
+ وصول المكالمة إلى مركز الإرسال
انتهاء الحادث
مركز الإرسال
رمز المحدد
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
index 386e6977c..ea152aab6 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Dispositionszeiten
Einsatz erstellt
Anruf angenommen
- Erstes Eintreffen
+ Eingang des Anrufs in der Leitstelle
Einsatz beendet
Leitstelle
Determinantencode
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
index a1b591dbf..413b7c24a 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Χρόνοι αποστολής
Δημιουργία κλήσης
Απάντηση κλήσης
- Πρώτη άφιξη
+ Λήψη κλήσης στο κέντρο επιχειρήσεων
Λήξη συμβάντος
Κέντρο αποστολής
Κωδικός καθοριστή
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
index f6fa6c34b..e7783e3f6 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Dispatch times
Call created
Call answered
- First arrival
+ Call received at dispatch
Incident cleared
Dispatch center
Determinant code
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
index 79abf0446..bb7f7eb2a 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Tiempos de despacho
Llamada creada
Llamada contestada
- Primera llegada
+ Recepción de la llamada en la central
Incidente despejado
Centro de despacho
Código determinante
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
index 43045aada..75a0da89f 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Horaires de régulation
Appel créé
Appel décroché
- Première arrivée
+ Réception de l'appel au centre de répartition
Incident terminé
Centre de régulation
Code déterminant
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
index b805460c8..f9ed853fc 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Tempi di dispatch
Chiamata creata
Chiamata risposta
- Primo arrivo
+ Ricezione della chiamata in centrale
Incidente concluso
Centrale operativa
Codice determinante
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
index 5d6ca794f..d97eddaf3 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Czasy dysponowania
Utworzenie wezwania
Odebranie zgłoszenia
- Pierwsze przybycie
+ Odebranie zgłoszenia w dyspozytorni
Zakończenie zdarzenia
Centrum dyspozytorskie
Kod determinanta
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
index a24a9126a..c93439bcc 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Utlarmningstider
Larm skapat
Samtal besvarat
- Första ankomst
+ Samtal inkom till larmcentral
Insats avslutad
Larmcentral
Determinantkod
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
index 49a0186ad..de605e40b 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx
2.0
@@ -258,7 +258,7 @@
Часи диспетчеризації
Виклик створено
Виклик прийнято
- Перше прибуття
+ Надходження виклику до диспетчерської
Інцидент завершено
Диспетчерський центр
Код детермінанти
diff --git a/Core/Resgrid.Model/Providers/INerisProviders.cs b/Core/Resgrid.Model/Providers/INerisProviders.cs
index baf1422ed..d48f9b45c 100644
--- a/Core/Resgrid.Model/Providers/INerisProviders.cs
+++ b/Core/Resgrid.Model/Providers/INerisProviders.cs
@@ -7,6 +7,8 @@ namespace Resgrid.Model.Providers
/// Department NERIS profile, credential, value sets and crosswalks (RMS plan section 5.5).
public interface INerisProfileService
{
+ /// Credential-free identity of the configured entity, environment and effective endpoint.
+ string GetDestinationIdentity(RmsNerisProfile profile);
Task GetProfileAsync(int departmentId);
/// Saves the profile; a non-null credential replaces the stored one (encrypted per department), null keeps it.
diff --git a/Core/Resgrid.Model/Providers/IPdfProvider.cs b/Core/Resgrid.Model/Providers/IPdfProvider.cs
index 55babb9db..555deecd7 100644
--- a/Core/Resgrid.Model/Providers/IPdfProvider.cs
+++ b/Core/Resgrid.Model/Providers/IPdfProvider.cs
@@ -3,5 +3,7 @@
public interface IPdfProvider
{
byte[] ConvertHtmlToPdf(string html);
+ /// Departmental Records require an explicit page size; older providers retain their existing default.
+ byte[] ConvertHtmlToPdf(string html, string pageSize) => ConvertHtmlToPdf(html);
}
-}
\ No newline at end of file
+}
diff --git a/Core/Resgrid.Model/Records/IncidentReportContracts.cs b/Core/Resgrid.Model/Records/IncidentReportContracts.cs
index f8aa8c33a..e00efd838 100644
--- a/Core/Resgrid.Model/Records/IncidentReportContracts.cs
+++ b/Core/Resgrid.Model/Records/IncidentReportContracts.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
namespace Resgrid.Model
@@ -6,6 +6,9 @@ namespace Resgrid.Model
/// The incident report aggregate as the Web and the mapper read it: header plus the working-draft child rows (or a revision's copies).
public class IncidentReportAggregate
{
+ public RecordUdfSection CustomFields { get; set; }
+ public List Attachments { get; set; } = new List();
+ public List Evidence { get; set; } = new List();
public RmsIncidentReport Report { get; set; }
public RmsLocation Location { get; set; }
public List Types { get; set; } = new List();
@@ -101,6 +104,7 @@ public class IncidentLocationInput
///
public class IncidentReportDraftInput
{
+ public RecordUdfInput CustomFields { get; set; }
public string IncidentNumber { get; set; }
public DateTime? CallCreatedOn { get; set; }
public DateTime? CallAnsweredOn { get; set; }
@@ -161,6 +165,7 @@ public class IncidentResourceInput
///
public class IncidentCasualtyRescueInput
{
+ public string CasualtyId { get; set; }
public RmsCasualtyRescueKind Kind { get; set; }
public string PersonType { get; set; }
public string PersonnelUserId { get; set; }
@@ -264,6 +269,7 @@ public class IncidentPropertyInput
/// VIN and plate are restricted; the service drops them from a caller without RecordRestricted_View.
public class IncidentVehicleInput
{
+ public string VehicleId { get; set; }
public string VehicleKind { get; set; }
public string Make { get; set; }
public string Model { get; set; }
diff --git a/Core/Resgrid.Model/Records/NerisContracts.cs b/Core/Resgrid.Model/Records/NerisContracts.cs
index 3fa46b8b5..3957a9d6d 100644
--- a/Core/Resgrid.Model/Records/NerisContracts.cs
+++ b/Core/Resgrid.Model/Records/NerisContracts.cs
@@ -10,6 +10,10 @@ namespace Resgrid.Model
///
public class NerisIncidentSnapshot
{
+ public RecordUdfSection CustomFields { get; set; }
+ /// Departmental revision content. The national mapper never emits these collections.
+ public List Attachments { get; set; } = new List();
+ public List Evidence { get; set; } = new List();
public RmsIncidentReport Report { get; set; }
public RmsLocation Location { get; set; }
public List Types { get; set; } = new List();
@@ -117,6 +121,9 @@ public enum NerisOutcomeKind
/// One exchange with the destination, reduced to what the submission row and the workflow triggers may see.
public class NerisSubmissionOutcome
{
+ /// The queued payload was refused locally before credentials or HTTP; no destination response is implied.
+ public bool LocalValidationFailure { get; set; }
+ public bool DeliveryUncertain { get; set; }
public NerisOutcomeKind Kind { get; set; }
public int? StatusCode { get; set; }
public string ExternalId { get; set; }
diff --git a/Core/Resgrid.Model/Records/RecordIdempotencyException.cs b/Core/Resgrid.Model/Records/RecordIdempotencyException.cs
new file mode 100644
index 000000000..6f6005221
--- /dev/null
+++ b/Core/Resgrid.Model/Records/RecordIdempotencyException.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace Resgrid.Model
+{
+ public sealed class RecordIdempotencyException : InvalidOperationException
+ {
+ public RecordIdempotencyException(string message) : base(message) { }
+ }
+}
diff --git a/Core/Resgrid.Model/Records/RecordUdfContracts.cs b/Core/Resgrid.Model/Records/RecordUdfContracts.cs
new file mode 100644
index 000000000..b1e4b07e0
--- /dev/null
+++ b/Core/Resgrid.Model/Records/RecordUdfContracts.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ /// Values only: labels, types, visibility and classification come from the pinned published definition.
+ public class RecordUdfInput
+ {
+ public string DefinitionId { get; set; }
+ public Dictionary Values { get; set; } = new Dictionary();
+ }
+ public class RecordUdfSection
+ {
+ public string DefinitionId { get; set; }
+ public string RecordDefinitionKey { get; set; }
+ public int RecordDefinitionVersion { get; set; }
+ public int ExtensionVersion { get; set; }
+ public List Fields { get; set; } = new List();
+ }
+ public class RecordUdfField
+ {
+ public UdfField Field { get; set; }
+ public string Value { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Records/RecordsApiContracts.cs b/Core/Resgrid.Model/Records/RecordsApiContracts.cs
index f3931795c..b785949cb 100644
--- a/Core/Resgrid.Model/Records/RecordsApiContracts.cs
+++ b/Core/Resgrid.Model/Records/RecordsApiContracts.cs
@@ -252,13 +252,17 @@ public interface IRecordAttachmentUploadService
Task GetAsync(int departmentId, string userId, string uploadId);
Task AppendAsync(int departmentId, string userId, string uploadId, long offset, byte[] data);
/// Verifies size and SHA-256, then stores the attachment through the same hygiene/scanner path as a direct upload.
- Task CompleteAsync(int departmentId, string userId, string uploadId, string description, CancellationToken cancellationToken = default);
+ Task CompleteAsync(int departmentId, string userId, string uploadId, string description, CancellationToken cancellationToken = default, int classification = 1);
Task AbortAsync(int departmentId, string userId, string uploadId);
}
/// Scoped idempotency for v4 Records commands: the same (department, user, key) replays the first outcome instead of re-running the transition.
public interface IRecordsApiIdempotencyService
{
+ /// Commit an exclusive reservation before mutation. A false result must never execute the command.
+ Task TryReserveCommandAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId, string requestChecksum);
+ Task TryGetCommandAsync(int departmentId, string userId, string idempotencyKey, string command);
+ Task RememberCommandAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId, string requestChecksum);
///
/// The record a previous call under this key produced, or null. scopes the key to
/// one operation: a client that reuses a key across two different commands on the same record must not have
@@ -268,4 +272,12 @@ public interface IRecordsApiIdempotencyService
Task RememberAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId);
}
+
+ public class RecordCommandReceipt
+ {
+ public string RecordId { get; set; }
+ public string RequestChecksum { get; set; }
+ /// The command may be running or may have committed without a receipt. Do not automatically repeat it.
+ public bool IsPending { get; set; }
+ }
}
diff --git a/Core/Resgrid.Model/Records/RecordsContracts.cs b/Core/Resgrid.Model/Records/RecordsContracts.cs
index 74640780e..9fee8587d 100644
--- a/Core/Resgrid.Model/Records/RecordsContracts.cs
+++ b/Core/Resgrid.Model/Records/RecordsContracts.cs
@@ -145,9 +145,19 @@ public class RecordUnitResponseInput
public DateTime? InQuarters { get; set; }
}
+ /// Officer-authored historical Call created and linked from an existing Run draft.
+ public class RecordNewCallInput
+ {
+ public string Name { get; set; }
+ public string Address { get; set; }
+ public string Nature { get; set; }
+ public DateTime OccurredOnUtc { get; set; }
+ }
+
/// Draft create/save input for a locked Logs-parity definition.
public class RecordDraftInput
{
+ public RecordUdfInput CustomFields { get; set; }
/// One of ; required on create.
public string DefinitionKey { get; set; }
public int? CallId { get; set; }
@@ -170,6 +180,7 @@ public class RecordDraftInput
/// A hydrated Record: header, working/revision details, participants, units, attachment metadata.
public class RecordAggregate
{
+ public RecordUdfSection CustomFields { get; set; }
public RmsOperationalRecord Record { get; set; }
public RmsOperationalRecordDetail Details { get; set; }
public List Participants { get; set; } = new List();
@@ -185,7 +196,9 @@ public class RecordAggregate
///
public class RecordSnapshot
{
+ public RecordUdfSection CustomFields { get; set; }
public int SnapshotVersion { get; set; } = 1;
+ public List Evidence { get; set; } = new List();
public string RecordId { get; set; }
public int DepartmentId { get; set; }
public string DefinitionKey { get; set; }
@@ -210,6 +223,7 @@ public class RecordFieldDiff
{
public string Section { get; set; }
public string FieldKey { get; set; }
+ public string FieldLabel { get; set; }
public string OldValue { get; set; }
public string NewValue { get; set; }
/// True when the field is restricted and the viewer lacks RecordRestricted_View: values are withheld.
diff --git a/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs b/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs
index 16518b4a5..f0f2778ec 100644
--- a/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs
+++ b/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using ProtoBuf;
namespace Resgrid.Model
@@ -107,6 +108,47 @@ public RecordsRetentionPolicy()
[ProtoMember(4)]
public DateTime? LastChangedOn { get; set; }
+ [ProtoMember(5)]
+ public List History { get; set; } = new List();
+
+ /// The policy in force when this revision became official. Unknown pre-history is retained permanently.
+ public int ResolveYears(string definitionKey, DateTime revisionOn)
+ {
+ var applicable = this;
+ if (LastChangedOn.HasValue && revisionOn < LastChangedOn.Value)
+ {
+ applicable = (History ?? new List()).Where(v => v.EffectiveOn <= revisionOn)
+ .OrderByDescending(v => v.EffectiveOn).Select(v => v.Policy).FirstOrDefault();
+ if (applicable == null) return Permanent;
+ }
+ var rule = applicable.Overrides?.Where(o => o.DefinitionKey == definitionKey && o.AppliesFrom <= revisionOn)
+ .OrderByDescending(o => o.AppliesFrom).FirstOrDefault();
+ if (rule != null) return Math.Max(Permanent, rule.RetentionYears);
+ return RmsDefinitionKeys.RestrictedClass.Contains(definitionKey ?? string.Empty) ? Permanent
+ : Math.Max(Permanent, applicable.DepartmentDefaultYears ?? StandardClassDefaultYears);
+ }
+
+ /// Called while holding the department write lock; caller-supplied history is never accepted.
+ public void PreserveHistory(RecordsRetentionPolicy previous, DateTime now)
+ {
+ previous ??= new RecordsRetentionPolicy();
+ History = new List(previous.History ?? new List());
+ History.Add(new RecordsRetentionPolicyVersion
+ {
+ EffectiveOn = previous.LastChangedOn ?? DateTime.MinValue,
+ Policy = new RecordsRetentionPolicy { DepartmentDefaultYears = previous.DepartmentDefaultYears,
+ Overrides = (previous.Overrides ?? new List()).Select(o => new RecordsRetentionOverride
+ { DefinitionKey = o.DefinitionKey, RetentionYears = o.RetentionYears, AppliesFrom = o.AppliesFrom }).ToList(),
+ LastChangedByUserId = previous.LastChangedByUserId }
+ });
+ LastChangedOn = now;
+ foreach (var rule in Overrides ?? new List())
+ {
+ var old = previous.Overrides?.FirstOrDefault(o => o.DefinitionKey == rule.DefinitionKey && o.RetentionYears == rule.RetentionYears);
+ rule.AppliesFrom = old?.AppliesFrom ?? now;
+ }
+ }
+
///
/// Retention years for a definition under this policy (legal hold is evaluated by the caller
/// first). Restricted-class definitions never inherit the department default; they need an
@@ -133,6 +175,13 @@ public int ResolveYears(string definitionKey)
}
}
+ [ProtoContract]
+ public class RecordsRetentionPolicyVersion
+ {
+ [ProtoMember(1)] public DateTime EffectiveOn { get; set; }
+ [ProtoMember(2)] public RecordsRetentionPolicy Policy { get; set; }
+ }
+
/// Department setting 77 (RecordsDisclosureConfig). RMS-3 consumes it; the shape ships now so the value is claimed.
[ProtoContract]
public class RecordsDisclosureConfig
diff --git a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs
index d81fbe1d2..74304fc1f 100644
--- a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs
+++ b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs
@@ -88,6 +88,7 @@ public class RecordsSearchIndexSweepResult
public int DepartmentsRebuilt { get; set; }
public int DocumentsIndexed { get; set; }
public int DocumentsDeleted { get; set; }
+ public int SearchErasuresCompleted { get; set; }
public int Errors { get; set; }
public bool Skipped { get; set; }
public string Message { get; set; }
diff --git a/Core/Resgrid.Model/Records/RmsDisclosure.cs b/Core/Resgrid.Model/Records/RmsDisclosure.cs
index 81692c1b0..5a580f3e8 100644
--- a/Core/Resgrid.Model/Records/RmsDisclosure.cs
+++ b/Core/Resgrid.Model/Records/RmsDisclosure.cs
@@ -180,6 +180,8 @@ public class RmsDisclosureProduction : IEntity
public DateTime PreparedOn { get; set; }
public string ReleasedByUserId { get; set; }
+ public string DeliveryMethod { get; set; }
+ public string DeliveryReference { get; set; }
public DateTime? ReleasedOn { get; set; }
@@ -207,13 +209,14 @@ public class RmsDisclosureProduction : IEntity
/// One record in a disclosure scope preview, before anything is produced.
public class RmsDisclosureScopeItem
{
+ public RmsRecordKind RecordKind { get; set; } = RmsRecordKind.Operational;
public string RecordId { get; set; }
public string RecordNumber { get; set; }
public string DefinitionKey { get; set; }
public string Summary { get; set; }
public DateTime? OccurredOn { get; set; }
public string CurrentRevisionId { get; set; }
- /// False when the record is not finalized; a draft is not a public record and is never produced.
+ /// False when no saved revision is available for automatic production.
public bool Producible { get; set; }
public string NotProducibleReason { get; set; }
}
@@ -231,10 +234,79 @@ public class RmsDisclosureScopePreview
/// One withheld field in a production's redaction log.
public class RmsRedactionEntry
{
+ public string Authority { get; set; }
public string RecordId { get; set; }
public string Section { get; set; }
public string Field { get; set; }
/// Why it was withheld — the classification or profile rule relied on.
public string Basis { get; set; }
}
+
+ public class RmsDisclosureFieldValue
+ {
+ public string Path { get; set; }
+ public string Value { get; set; }
+ }
+ public class RmsDisclosureFieldDecision
+ {
+ public string Path { get; set; }
+ public bool Withhold { get; set; }
+ public string Authority { get; set; }
+ public string Basis { get; set; }
+ }
+
+ public class RmsDisclosureDownload
+ {
+ public byte[] Data { get; set; }
+ public string ContentType { get; set; }
+ public string FileName { get; set; }
+ }
+ public class RmsDisclosureReview
+ {
+ public string RequestId { get; set; }
+ public string Profile { get; set; }
+ public string ScopeChecksum { get; set; }
+ public bool Reviewed { get; set; }
+ public string Authority { get; set; }
+ public string Basis { get; set; }
+ /// Recorded handling of unfinished or inaccessible scope items; never silently omitted.
+ public string UnresolvedScopeHandling { get; set; }
+ public List Records { get; set; } = new List();
+ }
+ public class RmsDisclosureRecordReview
+ {
+ public RmsRecordKind RecordKind { get; set; }
+ public string RecordId { get; set; }
+ public string RecordNumber { get; set; }
+ public string RevisionId { get; set; }
+ public string RevisionChecksum { get; set; }
+ public string ContentChecksum { get; set; }
+ public bool WithholdWhole { get; set; }
+ public string Authority { get; set; }
+ public string Basis { get; set; }
+ public List AutomaticWithholds { get; set; } = new List();
+ public List Fields { get; set; } = new List();
+ public List Decisions { get; set; } = new List();
+ public List Attachments { get; set; } = new List();
+ }
+ public class RmsDisclosureAttachmentDecision
+ {
+ public RmsDisclosureAttachmentDerivative Derivative { get; set; }
+ public List Metadata { get; set; } = new List();
+ public string AttachmentId { get; set; }
+ public string FileName { get; set; }
+ public string Checksum { get; set; }
+ public bool Include { get; set; }
+ public bool Reviewed { get; set; }
+ public string Authority { get; set; }
+ public string Basis { get; set; }
+ }
+ /// A custodian-reviewed replacement file; its source remains unchanged.
+ public class RmsDisclosureAttachmentDerivative
+ {
+ public string FileName { get; set; }
+ public string ContentType { get; set; }
+ public byte[] Data { get; set; }
+ public string Checksum { get; set; }
+ }
}
diff --git a/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs b/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs
index 598358f89..5a616fef8 100644
--- a/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs
+++ b/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs
@@ -192,6 +192,8 @@ public class RecordsRetentionSweepResult
public int DepartmentsEvaluated { get; set; }
public int RecordsEvaluated { get; set; }
public int RecordsPurged { get; set; }
+ /// SQL purges from this sweep still awaiting committed index erasure; not a total backlog count.
+ public int SearchErasuresPending { get; set; }
public int AttachmentsPurged { get; set; }
public int HeldByLegalHold { get; set; }
public int AttachmentsRescanned { get; set; }
diff --git a/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs b/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs
index 074748fba..9f981f4be 100644
--- a/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs
+++ b/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs
@@ -103,6 +103,8 @@ public class RmsEvidenceArtifact : IEntity
/// Version or schema of the source at capture time, where the source carries one.
public string SourceVersion { get; set; }
+ /// Identity of the original capture request for safe API replay.
+ public string CaptureRequestChecksum { get; set; }
/// Start of the period the artifact covers (a tracking window, a readiness period).
public DateTime? CoverageStart { get; set; }
@@ -172,6 +174,8 @@ public class RmsEvidenceArtifact : IEntity
/// What a caller asks an evidence adapter to capture. The adapter decides what it can honour.
public class RecordEvidenceCaptureRequest
{
+ /// Optional caller version; the service pins the observed parent version when omitted.
+ public long? ExpectedRowVersion { get; set; }
public int DepartmentId { get; set; }
public string RecordId { get; set; }
diff --git a/Core/Resgrid.Model/Records/RmsIncidentReport.cs b/Core/Resgrid.Model/Records/RmsIncidentReport.cs
index 91d3b6ac2..537ad91bc 100644
--- a/Core/Resgrid.Model/Records/RmsIncidentReport.cs
+++ b/Core/Resgrid.Model/Records/RmsIncidentReport.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using ProtoBuf;
@@ -37,6 +37,7 @@ public class RmsIncidentReport : IEntity
[ProtoMember(7)]
public int DefinitionVersion { get; set; }
+ public string UdfDefinitionId { get; set; }
/// Pinned NERIS contract version (value sets and payload shape) this report was authored against.
[ProtoMember(8)]
@@ -162,7 +163,7 @@ public class RmsIncidentReport : IEntity
[ProtoMember(54)]
public DateTime? CallAnsweredOn { get; set; }
- /// First unit on scene (NERIS call_arrival).
+ /// Call arrival at the PSAP/dispatch center (NERIS call_arrival), before answer/create; never unit on-scene.
[ProtoMember(55)]
public DateTime? CallArrivalOn { get; set; }
diff --git a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs
index 04ae647f7..da4a69ab9 100644
--- a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs
+++ b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs
@@ -28,6 +28,7 @@ public class RmsOperationalRecord : IEntity
public string DefinitionKey { get; set; }
public int DefinitionVersion { get; set; }
+ public string UdfDefinitionId { get; set; }
/// for locked definitions; null for department-owned ones.
public int? RecordType { get; set; }
@@ -111,6 +112,9 @@ public class RmsOperationalRecord : IEntity
/// Scoped idempotency key for create; unique per department when present.
public string IdempotencyKey { get; set; }
+ /// Original create command fingerprint; never changed by subsequent draft edits.
+ public string OriginalRequestChecksum { get; set; }
+
/// .
public int OriginClient { get; set; }
diff --git a/Core/Resgrid.Model/Records/RmsRecordAttachment.cs b/Core/Resgrid.Model/Records/RmsRecordAttachment.cs
index 04093c01b..cac5496d5 100644
--- a/Core/Resgrid.Model/Records/RmsRecordAttachment.cs
+++ b/Core/Resgrid.Model/Records/RmsRecordAttachment.cs
@@ -48,6 +48,11 @@ public class RmsRecordAttachment : IEntity
/// True when location/device metadata was stripped on upload (plan section 4.7, media hygiene).
public bool MetadataStripped { get; set; }
+ /// RmsEvidenceClassification. Null means legacy/unclassified and requires restricted access.
+ public int? Classification { get; set; }
+ [NotMapped, JsonIgnore]
+ public bool RequiresRestrictedAccess => IsProtected || Classification != (int)RmsEvidenceClassification.Unrestricted;
+
public bool IsProtected { get; set; }
public int ProtectedCatalogVersion { get; set; }
@@ -78,6 +83,6 @@ public object IdValue
public int IdType => 1;
[NotMapped]
- public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "RequiresRestrictedAccess" };
}
}
diff --git a/Core/Resgrid.Model/Records/RmsSubmission.cs b/Core/Resgrid.Model/Records/RmsSubmission.cs
index dbdc62eeb..c4a4f3b61 100644
--- a/Core/Resgrid.Model/Records/RmsSubmission.cs
+++ b/Core/Resgrid.Model/Records/RmsSubmission.cs
@@ -3,6 +3,14 @@
namespace Resgrid.Model
{
+ public class RmsSubmissionReconciliationInput
+ {
+ public long RowVersion { get; set; }
+ public string ExternalId { get; set; }
+ public string Reason { get; set; }
+ public bool ConfirmedNotCreated { get; set; }
+ public string VerificationReference { get; set; }
+ }
public enum RmsSubmissionState
{
Queued = 0,
@@ -46,6 +54,11 @@ public class RmsSubmission : IEntity
public string RevisionId { get; set; }
public string Destination { get; set; }
public string DestinationVersion { get; set; }
+ public string DestinationIdentity { get; set; }
+ /// A create may have reached the destination without a usable receipt; automatic create retries are blocked.
+ public bool RequiresReconciliation { get; set; }
+ /// A create was dispatched and has not received a definitive, applied outcome.
+ public bool CreatePendingReceipt { get; set; }
/// Scoped idempotency key: one per (record, revision); reused on every retry.
public string IdempotencyKey { get; set; }
/// .
diff --git a/Core/Resgrid.Model/Records/RmsSubmissionExchange.cs b/Core/Resgrid.Model/Records/RmsSubmissionExchange.cs
new file mode 100644
index 000000000..97e9c8af4
--- /dev/null
+++ b/Core/Resgrid.Model/Records/RmsSubmissionExchange.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ /// Append-only delivery journal. Started, Response, and Applied entries share ExchangeId; no response is overwritten.
+ public class RmsSubmissionExchange : IEntity
+ {
+ public string RmsSubmissionExchangeId { get; set; }
+ public int DepartmentId { get; set; }
+ public string SubmissionId { get; set; }
+ public string RecordId { get; set; }
+ public string RevisionId { get; set; }
+ public string ExchangeId { get; set; }
+ public string Stage { get; set; }
+ public string Operation { get; set; }
+ public string DestinationIdentity { get; set; }
+ public string PayloadChecksum { get; set; }
+ public string OutcomeJson { get; set; }
+ public string OutcomeChecksum { get; set; }
+ public int AttemptNumber { get; set; }
+ public DateTime OccurredOn { get; set; }
+ public object IdValue { get => RmsSubmissionExchangeId; set => RmsSubmissionExchangeId = (string)value; }
+ public string TableName => "RmsSubmissionExchanges";
+ public string IdName => "RmsSubmissionExchangeId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IDepartmentSettingsRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentSettingsRepository.cs
index 2c7e1ba0e..9da39a7ec 100644
--- a/Core/Resgrid.Model/Repositories/IDepartmentSettingsRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IDepartmentSettingsRepository.cs
@@ -10,6 +10,7 @@ namespace Resgrid.Model.Repositories
///
public interface IDepartmentSettingsRepository: IRepository
{
+ Task SaveRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, System.Threading.CancellationToken cancellationToken = default);
///
/// Gets the department setting by user identifier type asynchronous.
///
diff --git a/Core/Resgrid.Model/Repositories/IRmsCommandReceiptsRepository.cs b/Core/Resgrid.Model/Repositories/IRmsCommandReceiptsRepository.cs
new file mode 100644
index 000000000..dee6d77ab
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRmsCommandReceiptsRepository.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Model.Repositories
+{
+ /// Durable, metadata-only command reservations. Pending commands never expire into automatic retries.
+ public interface IRmsCommandReceiptsRepository
+ {
+ Task GetAsync(int departmentId, string keyHash);
+ Task ReserveAsync(int departmentId, string keyHash, string recordId, string requestChecksum, string reservationId);
+ Task CompleteAsync(int departmentId, string keyHash, string recordId, string requestChecksum, string reservationId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs
index d63c677ae..13522ea6a 100644
--- a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -40,7 +40,7 @@ public interface IRmsIncidentReportsRepository : IRepository
Task GetMaxRecordNumberSequenceAsync(int departmentId, string numberPrefix);
Task TryBumpRowVersionAsync(int departmentId, string reportId, long expectedRowVersion, CancellationToken cancellationToken = default);
/// Retention candidates (RMS-3, worker 43): live, closed reports finalized before the cutoff, oldest first.
- Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take);
+ Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take, string afterId = null);
}
/// Shared contract of every per-report child row set: a working draft (RevisionId null) and immutable revision copies.
@@ -74,12 +74,14 @@ public interface IRmsIncidentVehiclesRepository : IRmsIncidentChildRepository
{
+ Task TryBumpRowVersionAsync(int departmentId, string analysisId, long expectedRowVersion, CancellationToken cancellationToken = default);
Task GetByIdForDepartmentAsync(int departmentId, string analysisId);
/// The analysis for an incident report; one per report, null when none has been started.
Task GetForReportAsync(int departmentId, string incidentReportId);
/// Analyses finalized but not yet filed because their incident had no NERIS id at the time.
Task> GetAwaitingIncidentAsync(int departmentId, int take);
Task CountByStateAsync(int departmentId, RmsIncidentAnalysisState state);
+ Task CountVisibleByStateAsync(int departmentId, RmsIncidentAnalysisState state, List visibleGroupIds, string userId);
}
public interface IRmsValidationIssuesRepository : IRepository
@@ -91,6 +93,11 @@ public interface IRmsValidationIssuesRepository : IRepository
{
+ Task TryReconcileReceiptAsync(int departmentId, string submissionId, long expectedVersion, string externalId, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default);
+ Task TryBindUnsentAsync(int departmentId, string submissionId, long expectedVersion, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default);
+ Task TryConfirmNotCreatedAsync(int departmentId, string submissionId, long expectedVersion, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default);
+ /// Transaction-local completion/dispatch fence: locks and bumps only the current, unexpired lease.
+ Task TryFenceLeaseAsync(int departmentId, string submissionId, long expectedVersion, string leaseOwner, DateTime now, CancellationToken cancellationToken = default);
Task GetByIdForDepartmentAsync(int departmentId, string submissionId);
Task> GetForRecordAsync(int departmentId, string recordId);
Task GetByIdempotencyKeyAsync(string idempotencyKey);
@@ -102,6 +109,11 @@ public interface IRmsSubmissionsRepository : IRepository
Task SupersedeOpenForRecordAsync(int departmentId, string recordId, string exceptSubmissionId, DateTime utcNow, CancellationToken cancellationToken = default);
}
+ public interface IRmsSubmissionExchangesRepository : IRepository
+ {
+ Task> GetForSubmissionAsync(int departmentId, string submissionId);
+ }
+
public interface IRmsSignaturesRepository : IRepository
{
Task> GetForRecordAsync(int departmentId, string recordId);
diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
index 38f3f5a84..56331d0c5 100644
--- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -38,6 +38,7 @@ public interface IRmsOperationalRecordsRepository : IRepository> GetByOwnerAndStatesAsync(int departmentId, string ownerUserId, IEnumerable states);
Task> GetByDepartmentAndStatesAsync(int departmentId, IEnumerable states, int? year, int skip, int take);
Task CountByDepartmentAsync(int departmentId, IEnumerable states);
+ Task CountVisibleAsync(int departmentId, IEnumerable states, List visibleGroupIds, string userId);
Task CountCreatedSinceAsync(int departmentId, DateTime sinceUtc);
Task CountFinalizedSinceAsync(int departmentId, DateTime sinceUtc);
/// Every live Record in the department, any state.
@@ -47,7 +48,7 @@ public interface IRmsOperationalRecordsRepository : IRepositoryLive Finalized/Amended Records whose FinalizedOn is at or after the instant.
Task> GetFinalizedSinceAsync(int departmentId, DateTime sinceUtc);
/// Retention candidates (RMS-3, worker 43): live, closed Records finalized before the cutoff, oldest first.
- Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take);
+ Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take, string afterId = null);
/// Live Records with no RmsRecordGroupScope row: they stay department-wide under group scoping (plan 5.7.1).
Task CountWithoutGroupScopeAsync(int departmentId);
Task> GetYearsAsync(int departmentId);
@@ -89,6 +90,9 @@ public interface IRmsRecordUnitResponsesRepository : IRepository
{
+ /// Includes attachments removed from the draft. Caller must verify immutable revision membership and authorize the live parent.
+ Task GetHistoricalByIdForDepartmentAsync(int departmentId, string attachmentId);
+ Task ApplyScanResultAsync(int departmentId, string attachmentId, long expectedVersion, RmsAttachmentScanState state, DateTime now, CancellationToken cancellationToken = default);
/// Metadata only: never loads Data.
Task> GetMetadataForRecordAsync(int departmentId, string recordId);
/// Loads Data; authorized per Record by the caller on every request.
@@ -134,6 +138,7 @@ public interface IRmsDepartmentCutoverEventsRepository : IRepository
{
+ Task> GetByIdsForDepartmentAsync(int departmentId, IEnumerable revisionIds);
Task> GetForRecordAsync(int departmentId, string recordId);
Task GetByIdForDepartmentAsync(int departmentId, string revisionId);
}
@@ -167,6 +172,8 @@ public interface IRmsSearchIndexStatesRepository : IRepository
{
+ /// Live share identities for cache-scope invalidation. Excludes free-text reasons and expired/revoked grants.
+ Task> GetEffectiveSharesAsync(int departmentId, IEnumerable groupIds);
Task> GetForRecordAsync(int departmentId, string recordId);
Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds);
/// Deletes the Record's scope rows and inserts the supplied set; run inside the owning transaction.
@@ -190,6 +197,8 @@ public interface IRmsRecordSharesRepository : IRepository
///
public interface IRmsEvidenceArtifactsRepository : IRepository
{
+ /// Metadata-only history across draft and signed revisions, including superseded evidence.
+ Task> GetHistoryAsync(int departmentId, string recordId, int skip, int take);
Task GetByIdForDepartmentAsync(int departmentId, string artifactId);
/// null returns the working-draft artifacts.
Task> GetForRecordAsync(int departmentId, string recordId, string revisionId, bool includeSuperseded);
@@ -208,12 +217,14 @@ public interface IRmsRecordDueStatesRepository : IRepository
Task> GetOpenForDepartmentAsync(int departmentId, int take);
/// Count of obligations currently sitting overdue; the accountability view and dashboards read it.
Task CountOverdueAsync(int departmentId);
+ Task CountVisibleOverdueAsync(int departmentId, List visibleGroupIds, string userId);
Task ClearForRecordAsync(int departmentId, string recordId, DateTime utcNow, CancellationToken cancellationToken = default);
}
/// Public-records requests (registry M0171, RMS-3); the statutory clock is what these are read by.
public interface IRmsDisclosureRequestsRepository : IRepository
{
+ Task TryBumpRowVersionAsync(int departmentId, string requestId, long expectedVersion, CancellationToken cancellationToken = default);
Task GetByIdForDepartmentAsync(int departmentId, string requestId);
Task> GetForDepartmentAsync(int departmentId, IEnumerable states, int skip, int take);
Task CountByStateAsync(int departmentId, RmsDisclosureState state);
@@ -228,6 +239,7 @@ public interface IRmsDisclosureRequestsRepository : IRepository
public interface IRmsDisclosureProductionsRepository : IRepository
{
+ Task TryReleaseAsync(int departmentId, string productionId, long expectedVersion, string userId, DateTime releasedOn, string deliveryMethod, string deliveryReference, CancellationToken cancellationToken = default);
Task GetByIdForDepartmentAsync(int departmentId, string productionId);
Task> GetForRequestAsync(int departmentId, string requestId);
Task GetMaxProductionNumberAsync(int departmentId, string requestId);
@@ -235,6 +247,7 @@ public interface IRmsDisclosureProductionsRepository : IRepository
{
+ Task TryReleaseAsync(int departmentId, string holdId, long expectedVersion, string userId, string reason, DateTime releasedOn, CancellationToken cancellationToken = default);
Task GetByIdForDepartmentAsync(int departmentId, string holdId);
/// Every hold still in force for the department; the retention sweep loads them once per pass.
Task> GetActiveForDepartmentAsync(int departmentId);
diff --git a/Core/Resgrid.Model/Repositories/IRmsRetentionRepository.cs b/Core/Resgrid.Model/Repositories/IRmsRetentionRepository.cs
new file mode 100644
index 000000000..4b086975a
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRmsRetentionRepository.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public sealed class RmsPurgeResult
+ {
+ /// SQL content was removed. Search storage is complete only after its separate durable acknowledgement.
+ public bool Purged { get; set; }
+ public bool SearchErasurePending { get; set; }
+ public bool Held { get; set; }
+ public int AttachmentsPurged { get; set; }
+ public string Reason { get; set; }
+ }
+
+ public sealed class RmsSearchErasureTarget
+ {
+ public int DepartmentId { get; set; }
+ public int RecordKind { get; set; }
+ public string RecordId { get; set; }
+ public DateTime PurgedOn { get; set; }
+ public List SourceIds { get; set; } = new List();
+ }
+
+ public interface IRmsRetentionRepository
+ {
+ /// Rechecks the current policy, all holds, and aggregate version under one department transaction lock.
+ Task PurgeAsync(int departmentId, string recordId, RmsRecordKind kind, long expectedVersion, DateTime now, CancellationToken cancellationToken = default);
+ Task> GetPendingSearchErasuresAsync(int take, RmsSearchErasureTarget after = null, CancellationToken cancellationToken = default);
+ /// Called only after all source keys have been removed, deleted Lucene documents expunged and the index committed.
+ Task CompleteSearchErasureAsync(RmsSearchErasureTarget target, DateTime completedOn, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRmsSearchWriteFence.cs b/Core/Resgrid.Model/Repositories/IRmsSearchWriteFence.cs
new file mode 100644
index 000000000..5d98c2807
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRmsSearchWriteFence.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ /// Serializes each index mutation with retention and verifies the current SQL source before invoking the synchronous index writer.
+ public interface IRmsSearchWriteFence
+ {
+ Task WithLiveSourceAsync(RecordsSearchDocumentSource source, Func write, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRmsUdfDefinitionsRepository.cs b/Core/Resgrid.Model/Repositories/IRmsUdfDefinitionsRepository.cs
new file mode 100644
index 000000000..53e5c4d38
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRmsUdfDefinitionsRepository.cs
@@ -0,0 +1,15 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IRmsUdfDefinitionsRepository : IRepository
+ {
+ Task GetActiveAsync(int departmentId, string key, int version);
+ Task GetScopedAsync(int departmentId, string definitionId, string key, int version);
+ Task DeactivateAsync(int departmentId, string key, int version, CancellationToken ct);
+ Task LockDepartmentAsync(int departmentId, CancellationToken ct);
+ Task GuardRecordAsync(int departmentId, string recordId, CancellationToken ct);
+ Task DeleteRecordValuesAsync(int departmentId, string recordId, CancellationToken ct);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs b/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs
index a1a1dcce1..7fa00d5d6 100644
--- a/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs
+++ b/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs
@@ -25,9 +25,10 @@ public interface IIncidentAnalysisService
///
/// ETag-guarded draft save. false keeps the stored VIN and plate on
- /// each vehicle row rather than accepting or erasing them.
+ /// each vehicle row rather than accepting or erasing them. It defaults to false: a caller that has not
+ /// resolved the claim must not be granted restricted writes by omission.
///
- Task SaveDraftAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, IncidentAnalysisDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default);
+ Task SaveDraftAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, IncidentAnalysisDraftInput input, bool canWriteRestricted = false, CancellationToken cancellationToken = default);
/// Local validation against the pinned contract; never touches the incident's own issue list.
Task> ValidateAsync(int departmentId, string analysisId, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Services/IIncidentAttachmentsService.cs b/Core/Resgrid.Model/Services/IIncidentAttachmentsService.cs
new file mode 100644
index 000000000..1c6444e6e
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IIncidentAttachmentsService.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IIncidentAttachmentsService
+ {
+ Task RemoveAsync(int departmentId, string userId, string reportId, string attachmentId, long expectedVersion, CancellationToken cancellationToken = default);
+ Task AddAsync(int departmentId, string userId, string reportId, long expectedVersion, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default, int classification = 1);
+ Task GetAsync(int departmentId, string userId, string reportId, string attachmentId, string revisionId = null);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IIncidentReportsService.cs b/Core/Resgrid.Model/Services/IIncidentReportsService.cs
index bf25691f5..6d644c020 100644
--- a/Core/Resgrid.Model/Services/IIncidentReportsService.cs
+++ b/Core/Resgrid.Model/Services/IIncidentReportsService.cs
@@ -22,9 +22,10 @@ public interface IIncidentReportsService
///
/// ETag-guarded draft save. false keeps the restricted halves of the
/// casualty rows as they stand instead of accepting or erasing them, so a reviewer without
- /// RecordRestricted_View can still correct the rest of the report (RMS-3).
+ /// RecordRestricted_View can still correct the rest of the report (RMS-3). It defaults to false: a caller
+ /// that has not resolved the claim must not be granted restricted writes by omission.
///
- Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default);
+ Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, bool canWriteRestricted = false, CancellationToken cancellationToken = default);
/// Runs local validation (and the destination's validate endpoint when asked and configured) and stores the issues on the report.
Task> ValidateAsync(int departmentId, string reportId, bool includeDestination, CancellationToken cancellationToken = default);
@@ -88,6 +89,11 @@ public class RecordsSubmissionSweepResult
///
public interface IRecordsSubmissionService
{
+ Task> GetHistoryAsync(int departmentId, string userId, string submissionId, CancellationToken cancellationToken = default);
+ /// Bind an ambiguous create to a verified destination filing without issuing another POST.
+ Task ReconcileAsync(int departmentId, string userId, string submissionId, long expectedVersion, string externalId, string reason, CancellationToken cancellationToken = default);
+ /// Administrator records externally verified absence of a filing. Clears ambiguity without delivering or automatically retrying.
+ Task ConfirmNotCreatedAsync(int departmentId, string userId, string submissionId, long expectedVersion, string verificationReference, string reason, CancellationToken cancellationToken = default);
Task SweepAsync(CancellationToken cancellationToken = default);
/// Processes one claimed submission; public so a single delivery can be driven and tested directly.
diff --git a/Core/Resgrid.Model/Services/IRecordEvidenceSelectionService.cs b/Core/Resgrid.Model/Services/IRecordEvidenceSelectionService.cs
new file mode 100644
index 000000000..2b96f85c2
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IRecordEvidenceSelectionService.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IRecordEvidenceSelectionService
+ {
+ Task GetContextAsync(int departmentId, string userId, string recordId, RmsRecordKind recordKind);
+ Task GetAsync(int departmentId, string userId, string recordId, RmsRecordKind recordKind,
+ RmsEvidenceKind sourceKind, string channelId = null, long afterSequence = 0);
+ }
+
+ public class RecordEvidenceContext
+ {
+ public string RecordId { get; set; }
+ public RmsRecordKind RecordKind { get; set; }
+ public string RecordNumber { get; set; }
+ public long RowVersion { get; set; }
+ public int? CallId { get; set; }
+ public DateTime? StartUtc { get; set; }
+ public DateTime? EndUtc { get; set; }
+ public bool CanCapture { get; set; }
+ public bool CanViewRestricted { get; set; }
+ public bool CanExport { get; set; }
+ }
+
+ public class RecordEvidenceSelection
+ {
+ public RecordEvidenceContext Context { get; set; }
+ public RmsEvidenceKind SourceKind { get; set; }
+ public List Sources { get; set; } = new();
+ public List Choices { get; set; } = new();
+ public List Channels { get; set; } = new();
+ public string ChannelId { get; set; }
+ public long? NextSequence { get; set; }
+ }
+
+ public class RecordEvidenceChoice
+ {
+ public string Id { get; set; }
+ public string Label { get; set; }
+ public string Body { get; set; }
+ public DateTime? OccurredOn { get; set; }
+ public DateTime? EditedOn { get; set; }
+ public long? Sequence { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs
index 7396060b2..41690f909 100644
--- a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs
@@ -12,6 +12,13 @@ namespace Resgrid.Model.Services
///
public interface IRecordsAuthorizationService
{
+ Task IsActiveMemberAsync(string userId, int departmentId);
+ Task IsDepartmentAdminAsync(string userId, int departmentId);
+ Task HasPermissionAsync(string userId, int departmentId, PermissionTypes permissionType);
+ Task CanReadSourceCallAsync(string userId, int departmentId, Call call);
+ Task CanCreateSourceCallAsync(string userId, int departmentId);
+ Task CanUseSourceInventoryAsync(string userId, int departmentId, int? groupId = null);
+
/// Whether cross-group scoping is in force for the department (setting 75 GroupScoped and ViewGroupRecords LockToGroup).
Task IsGroupScopedAsync(int departmentId);
@@ -22,6 +29,9 @@ public interface IRecordsAuthorizationService
///
Task> GetVisibleGroupIdsAsync(string userId, int departmentId);
+ /// Opaque fingerprint of current membership, roles, group and permission policy for local-cache invalidation. Null means read access cannot be established.
+ Task GetReadScopeStampAsync(string userId, int departmentId);
+
/// Per-Record check applied on every detail, attachment and deep-link read.
Task CanUserViewRecordAsync(string userId, string recordId, int departmentId);
diff --git a/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs b/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs
index 05f808572..26bb18fc9 100644
--- a/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs
@@ -15,24 +15,24 @@ namespace Resgrid.Model.Services
/// snapshot so a later amendment cannot silently change what was handed over.
///
///
- /// Callers must hold RecordDisclosure_Update; this service assumes the policy check already happened
- /// at the controller and enforces the workflow rules, not the permission.
+ /// Callers must hold RecordDisclosure_Update; mutations and production also check live authorization
+ /// in the service.
///
///
public interface IRecordsDisclosureService
{
Task CreateRequestAsync(int departmentId, string userId, RmsDisclosureRequest request, CancellationToken cancellationToken = default);
- Task GetAsync(int departmentId, string requestId);
+ Task GetAsync(int departmentId, string userId, string requestId);
- Task> QueryAsync(int departmentId, IEnumerable states, int skip = 0, int take = 50);
+ Task> QueryAsync(int departmentId, string userId, IEnumerable states, int skip = 0, int take = 50);
/// Saves the scope query and narrative; refused once a production exists, because the scope is what was produced against.
Task SaveScopeAsync(int departmentId, string userId, string requestId, string scopeNarrative, RmsRecordQuery scope, string redactionProfile, CancellationToken cancellationToken = default);
///
/// What the scope resolves to right now, through the same authorization and group-scope path as the
- /// Records queue. Drafts are listed but never producible: an unfinished report is not a public record.
+ /// Records queue. Unfinished records require a separate review; automatic production uses saved revisions.
///
Task PreviewScopeAsync(int departmentId, string userId, string requestId, int take = 200);
@@ -40,12 +40,16 @@ public interface IRecordsDisclosureService
/// Builds a new immutable production: redacted content, the produced-set snapshot, a redaction log and a
/// checksum. Never mutates a source revision.
///
- Task ProduceAsync(int departmentId, string userId, string requestId, string redactionProfile = null, CancellationToken cancellationToken = default);
+ Task GetReviewAsync(int departmentId, string userId, string requestId, string redactionProfile = null);
+ Task ProduceAsync(int departmentId, string userId, string requestId, string redactionProfile = null, CancellationToken cancellationToken = default, RmsDisclosureReview review = null);
/// Releases a prepared production to the requester and closes the statutory clock.
- Task ReleaseAsync(int departmentId, string userId, string productionId, CancellationToken cancellationToken = default);
+ Task ReleaseAsync(int departmentId, string userId, string productionId, CancellationToken cancellationToken = default, string deliveryMethod = null, string deliveryReference = null);
- Task> GetProductionsAsync(int departmentId, string requestId);
+ Task> GetProductionsAsync(int departmentId, string userId, string requestId);
+ Task GetAuthorizedProductionAsync(int departmentId, string userId, string productionId);
+ Task DownloadAsync(int departmentId, string userId, string productionId, string format);
+ Task GetReviewAttachmentAsync(int departmentId, string userId, string requestId, string recordId, string revisionId, string attachmentId, string profile);
/// Closes a request without release — denied under an exemption, or withdrawn. A reason is required.
Task CloseAsync(int departmentId, string userId, string requestId, RmsDisclosureState disposition, string reason, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Services/IRecordsDocumentService.cs b/Core/Resgrid.Model/Services/IRecordsDocumentService.cs
new file mode 100644
index 000000000..28efc784b
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IRecordsDocumentService.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public sealed class RecordDocument
+ {
+ public string Format { get; set; } = "resgrid.department-record.v2";
+ public string RecordId { get; set; }
+ public RmsRecordKind RecordKind { get; set; }
+ public string RecordNumber { get; set; }
+ public string RevisionId { get; set; }
+ public int RevisionNumber { get; set; }
+ public string OriginalChecksum { get; set; }
+ public string ContentChecksum { get; set; }
+ public DateTime FinalizedOn { get; set; }
+ public string AttestedBy { get; set; }
+ public string AttestationVersion { get; set; }
+ public string ContentJson { get; set; }
+ public List WithheldFields { get; set; } = new List();
+ }
+ public interface IRecordsDocumentService
+ {
+ /// Defaults to the official current revision, even while an amendment is open. Live authorization is always required.
+ Task GetAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, string revisionId = null, bool exporting = false);
+ Task RenderHtmlAsync(int departmentId, string userId, RecordDocument document);
+ Task RenderPdfAsync(int departmentId, string userId, RecordDocument document);
+ Task RenderDiffPdfAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, string fromRevisionId, string toRevisionId);
+ Task> DiffAsync(int departmentId, string userId, RecordDocument from, RecordDocument to);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs b/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs
index ef3ca2955..a55280089 100644
--- a/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs
@@ -35,6 +35,7 @@ public interface IRecordEvidenceAdapter
///
public interface IRecordsEvidenceService
{
+ Task> GetHistoryAsync(int departmentId, string recordId, int skip, int take);
/// Which of the six sources can actually produce evidence for this department right now.
Task> GetSourceStatesAsync(int departmentId);
@@ -54,6 +55,7 @@ public interface IRecordsEvidenceService
/// Re-computes the checksum from the stored manifest; false means the artifact was tampered with.
Task VerifyAsync(int departmentId, string artifactId);
+ Task RequireInventoryCoverageAsync(int departmentId, string recordId, IEnumerable captured);
}
/// Whether one evidence source can produce anything for a department, and why not when it cannot.
diff --git a/Core/Resgrid.Model/Services/IRecordsLegalHoldService.cs b/Core/Resgrid.Model/Services/IRecordsLegalHoldService.cs
new file mode 100644
index 000000000..f83714be7
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IRecordsLegalHoldService.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IRecordsLegalHoldService
+ {
+ Task> GetAsync(int departmentId, string userId);
+ Task PlaceAsync(int departmentId, string userId, RmsRecordLegalHold input, CancellationToken cancellationToken = default);
+ Task ReleaseAsync(int departmentId, string userId, string holdId, long expectedVersion, string reason, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs b/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs
index 753f17ea1..be1af190b 100644
--- a/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs
+++ b/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs
@@ -18,6 +18,9 @@ public interface IRecordsSearchIndexer
Task CommitAsync(CancellationToken cancellationToken = default);
+ /// Expunges deleted documents from the configured index, commits replacement segments and refreshes this process's reader.
+ Task ExpungeDeletesAsync(CancellationToken cancellationToken = default);
+
Task CountDocumentsAsync(int departmentId);
}
}
diff --git a/Core/Resgrid.Model/Services/IRecordsService.cs b/Core/Resgrid.Model/Services/IRecordsService.cs
index e5bce3715..5bb447f3d 100644
--- a/Core/Resgrid.Model/Services/IRecordsService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsService.cs
@@ -18,6 +18,7 @@ public interface IRecordsService
/// ETag-guarded draft save; throws on a stale RowVersion.
Task SaveDraftAsync(int departmentId, string userId, string recordId, long expectedRowVersion, RecordDraftInput input, CancellationToken cancellationToken = default);
+ Task CreateRunCallAsync(int departmentId, string userId, string recordId, long expectedRowVersion, RecordNewCallInput input, CancellationToken cancellationToken = default);
Task SubmitForReviewAsync(int departmentId, string userId, string recordId, long expectedRowVersion, CancellationToken cancellationToken = default);
@@ -63,10 +64,10 @@ public interface IRecordsService
/// Delta cursor for clients (plan section 5.3): projections modified after , oldest first, including tombstones (cancelled, voided, deleted).
Task> GetChangesSinceAsync(int departmentId, DateTime? since, int take, string sinceId = null);
- Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default);
+ Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default, int classification = 1);
/// Loads bytes; the caller authorizes per Record before serving.
- Task GetAttachmentAsync(int departmentId, string attachmentId);
+ Task GetAttachmentAsync(int departmentId, string userId, string attachmentId);
Task RemoveAttachmentAsync(int departmentId, string userId, string recordId, string attachmentId, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Services/IRecordsUdfService.cs b/Core/Resgrid.Model/Services/IRecordsUdfService.cs
new file mode 100644
index 000000000..e971989ec
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IRecordsUdfService.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IRecordsUdfService
+ {
+ Task GetForDesignerAsync(int departmentId, string userId, string key, int version);
+ Task GetNewFormAsync(int departmentId, string userId, string key, int version);
+ Task GetVisibilityLevelAsync(int departmentId, string userId);
+ Task PublishAsync(int departmentId, string userId, string key, int version, string expectedDefinitionId, List fields, CancellationToken ct = default);
+ /// Internal capture; entry points must project with current caller permissions before returning it.
+ Task CaptureAsync(int departmentId, string recordId, string key, int version, string definitionId);
+ Task ProjectAsync(int departmentId, string userId, RecordUdfSection section, bool mobile = false, bool reportLayout = false);
+ /// Called only under the owning RMS header CAS transaction. Returns the immutable definition pin.
+ Task SaveInTransactionAsync(int departmentId, string userId, string recordId, string key, int version, string pinnedDefinitionId, RecordUdfInput input, CancellationToken ct);
+ Task RestoreInTransactionAsync(int departmentId, string recordId, string key, int version, RecordUdfSection section, string userId, CancellationToken ct);
+ void ValidateForFinalization(RecordUdfSection section);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs
index aa04672e5..1ceab0edd 100644
--- a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs
+++ b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs
@@ -19,6 +19,10 @@ public class RmsInventoryUsage
public int InventoryId { get; set; }
public decimal Quantity { get; set; }
public string Note { get; set; }
+ public string ItemName { get; set; }
+ public string UnitOfMeasure { get; set; }
+ public string SourceChecksum { get; set; }
+ public string ReferenceChecksum { get; set; }
public string CapturedByUserId { get; set; }
public DateTime CapturedOn { get; set; }
}
@@ -33,6 +37,7 @@ public class RmsInventoryUsage
///
public interface IRmsInventoryUsageAdapter
{
+ Task ConsumeAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, long expectedRowVersion, int typeId, int groupId, int? unitId, decimal quantity, string note, CancellationToken cancellationToken = default);
Task> GetUsageForRecordAsync(int departmentId, string recordId);
Task> GetUsageForLegacyLogAsync(int departmentId, int logId);
diff --git a/Core/Resgrid.Model/UdfDefinition.cs b/Core/Resgrid.Model/UdfDefinition.cs
index 385b3e9f4..16187cd39 100644
--- a/Core/Resgrid.Model/UdfDefinition.cs
+++ b/Core/Resgrid.Model/UdfDefinition.cs
@@ -16,6 +16,9 @@ public class UdfDefinition : IEntity
/// The entity type this definition applies to. See .
///
public int EntityType { get; set; }
+ /// Required for Record extensions; null for other entity types.
+ public string RecordDefinitionKey { get; set; }
+ public int? RecordDefinitionVersion { get; set; }
///
/// Auto-incrementing version number per department + entity type. Each save creates a new version.
diff --git a/Core/Resgrid.Model/UdfField.cs b/Core/Resgrid.Model/UdfField.cs
index 5603a039e..cdbc8ac81 100644
--- a/Core/Resgrid.Model/UdfField.cs
+++ b/Core/Resgrid.Model/UdfField.cs
@@ -72,6 +72,8 @@ public class UdfField : IEntity
/// Everyone=0, DepartmentAndGroupAdmins=1, DepartmentAdminsOnly=2.
///
public int Visibility { get; set; }
+ /// Published RMS classification: 0 unrestricted, 1 restricted; unknown legacy fields require restricted access.
+ public int? RmsClassification { get; set; }
[NotMapped]
[JsonIgnore]
diff --git a/Core/Resgrid.Search/LuceneRecordsIndexHost.cs b/Core/Resgrid.Search/LuceneRecordsIndexHost.cs
index 35c219511..bfe8093d5 100644
--- a/Core/Resgrid.Search/LuceneRecordsIndexHost.cs
+++ b/Core/Resgrid.Search/LuceneRecordsIndexHost.cs
@@ -20,16 +20,20 @@ namespace Resgrid.Search
public sealed class LuceneRecordsIndexHost : IDisposable
{
private readonly object _sync = new object();
- private readonly Directory _directory;
private readonly bool _ownsDirectory;
+ private Directory _directory;
private IndexWriter _writer;
private SearcherManager _searcherManager;
private bool _searcherIsNrt;
private bool _disposed;
public LuceneRecordsIndexHost()
- : this(OpenConfiguredDirectory(), true)
{
+ // Deliberately no I/O here. This type is a container singleton, so opening the configured path from the
+ // constructor makes every process that merely composes its container depend on the shared volume being
+ // present and writable — a process with search disabled must still start. The directory opens on use.
+ _ownsDirectory = true;
+ Analyzer = new StandardAnalyzer(RecordsIndexFields.Version);
}
/// Test seam: host any directory (e.g. RAMDirectory) without touching the configured path.
@@ -46,11 +50,24 @@ public LuceneRecordsIndexHost(Directory directory, bool ownsDirectory = false)
public bool Enabled => SearchConfig.Enabled;
+ /// The backing store, opening the configured path on first use.
+ private Directory Store
+ {
+ get
+ {
+ if (_directory != null)
+ return _directory;
+
+ lock (_sync)
+ return _directory ??= OpenConfiguredDirectory();
+ }
+ }
+
public bool IndexExists
{
get
{
- try { return DirectoryReader.IndexExists(_directory); }
+ try { return DirectoryReader.IndexExists(Store); }
catch (Exception ex)
{
Logging.LogException(ex, "Records search index existence check failed.");
@@ -71,9 +88,10 @@ public IndexWriter GetWriter()
var config = new IndexWriterConfig(RecordsIndexFields.Version, Analyzer)
{
OpenMode = OpenMode.CREATE_OR_APPEND,
+ MergePolicy = new TieredMergePolicy { ForceMergeDeletesPctAllowed = 0 },
RAMBufferSizeMB = Math.Max(1, SearchConfig.RamBufferSizeMb)
};
- _writer = new IndexWriter(_directory, config);
+ _writer = new IndexWriter(Store, config);
// A reader opened before the writer existed keeps working; from here on prefer the NRT view.
if (_searcherManager != null && !_searcherIsNrt)
@@ -102,10 +120,10 @@ public SearcherManager GetSearcherManager()
return _searcherManager;
}
- if (!DirectoryReader.IndexExists(_directory))
+ if (!DirectoryReader.IndexExists(Store))
return null;
- _searcherManager = new SearcherManager(_directory, null);
+ _searcherManager = new SearcherManager(Store, null);
_searcherIsNrt = false;
return _searcherManager;
}
@@ -123,6 +141,27 @@ public void MaybeRefresh()
catch (Exception ex) { Logging.LogException(ex, "Records search reader refresh failed."); }
}
+ /// Serializes mutations with the committed-segment erasure pass.
+ public int Write(Func mutation)
+ {
+ lock (_sync) { ThrowIfDisposed(); return mutation(GetWriter()); }
+ }
+
+ public void ExpungeDeletes()
+ {
+ lock (_sync)
+ {
+ ThrowIfDisposed();
+ var writer = GetWriter();
+ writer.ForceMergeDeletes(true);
+ writer.Commit();
+ _searcherManager?.MaybeRefreshBlocking();
+ writer.DeleteUnusedFiles();
+ using var committed = DirectoryReader.Open(Store);
+ if (committed.HasDeletions) throw new InvalidOperationException("Deleted records remain in the committed index; erasure cannot be acknowledged.");
+ }
+ }
+
private static Directory OpenConfiguredDirectory()
{
var path = Path.Combine(SearchConfig.IndexPath ?? string.Empty, RecordsIndexFields.IndexName);
@@ -146,7 +185,9 @@ public void Dispose()
try { _searcherManager?.Dispose(); } catch (Exception ex) { Logging.LogException(ex); }
try { _writer?.Dispose(); } catch (Exception ex) { Logging.LogException(ex); }
- if (_ownsDirectory)
+ // _directory, never Store: a host that was disposed without ever indexing must not open the
+ // configured path on its way out.
+ if (_ownsDirectory && _directory != null)
{
try { _directory.Dispose(); } catch (Exception ex) { Logging.LogException(ex); }
}
diff --git a/Core/Resgrid.Search/LuceneRecordsIndexer.cs b/Core/Resgrid.Search/LuceneRecordsIndexer.cs
index 81e4f2e28..11c8ff370 100644
--- a/Core/Resgrid.Search/LuceneRecordsIndexer.cs
+++ b/Core/Resgrid.Search/LuceneRecordsIndexer.cs
@@ -5,6 +5,7 @@
using Lucene.Net.Index;
using Lucene.Net.Search;
using Resgrid.Model;
+using Resgrid.Model.Repositories;
using Resgrid.Model.Services;
namespace Resgrid.Search
@@ -16,15 +17,16 @@ namespace Resgrid.Search
public class LuceneRecordsIndexer : IRecordsSearchIndexer
{
private readonly LuceneRecordsIndexHost _host;
+ private readonly IRmsSearchWriteFence _fence;
- public LuceneRecordsIndexer(LuceneRecordsIndexHost host)
+ public LuceneRecordsIndexer(LuceneRecordsIndexHost host, IRmsSearchWriteFence fence)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
+ _fence = fence ?? throw new ArgumentNullException(nameof(fence));
}
- public Task IndexAsync(IEnumerable documents, CancellationToken cancellationToken = default)
+ public async Task IndexAsync(IEnumerable documents, CancellationToken cancellationToken = default)
{
- var writer = _host.GetWriter();
var count = 0;
foreach (var source in documents ?? Array.Empty())
@@ -33,41 +35,56 @@ public Task IndexAsync(IEnumerable documents,
if (source?.Projection == null)
continue;
- var p = source.Projection;
- var key = RecordsIndexFields.BuildKey(p.DepartmentId, p.SourceType, p.SourceId);
-
- if (p.DeletedOn.HasValue)
+ count += await _fence.WithLiveSourceAsync(source, current => _host.Write(writer =>
{
- writer.DeleteDocuments(new Term(RecordsIndexFields.Key, key));
- continue;
- }
-
- writer.UpdateDocument(new Term(RecordsIndexFields.Key, key), RecordsSearchDocumentBuilder.Build(source));
- count++;
+ var p = current.Projection;
+ var key = RecordsIndexFields.BuildKey(p.DepartmentId, p.SourceType, p.SourceId);
+ if (p.DeletedOn.HasValue)
+ {
+ writer.DeleteDocuments(new Term(RecordsIndexFields.Key, key));
+ return 0;
+ }
+ writer.UpdateDocument(new Term(RecordsIndexFields.Key, key), RecordsSearchDocumentBuilder.Build(current));
+ return 1;
+ }), cancellationToken);
}
- return Task.FromResult(count);
+ return count;
}
- public Task DeleteAsync(int departmentId, int sourceType, string sourceId, CancellationToken cancellationToken = default)
+ public async Task DeleteAsync(int departmentId, int sourceType, string sourceId, CancellationToken cancellationToken = default)
{
- _host.GetWriter().DeleteDocuments(new Term(RecordsIndexFields.Key, RecordsIndexFields.BuildKey(departmentId, sourceType, sourceId)));
- return Task.CompletedTask;
+ await _fence.WithLiveSourceAsync(new RecordsSearchDocumentSource { Projection = new RmsRecordSearchProjection
+ { DepartmentId = departmentId, SourceType = sourceType, SourceId = sourceId, DeletedOn = DateTime.UtcNow } }, current =>
+ {
+ if (!current.Projection.DeletedOn.HasValue) throw new InvalidOperationException("A live record cannot be erased from the index by a stale deletion request.");
+ _host.Write(writer => { writer.DeleteDocuments(new Term(RecordsIndexFields.Key, RecordsIndexFields.BuildKey(departmentId, sourceType, sourceId))); return 0; });
+ return 0;
+ }, cancellationToken);
}
public Task DeleteDepartmentAsync(int departmentId, CancellationToken cancellationToken = default)
{
- _host.GetWriter().DeleteDocuments(new Term(RecordsIndexFields.DepartmentId, departmentId.ToString()));
+ cancellationToken.ThrowIfCancellationRequested();
+ _host.Write(writer => { writer.DeleteDocuments(new Term(RecordsIndexFields.DepartmentId, departmentId.ToString())); return 0; });
return Task.CompletedTask;
}
public Task CommitAsync(CancellationToken cancellationToken = default)
{
- _host.GetWriter().Commit();
+ cancellationToken.ThrowIfCancellationRequested();
+ _host.Write(writer => { writer.Commit(); return 0; });
_host.MaybeRefresh();
return Task.CompletedTask;
}
+ public Task ExpungeDeletesAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _host.ExpungeDeletes();
+ return Task.CompletedTask;
+ }
+
public Task CountDocumentsAsync(int departmentId)
{
var manager = _host.GetSearcherManager();
diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs
index f4fa2503a..bfc882415 100644
--- a/Core/Resgrid.Services/DepartmentSettingsService.cs
+++ b/Core/Resgrid.Services/DepartmentSettingsService.cs
@@ -1504,14 +1504,18 @@ public Task GetRecordsSearchConfigAsync(int departmentId, b
return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsSearchConfig, ObjectSerialization.Serialize(config ?? new RecordsSearchConfig()), cancellationToken);
}
- public Task GetRecordsRetentionPolicyAsync(int departmentId, bool bypassCache = false)
+ public async Task GetRecordsRetentionPolicyAsync(int departmentId, bool bypassCache = false)
{
- return GetRecordsSettingObjectAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy, bypassCache);
+ var value = await GetRecordsSettingStringAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy, bypassCache);
+ if (string.IsNullOrWhiteSpace(value)) return new RecordsRetentionPolicy();
+ return ObjectSerialization.Deserialize(value) ?? throw new InvalidOperationException("The retention policy cannot be read. Automatic purge is suspended.");
}
- public Task SetRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, CancellationToken cancellationToken = default(CancellationToken))
+ public async Task SetRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, CancellationToken cancellationToken = default(CancellationToken))
{
- return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy, ObjectSerialization.Serialize(policy ?? new RecordsRetentionPolicy()), cancellationToken);
+ var result = await _departmentSettingsRepository.SaveRecordsRetentionPolicyAsync(departmentId, policy ?? new RecordsRetentionPolicy(), cancellationToken);
+ _cacheProvider.Remove(string.Format(RecordsSettingCacheKey, (int)DepartmentSettingTypes.RecordsRetentionPolicy, departmentId));
+ return result;
}
public async Task GetRecordsGroupVisibilityModeAsync(int departmentId, bool bypassCache = false)
diff --git a/Core/Resgrid.Services/Records/DisclosureContentPolicy.cs b/Core/Resgrid.Services/Records/DisclosureContentPolicy.cs
new file mode 100644
index 000000000..4e8a3d404
--- /dev/null
+++ b/Core/Resgrid.Services/Records/DisclosureContentPolicy.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Resgrid.Model;
+
+namespace Resgrid.Services.Records
+{
+ /// Exact field selections for a reviewed disclosure. A decision may only withhold content, never introduce or broaden it.
+ public static class DisclosureContentPolicy
+ {
+ public static JObject Prepare(JObject authorizedContent)
+ {
+ var content = (JObject)authorizedContent.DeepClone();
+ void Expand(JToken token, int depth)
+ {
+ if (depth > 32) throw new ArgumentException("Disclosure content is too deeply nested.");
+ if (token is JObject obj)
+ foreach (var property in obj.Properties().ToList())
+ {
+ if (property.Value.Type == JTokenType.String && property.Name.EndsWith("Json", StringComparison.Ordinal))
+ {
+ var text = (string)property.Value;
+ if (text?.TrimStart().StartsWith("{") == true || text?.TrimStart().StartsWith("[") == true)
+ try { property.Value = JToken.Parse(text); } catch (JsonException) { }
+ }
+ Expand(property.Value, depth + 1);
+ }
+ else if (token is JArray array) foreach (var item in array) Expand(item, depth + 1);
+ }
+ Expand(content, 0); return content;
+ }
+
+ public static List Fields(JObject content)
+ {
+ var fields = new List();
+ void Walk(JToken token, string path)
+ {
+ if (token is JObject obj) foreach (var p in obj.Properties()) Walk(p.Value, path + "/" + Escape(p.Name));
+ else if (token is JArray array) for (var i = 0; i < array.Count; i++) Walk(array[i], path + "/" + i);
+ else if (token.Type != JTokenType.Null) fields.Add(new RmsDisclosureFieldValue { Path = path, Value = token.ToString() });
+ }
+ Walk(content, ""); return fields;
+ }
+
+ public static void Apply(JObject content, string recordId, IEnumerable decisions, List log)
+ {
+ var selected = (decisions ?? Enumerable.Empty()).Where(d => d.Withhold).ToList();
+ if (selected.Select(d => d.Path).Distinct(StringComparer.Ordinal).Count() != selected.Count) throw new ArgumentException("A field has duplicate redaction decisions.");
+ // Resolve and validate every decision before changing anything; a stale path cannot leave a partial redaction.
+ var resolved = selected.Select(d =>
+ {
+ if (string.IsNullOrWhiteSpace(d.Authority) || string.IsNullOrWhiteSpace(d.Basis)) throw new ArgumentException("Record the applicable authority and reason for each redaction.");
+ var token = Resolve(content, d.Path) ?? throw new ArgumentException("A selected field no longer exists. Reload the reviewed revision.");
+ return (Decision: d, Token: token);
+ }).ToList();
+ foreach (var entry in resolved.OrderByDescending(e => e.Decision.Path.Length))
+ {
+ entry.Token.Replace(new JValue("[WITHHELD]"));
+ log.Add(new RmsRedactionEntry { RecordId = recordId, Section = "Record", Field = entry.Decision.Path, Authority = entry.Decision.Authority.Trim(), Basis = entry.Decision.Basis.Trim() });
+ }
+ }
+
+ private static JToken Resolve(JToken root, string path)
+ {
+ if (string.IsNullOrEmpty(path) || !path.StartsWith("/", StringComparison.Ordinal) || path.Length > 2048) throw new ArgumentException("A redaction must name an exact field path.");
+ JToken current = root;
+ foreach (var encoded in path.Substring(1).Split('/'))
+ {
+ for (var i = 0; i < encoded.Length; i++) if (encoded[i] == '~' && (++i >= encoded.Length || encoded[i] != '0' && encoded[i] != '1')) throw new ArgumentException("The redaction path is invalid.");
+ var part = encoded.Replace("~1", "/").Replace("~0", "~");
+ if (current is JObject obj) current = obj.Property(part, StringComparison.Ordinal)?.Value;
+ else if (current is JArray array && int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var index) && index >= 0 && index < array.Count && part == index.ToString(CultureInfo.InvariantCulture)) current = array[index];
+ else return null;
+ if (current == null) return null;
+ }
+ return current;
+ }
+ private static string Escape(string key) => key.Replace("~", "~0").Replace("/", "~1");
+ }
+}
diff --git a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs
index f26ca2405..69d15b58d 100644
--- a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs
+++ b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs
@@ -32,14 +32,17 @@ internal static class EvidenceLimits
/// Most source rows a single artifact may carry; a manifest is evidence, not an export.
public const int MaxItems = 500;
- public static (DateTime start, DateTime end) ClampWindow(DateTime? start, DateTime? end)
+ // Source IDs remain in the manifest. This bounded identity distinguishes independently selected
+ // evidence; a later page/window must not supersede a different selection from the same subsystem.
+ public static string SelectionIdentity(object selection) => "selection-sha256:" +
+ RecordSnapshotSerializer.Checksum(RecordsEvidenceService.Serialize(selection));
+
+ public static (DateTime start, DateTime end) RequireWindow(DateTime? start, DateTime? end)
{
var to = end ?? DateTime.UtcNow;
var from = start ?? to - MaxCoverage;
- if (to < from)
- (from, to) = (to, from);
- if (to - from > MaxCoverage)
- from = to - MaxCoverage;
+ if (to < from || to - from > MaxCoverage)
+ throw new ArgumentException("Choose a tracking window in chronological order, at most 24 hours. Capture additional windows separately.");
return (from, to);
}
}
@@ -99,10 +102,11 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque
return RecordEvidenceCapture.Unavailable("Run card evidence needs the Call the record hangs off.");
var activations = (await _activations.GetActivationsByCallIdAsync(request.CallId.Value))?
- .Where(a => a != null && a.DepartmentId == request.DepartmentId)
+ .Where(a => a != null && a.DepartmentId == request.DepartmentId && a.CallId == request.CallId)
.OrderBy(a => a.CreatedOn)
- .Take(EvidenceLimits.MaxItems)
+ .Take(EvidenceLimits.MaxItems + 1)
.ToList() ?? new List();
+ if (activations.Count > EvidenceLimits.MaxItems) throw new ArgumentException("The Call has too many activations for one evidence capture.");
if (activations.Count == 0)
return RecordEvidenceCapture.Unavailable("No run card activation was recorded for this call.");
@@ -117,7 +121,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque
activated_on = a.CreatedOn,
activated_by_user_id = a.CreatedByUserId,
// The card's own recorded outcome, verbatim: what it selected and any shortfall it reported.
- result = TryParse(a.ResultJson)
+ result = ParseRecordedResult(a.ResultJson)
}).ToList();
return new RecordEvidenceCapture
@@ -125,7 +129,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque
Title = $"Run card activation for call {request.CallId.Value}",
SourceSubsystem = SourceSubsystem,
SourceEntityType = nameof(RunCardActivation),
- SourceEntityId = string.Join(",", activations.Select(a => a.RunCardActivationId)),
+ SourceEntityId = EvidenceLimits.SelectionIdentity(activations.Select(a => a.RunCardActivationId).OrderBy(x => x).ToArray()),
IdentifierScheme = IdentifierScheme,
CoverageStart = activations.First().CreatedOn,
CoverageEnd = activations.Last().CreatedOn,
@@ -135,13 +139,13 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque
};
}
- private static JToken TryParse(string json)
+ private static JToken ParseRecordedResult(string json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try { return JToken.Parse(json); }
- catch (JsonReaderException) { return null; }
+ catch (JsonReaderException ex) { throw new InvalidOperationException("A recorded Run Card decision is unreadable; repair the source before capturing evidence.", ex); }
}
}
@@ -163,10 +167,13 @@ public class TrackingFixEvidenceAdapter : IRecordEvidenceAdapter
public const int SamplesPerUnit = 24;
private readonly IUnitLocationRepository _locations;
+ private readonly IUnitsService _units;
+ private readonly Lazy _authorization;
- public TrackingFixEvidenceAdapter(IUnitLocationRepository locations)
+ public TrackingFixEvidenceAdapter(IUnitLocationRepository locations, IUnitsService units, Lazy authorization)
{
_locations = locations;
+ _units = units; _authorization = authorization;
}
public RmsEvidenceKind Kind => RmsEvidenceKind.TrackingFix;
@@ -176,10 +183,11 @@ public TrackingFixEvidenceAdapter(IUnitLocationRepository locations)
public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default)
{
var units = (request.UnitIds ?? new List()).Where(u => u > 0).Distinct().ToList();
+ if (units.Count > EvidenceLimits.MaxItems / SamplesPerUnit) throw new ArgumentException("Select at most " + EvidenceLimits.MaxItems / SamplesPerUnit + " units per tracking capture.");
if (units.Count == 0)
return RecordEvidenceCapture.Unavailable("Tracking evidence needs at least one unit.");
- var (from, to) = EvidenceLimits.ClampWindow(request.CoverageStart, request.CoverageEnd);
+ var (from, to) = EvidenceLimits.RequireWindow(request.CoverageStart, request.CoverageEnd);
var capturedOn = DateTime.UtcNow;
var step = (to - from).TotalSeconds / Math.Max(1, SamplesPerUnit - 1);
var manifestUnits = new List