Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions docs/samples/MIGRATION_VERIFICATION_REPORT_SAMPLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

| | |
|---|---|
| Run started (UTC) | 2026-11-09T09:07:53+00:00 |
| Run finished (UTC) | 2026-11-09T09:07:54+00:00 |
| Code version | `01602c18eb` (clean working tree) |
| Run started (UTC) | 2026-11-10T05:55:31+00:00 |
| Run finished (UTC) | 2026-11-10T05:55:32+00:00 |
| Code version | `f25c94f2d6` (clean working tree) |
| Database | PostgreSQL 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1) |
| Platform | Python 3.13.18, Linux-6.18.44-fc-v310-x86_64-with-glibc2.39 |
| Platform | Python 3.13.18, Linux-6.18.44-fc-v338-x86_64-with-glibc2.39 |

## 1. Result

**RECONCILED**: 12 of 12 checks passed. Of 10 source rows in the T&E file, **6 were loaded and 4 were rejected by the database**, for the reasons in section 4; nothing was lost or silently altered. The 8-row dynamic file loaded in full.
**RECONCILED**: 13 of 13 checks passed. Of 10 source rows in the T&E file, **6 were loaded and 4 were rejected by the database**, for the reasons in section 4; nothing was lost or silently altered. The 8-row dynamic file loaded in full.

## 2. Source files

Expand All @@ -37,8 +37,9 @@ Each check compares the system's output against the database itself, a hash comp
| 8 | Dynamic: file hash recorded by the system equals an independently computed SHA-256 | 14fab9c3bfb55400… | PASS |
| 9 | Dynamic: re-upload of the same file is refused | duplicate_file | PASS |
| 10 | Dynamic: the same content under a new filename is refused | duplicate_file (reason: content) | PASS |
| 11 | Audit: one entry per successful load; refused duplicates leave none | 2 entries for 2 loads | PASS |
| 11 | Audit: one entry per successful load; refused or fully failed uploads leave none | 2 entries for 2 loads | PASS |
| 12 | Audit: hash chain recomputed and intact | intact=True, rows checked=2 | PASS |
| 13 | T&E: re-uploading the same file, where every row now fails, is refused and changes nothing | status error; registry (id, loaded, failed) [[1, 6, 4]] vs before [[1, 6, 4]]; database rows 6 | PASS |

## 4. Rejected rows (T&E file)

Expand Down Expand Up @@ -66,7 +67,7 @@ Read back from `tenant_dev.organisations` after the load (6 rows):

## 6. Dynamic mode and duplicate handling

`equipment_register.csv` created table `csv_14fab9c3bfb55400` with 8 rows. Column types detected:
`equipment_register.csv` created table `csv_equipment_register_v1` with 8 rows. Column types detected:

| Column | PostgreSQL type |
|---|---|
Expand All @@ -84,19 +85,19 @@ Re-uploading the same file returned `duplicate_file` (reason: `content`); upload

| Action | File | Target | Mode | Actor | Time (UTC) |
|---|---|---|---|---|---|
| upload | equipment_register.csv | csv_14fab9c3bfb55400 | dynamic | admin | 2026-11-09T09:07:54.050344+00:00 |
| upload | orgs_sample.csv | tenant_dev.organisations | te | admin | 2026-11-09T09:07:54.013017+00:00 |
| upload | equipment_register.csv | csv_equipment_register_v1 | dynamic | admin | 2026-11-10T05:55:32.044474+00:00 |
| upload | orgs_sample.csv | tenant_dev.organisations | te | admin | 2026-11-10T05:55:31.982480+00:00 |

## 8. What this report does and does not show

**Shows:** for these two files, every source row is either in the database or rejected with the database's own reason; loaded values equal source values; file hashes are recorded correctly; duplicates are refused; the audit trail has one entry per successful load and its hash chain verifies.
**Shows:** for these two files, every source row is either in the database or rejected with the database's own reason; loaded values equal source values; file hashes are recorded correctly; duplicates are refused; a re-upload that fails every row changes nothing; the audit trail has one entry per successful load and its hash chain verifies.

**Does not show:**

- Behaviour on real or large data. The inputs are 10 and 8 invented rows.
- A hardened deployment. Observed during this run: a request with no credentials at all returned HTTP **200** (the API key is not set, so unauthenticated access is open); the API's database connections used role(s) **postgres** and the audit table is owned by **postgres**, so the database itself would not stop the API altering the audit log. The audit chain is designed to reveal such changes, but this run did not test that.
- Independent assurance. The checks were written and run by the system's developers. They are repeatable by a reviewer, not a third-party assessment.
- Conformance to any Defence standard. No claim of alignment with ASDEFCON or any DID is made here.
- That re-uploading a file is safe. The re-upload case for T&E mode has a known defect, tracked as #215.
- Re-upload behaviour beyond check 13. Only one case was exercised: re-uploading an identical file, where every row fails. Overwriting a file with changed content, and concurrent uploads, were not exercised here.

**Reproduce:** deploy the dev schema to an empty database, create a tenant admin, start the API, then run `python scripts/sample_verification_run.py`. The raw responses behind this report are in `docs/samples/verification-run-evidence.json`.
43 changes: 31 additions & 12 deletions docs/samples/verification-run-evidence.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"started_utc": "2026-11-09T09:07:53+00:00",
"started_utc": "2026-11-10T05:55:31+00:00",
"health": {
"status": "ok",
"database": "te_mgmt_dev",
Expand All @@ -8,10 +8,10 @@
"uploads_schema": "csv_uploads"
},
"environment": {
"code_commit": "01602c18eb10e4888fd9debb0c4d7fd7c573c008",
"code_commit": "f25c94f2d64d7ae2a0d31e52e99fa216672b8ce4",
"tracked_files_modified": false,
"python": "3.13.18",
"os": "Linux-6.18.44-fc-v310-x86_64-with-glibc2.39"
"os": "Linux-6.18.44-fc-v338-x86_64-with-glibc2.39"
},
"te": {
"file": "orgs_sample.csv",
Expand Down Expand Up @@ -118,7 +118,8 @@
"file_hash": "593c2d41f415f178193b97643b7a5beb373f1c490d58b46ad27cf8ca3ec0a09b",
"row_count": 6,
"total_rows": 10,
"failed_rows": 4
"failed_rows": 4,
"id": 1
},
"expected_rejects": {
"6": "duplicate name (unique constraint)",
Expand Down Expand Up @@ -155,7 +156,7 @@
"response": {
"status": "ok",
"fileId": "2",
"tableName": "csv_14fab9c3bfb55400",
"tableName": "csv_equipment_register_v1",
"totalRows": 8,
"insertedRows": 8,
"duplicateRowsSkipped": 0,
Expand All @@ -174,13 +175,19 @@
"date",
"int8"
],
"typesProvenance": "inferred_unconfirmed",
"rowErrors": [],
"dataset": "equipment_register",
"version": 1,
"schemaCheck": null,
"duplicateRowsAcrossUploads": 0,
"crossUploadDuplicates": [],
"overwritten": false,
"replacedFileName": null,
"uploadId": "2"
}
},
"table": "csv_14fab9c3bfb55400",
"table": "csv_equipment_register_v1",
"db_row_count": 8,
"db_columns": [
[
Expand Down Expand Up @@ -209,19 +216,31 @@
"status": "duplicate_file",
"reason": "content",
"existingFileName": "equipment_register.csv",
"tableName": "csv_14fab9c3bfb55400",
"tableName": "csv_equipment_register_v1",
"existingRowCount": 8,
"uploadId": "3"
},
"duplicate_renamed": {
"status": "duplicate_file",
"reason": "content",
"existingFileName": "equipment_register.csv",
"tableName": "csv_14fab9c3bfb55400",
"tableName": "csv_equipment_register_v1",
"existingRowCount": 8,
"uploadId": "4"
}
},
"te_reupload": {
"status": "error",
"inserted": 0,
"registry_after": [
[
1,
6,
4
]
],
"db_row_count_after": 6
},
"posture": {
"unauthenticated_request_status": 200,
"database_roles_connected": [
Expand All @@ -240,9 +259,9 @@
"action": "upload",
"fileId": 2,
"fileName": "equipment_register.csv",
"tableName": "csv_14fab9c3bfb55400",
"tableName": "csv_equipment_register_v1",
"mode": "dynamic",
"performedAt": "2026-11-09T09:07:54.050344+00:00",
"performedAt": "2026-11-10T05:55:32.044474+00:00",
"actor": "admin"
},
{
Expand All @@ -252,7 +271,7 @@
"fileName": "orgs_sample.csv",
"tableName": "tenant_dev.organisations",
"mode": "te",
"performedAt": "2026-11-09T09:07:54.013017+00:00",
"performedAt": "2026-11-10T05:55:31.982480+00:00",
"actor": "admin"
}
]
Expand All @@ -263,5 +282,5 @@
"firstBadId": null
}
},
"finished_utc": "2026-11-09T09:07:54+00:00"
"finished_utc": "2026-11-10T05:55:32+00:00"
}
26 changes: 21 additions & 5 deletions scripts/sample_verification_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,15 @@ def collect(api: Api, conn) -> dict:
schema, table = resp["tableName"].split(".")
cur.execute(f'SELECT name, org_type, country FROM "{schema}"."{table}" ORDER BY name')
db_rows = [list(r) for r in cur.fetchall()]
cur.execute(f'SELECT file_hash, row_count, total_rows, failed_rows FROM "{schema}".csv_files WHERE file_name=%s',
cur.execute(f'SELECT file_hash, row_count, total_rows, failed_rows, id FROM "{schema}".csv_files WHERE file_name=%s',
("orgs_sample.csv",))
reg = cur.fetchone()
expected_loaded = sorted(r for i, r in enumerate(src_rows, 1) if i not in EXPECTED_REJECTS)
ev["te"] = {
"file": "orgs_sample.csv", "bytes": len(orgs_bytes), "sha256": hashlib.sha256(orgs_bytes).hexdigest(),
"source_rows": len(src_rows), "source_lines": {i: ",".join(r) for i, r in enumerate(src_rows, 1)},
"api": a, "target": resp["tableName"], "db_row_count": len(db_rows), "db_rows": db_rows,
"registry": {"file_hash": reg[0], "row_count": reg[1], "total_rows": reg[2], "failed_rows": reg[3]},
"registry": {"file_hash": reg[0], "row_count": reg[1], "total_rows": reg[2], "failed_rows": reg[3], "id": reg[4]},
"expected_rejects": EXPECTED_REJECTS,
"rejected_row_numbers": sorted(e["rowNumber"] for e in resp.get("rowErrors", [])),
"loaded_matches_source": sorted(db_rows) == expected_loaded,
Expand All @@ -134,6 +134,14 @@ def collect(api: Api, conn) -> dict:
"duplicate_renamed": upload(api, "equipment_register_copy.csv", eq, "dynamic")["response"],
}

# --- Re-upload of the T&E file (#215): every row now collides, so nothing may change --
again = upload(api, "orgs_sample.csv", orgs, "te", "organisations")["response"]
cur.execute(f'SELECT id, row_count, failed_rows FROM "{schema}".csv_files WHERE file_name=%s', ("orgs_sample.csv",))
after = cur.fetchall()
cur.execute(f'SELECT count(*) FROM "{schema}"."{table}"')
ev["te_reupload"] = {"status": again.get("status"), "inserted": again.get("insertedRows"),
"registry_after": [list(r) for r in after], "db_row_count_after": cur.fetchone()[0]}

# --- Observed deployment posture (read from the running system, not from our env) --
unauth = urllib.request.Request(api.base + "/api/csv/files")
try:
Expand Down Expand Up @@ -188,10 +196,17 @@ def checks(ev: dict) -> list[tuple[str, str, bool]]:
("Dynamic: the same content under a new filename is refused",
f"{dy['duplicate_renamed'].get('status')} (reason: {dy['duplicate_renamed'].get('reason')})",
dy["duplicate_renamed"].get("status") == "duplicate_file" and dy["duplicate_renamed"].get("reason") == "content"),
("Audit: one entry per successful load; refused duplicates leave none",
("Audit: one entry per successful load; refused or fully failed uploads leave none",
f"{au['log']['total']} entries for 2 loads", au["log"]["total"] == 2 and len(entries) == 2),
("Audit: hash chain recomputed and intact",
f"intact={au['verify'].get('intact')}, rows checked={au['verify'].get('rows')}", au["verify"].get("intact") is True),
("T&E: re-uploading the same file, where every row now fails, is refused and changes nothing",
f"status {ev['te_reupload']['status']}; registry (id, loaded, failed) {ev['te_reupload']['registry_after']} "
f"vs before {[[te['registry']['id'], te['registry']['row_count'], te['registry']['failed_rows']]]}; "
f"database rows {ev['te_reupload']['db_row_count_after']}",
ev["te_reupload"]["status"] == "error"
and ev["te_reupload"]["registry_after"] == [[te["registry"]["id"], te["registry"]["row_count"], te["registry"]["failed_rows"]]]
and ev["te_reupload"]["db_row_count_after"] == te["db_row_count"]),
]


Expand Down Expand Up @@ -273,7 +288,7 @@ def render(ev: dict, rows: list[tuple[str, str, bool]]) -> str:
"## 8. What this report does and does not show",
"",
"**Shows:** for these two files, every source row is either in the database or rejected with the database's own reason; "
"loaded values equal source values; file hashes are recorded correctly; duplicates are refused; the audit trail has one entry per successful load and its hash chain verifies.",
"loaded values equal source values; file hashes are recorded correctly; duplicates are refused; a re-upload that fails every row changes nothing; the audit trail has one entry per successful load and its hash chain verifies.",
"",
"**Does not show:**",
"",
Expand All @@ -285,7 +300,8 @@ def render(ev: dict, rows: list[tuple[str, str, bool]]) -> str:
f"{', so the database itself would not stop the API altering the audit log. The audit chain is designed to reveal such changes, but this run did not test that' if po['audit_log_owner'] in po['database_roles_connected'] else ''}.",
"- Independent assurance. The checks were written and run by the system's developers. They are repeatable by a reviewer, not a third-party assessment.",
"- Conformance to any Defence standard. No claim of alignment with ASDEFCON or any DID is made here.",
"- That re-uploading a file is safe. The re-upload case for T&E mode has a known defect, tracked as #215.",
"- Re-upload behaviour beyond check 13. Only one case was exercised: re-uploading an identical file, where every row fails. "
"Overwriting a file with changed content, and concurrent uploads, were not exercised here.",
"",
"**Reproduce:** deploy the dev schema to an empty database, create a tenant admin, start the API, then run "
"`python scripts/sample_verification_run.py`. The raw responses behind this report are in "
Expand Down
Loading