diff --git a/apps/web/lib/catalogue-sync/persist-source-version.ts b/apps/web/lib/catalogue-sync/persist-source-version.ts
index 9454c963..c395e896 100644
--- a/apps/web/lib/catalogue-sync/persist-source-version.ts
+++ b/apps/web/lib/catalogue-sync/persist-source-version.ts
@@ -418,7 +418,10 @@ async function insertRequirements(
}
}
-/** Writes every content, requirement and evidence row for a new snapshot. */
+/**
+ * Writes every content, requirement, evidence and review-flag row for a new
+ * snapshot.
+ */
export async function insertVersionContent(
tx: Tx,
{
@@ -469,6 +472,16 @@ export async function insertVersionContent(
)
`;
}
+ for (const [index, flag] of write.flags.entries()) {
+ await tx`
+ insert into public.catalogue_version_flags (
+ version_id, position, field_path, severity, code, message
+ ) values (
+ ${snapshotId}, ${index + 1}, ${flag.fieldPath}, ${flag.severity},
+ ${flag.code}, ${flag.message}
+ )
+ `;
+ }
}
/** Persists one semantic ANU observation without changing local content. */
diff --git a/apps/web/lib/catalogue/review-notes.ts b/apps/web/lib/catalogue/review-notes.ts
new file mode 100644
index 00000000..f5cc74df
--- /dev/null
+++ b/apps/web/lib/catalogue/review-notes.ts
@@ -0,0 +1,143 @@
+/** One note the model left on a source version. */
+export type VersionFlag = {
+ fieldPath: string | null;
+ severity: "warning" | "error";
+ code: string;
+ message: string;
+};
+
+/** The model's evidence for one field, and how directly the page states it. */
+export type VersionEvidence = {
+ fieldPath: string;
+ confidence: number | null;
+ excerpt: string | null;
+};
+
+export type ReviewNote = {
+ fieldPath: string | null;
+ label: string;
+ message: string;
+};
+
+export type UncertainField = {
+ fieldPath: string;
+ label: string;
+ confidence: number;
+ excerpt: string | null;
+};
+
+/** Below this, a field is worth reading against the ANU page. */
+export const UNCERTAIN_CONFIDENCE = 0.8;
+
+const FIELD_NAMES: Record = {
+ modelExtraction: "The whole response",
+ prerequisiteRule: "Prerequisite rule",
+ corequisiteRule: "Corequisite rule",
+ prerequisiteText: "Prerequisite wording",
+ corequisiteText: "Corequisite wording",
+ incompatibilityText: "Incompatibility wording",
+ incompatibilityCourseCodes: "Incompatible courses",
+ softIncompatibilityCourseCodes: "Advisory incompatibilities",
+ unmodelledText: "Unmodelled requirement wording",
+ unitValue: "Units",
+ eftsl: "EFTSL",
+ totalUnits: "Units",
+ durationYears: "Length",
+ selectionRank: "Selection rank",
+ atar: "ATAR",
+ learningOutcomes: "Learning outcomes",
+ assessmentItems: "Assessment",
+ offerings: "Offerings",
+ summaryFields: "Key facts",
+ relationships: "Related structures",
+ requirements: "Requirements",
+ rule: "Requirement tree",
+ sourceUpdatedAt: "ANU update date",
+ areasOfInterest: "Areas of interest",
+ relatedCourses: "Related courses",
+ contactText: "Contact",
+ convenerText: "Convener",
+};
+
+function humanise(segment: string) {
+ const words = segment.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+/**
+ * A field path from the model's extraction, such as `requisites.prerequisiteRule`,
+ * `fees[2]` or `requirements.rule.children.3`, in words an administrator reads.
+ * The section a path belongs to leads; an array index becomes an item number
+ * and a requirement branch keeps its position.
+ */
+export function modelFieldLabel(fieldPath: string | null) {
+ if (!fieldPath) return "The record";
+ const segments = fieldPath.split(/[.[\]]/).filter(Boolean);
+ const names: string[] = [];
+ const branch: number[] = [];
+ let inRequirementTree = false;
+ for (const segment of segments) {
+ if (/^\d+$/.test(segment)) {
+ if (inRequirementTree) branch.push(Number(segment) + 1);
+ else names.push(`item ${Number(segment) + 1}`);
+ continue;
+ }
+ if (segment === "rule" && names.at(-1) === FIELD_NAMES.requirements) {
+ inRequirementTree = true;
+ continue;
+ }
+ if (segment === "children" || segment === "requisites") continue;
+ names.push(FIELD_NAMES[segment] ?? humanise(segment));
+ }
+ // Sentence case: only the first name keeps its capital, unless a later one
+ // is an initialism such as ATAR.
+ const label = names
+ .map((name, index) =>
+ index > 0 && /^[A-Z][a-z]/.test(name)
+ ? name.charAt(0).toLowerCase() + name.slice(1)
+ : name,
+ )
+ .join(", ");
+ return branch.length ? `${label}, branch ${branch.join(".")}` : label;
+}
+
+/**
+ * What the Changes tab asks an administrator to check: every error, every
+ * warning, and each field whose weakest evidence falls below
+ * `UNCERTAIN_CONFIDENCE`, least certain first.
+ */
+export function summariseReviewNotes({
+ flags,
+ evidence,
+}: {
+ flags: readonly VersionFlag[];
+ evidence: readonly VersionEvidence[];
+}) {
+ const note = (flag: VersionFlag): ReviewNote => ({
+ fieldPath: flag.fieldPath,
+ label: modelFieldLabel(flag.fieldPath),
+ message: flag.message,
+ });
+ const weakest = new Map();
+ for (const item of evidence) {
+ if (item.confidence === null || item.confidence >= UNCERTAIN_CONFIDENCE) {
+ continue;
+ }
+ const current = weakest.get(item.fieldPath);
+ if (!current || item.confidence < current.confidence) {
+ weakest.set(item.fieldPath, {
+ fieldPath: item.fieldPath,
+ label: modelFieldLabel(item.fieldPath),
+ confidence: item.confidence,
+ excerpt: item.excerpt,
+ });
+ }
+ }
+ return {
+ errors: flags.filter(({ severity }) => severity === "error").map(note),
+ warnings: flags.filter(({ severity }) => severity === "warning").map(note),
+ uncertain: [...weakest.values()].sort(
+ (left, right) => left.confidence - right.confidence,
+ ),
+ };
+}
diff --git a/apps/web/lib/coursemap/admin-catalogue-record.ts b/apps/web/lib/coursemap/admin-catalogue-record.ts
index e7a2ebe1..c358d55c 100644
--- a/apps/web/lib/coursemap/admin-catalogue-record.ts
+++ b/apps/web/lib/coursemap/admin-catalogue-record.ts
@@ -1,4 +1,8 @@
import "server-only";
+import type {
+ VersionEvidence,
+ VersionFlag,
+} from "@/lib/catalogue/review-notes";
import { readVersionContent } from "@/lib/catalogue-import/version-content";
import { withSyncDatabaseClient } from "@/lib/catalogue-sync/sync-store";
import type { CatalogueContent } from "@/lib/catalogue/content";
@@ -211,6 +215,46 @@ export async function loadVersionWrite(
return withSyncDatabaseClient((sql) => readVersionContent(sql, versionId));
}
+/**
+ * The flags and model evidence stored with a source version, for the Changes
+ * tab. Empty for a version an administrator published, which has neither.
+ */
+export async function loadVersionReviewNotes(versionId: number): Promise<{
+ flags: VersionFlag[];
+ evidence: VersionEvidence[];
+}> {
+ return withSyncDatabaseClient(async (sql) => {
+ const [flags, evidence] = await Promise.all([
+ sql`
+ select field_path, severity, code, message
+ from public.catalogue_version_flags
+ where version_id = ${versionId}
+ order by position
+ `,
+ sql`
+ select field_path, confidence, source_excerpt
+ from public.catalogue_version_provenance
+ where version_id = ${versionId} and method = 'model'
+ order by id
+ `,
+ ]);
+ return {
+ flags: flags.map((row) => ({
+ fieldPath: row.field_path === null ? null : String(row.field_path),
+ severity: row.severity === "error" ? "error" : "warning",
+ code: String(row.code),
+ message: String(row.message),
+ })),
+ evidence: evidence.map((row) => ({
+ fieldPath: String(row.field_path),
+ confidence: row.confidence === null ? null : Number(row.confidence),
+ excerpt:
+ row.source_excerpt === null ? null : String(row.source_excerpt),
+ })),
+ };
+ });
+}
+
/** The student-facing course details for a version, or null for structures. */
export async function loadVersionCoursePreview(versionId: number) {
const supabase = await createClient();
diff --git a/apps/web/tests/catalogue-changes-panel.test.tsx b/apps/web/tests/catalogue-changes-panel.test.tsx
index 7750e041..2eb49132 100644
--- a/apps/web/tests/catalogue-changes-panel.test.tsx
+++ b/apps/web/tests/catalogue-changes-panel.test.tsx
@@ -5,6 +5,7 @@ import type {
SourceReview,
SourceReviewChange,
} from "@/lib/catalogue/source-review-store";
+import { summariseReviewNotes } from "@/lib/catalogue/review-notes";
import { CatalogueChangesPanel } from "@/ui/admin/catalogue/changes/changes-panel";
const actions = vi.hoisted(() => ({ resolve: vi.fn() }));
@@ -157,3 +158,53 @@ test("kept values stay available without nagging", () => {
).toBeTruthy();
expect(screen.queryByRole("button", { name: "Keep current" })).toBeNull();
});
+
+test("what the model flagged leads the tab, least certain field first", () => {
+ renderPanel({
+ review: review({ incoming: [change()] }),
+ notes: summariseReviewNotes({
+ flags: [
+ {
+ fieldPath: "requisites.prerequisiteRule",
+ severity: "error",
+ code: "INVALID",
+ message: "The rule named a course code ANU does not use.",
+ },
+ {
+ fieldPath: "requirements.rule.children.3",
+ severity: "warning",
+ code: "AMBIGUOUS",
+ message: "Kept as the page's wording.",
+ },
+ ],
+ evidence: [
+ { fieldPath: "fees", confidence: 0.9, excerpt: "$5520" },
+ { fieldPath: "offerings", confidence: 0.55, excerpt: "First Semester" },
+ { fieldPath: "college", confidence: 0.7, excerpt: "ANU College" },
+ ],
+ }),
+ });
+ expect(
+ screen.getByRole("heading", { name: "What to check" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("1 part could not be read and was left empty"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Prerequisite rule:")).toBeInTheDocument();
+ expect(screen.getByText("Requirements, branch 4:")).toBeInTheDocument();
+ const uncertain = screen
+ .getAllByText(/% sure$/u)
+ .map((node) => node.textContent);
+ // Fees are sure enough not to be listed.
+ expect(uncertain).toEqual(["55% sure", "70% sure"]);
+});
+
+test("nothing flagged means no notes section", () => {
+ renderPanel({
+ review: review({ incoming: [change()] }),
+ notes: summariseReviewNotes({ flags: [], evidence: [] }),
+ });
+ expect(
+ screen.queryByRole("heading", { name: "What to check" }),
+ ).not.toBeInTheDocument();
+});
diff --git a/apps/web/tests/review-notes.test.ts b/apps/web/tests/review-notes.test.ts
new file mode 100644
index 00000000..a9569a9e
--- /dev/null
+++ b/apps/web/tests/review-notes.test.ts
@@ -0,0 +1,42 @@
+import { expect, test } from "vitest";
+
+import {
+ modelFieldLabel,
+ summariseReviewNotes,
+} from "@/lib/catalogue/review-notes";
+
+test("model field paths read as the fields an administrator knows", () => {
+ expect(modelFieldLabel("requisites.prerequisiteRule")).toBe(
+ "Prerequisite rule",
+ );
+ expect(modelFieldLabel("fees[2]")).toBe("Fees, item 3");
+ expect(modelFieldLabel("requirements.rule.children.3.children.0")).toBe(
+ "Requirements, branch 4.1",
+ );
+ expect(modelFieldLabel("offerings[1].startsOn")).toBe(
+ "Offerings, item 2, starts on",
+ );
+ expect(modelFieldLabel("atar")).toBe("ATAR");
+ expect(modelFieldLabel("modelExtraction")).toBe("The whole response");
+ expect(modelFieldLabel(null)).toBe("The record");
+});
+
+test("a field is listed once, at its weakest evidence", () => {
+ const { uncertain } = summariseReviewNotes({
+ flags: [],
+ evidence: [
+ { fieldPath: "fees", confidence: 0.6, excerpt: "first" },
+ { fieldPath: "fees", confidence: 0.4, excerpt: "weakest" },
+ { fieldPath: "title", confidence: null, excerpt: null },
+ { fieldPath: "college", confidence: 0.8, excerpt: "at the threshold" },
+ ],
+ });
+ expect(uncertain).toEqual([
+ {
+ fieldPath: "fees",
+ label: "Fees",
+ confidence: 0.4,
+ excerpt: "weakest",
+ },
+ ]);
+});
diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts
index e3909b8e..634bbec4 100644
--- a/apps/web/types/database.ts
+++ b/apps/web/types/database.ts
@@ -2039,6 +2039,51 @@ export type Database = {
},
]
}
+ catalogue_version_flags: {
+ Row: {
+ code: string
+ field_path: string | null
+ id: number
+ message: string
+ position: number
+ severity: string
+ version_id: number
+ }
+ Insert: {
+ code: string
+ field_path?: string | null
+ id?: never
+ message: string
+ position: number
+ severity: string
+ version_id: number
+ }
+ Update: {
+ code?: string
+ field_path?: string | null
+ id?: never
+ message?: string
+ position?: number
+ severity?: string
+ version_id?: number
+ }
+ Relationships: [
+ {
+ foreignKeyName: "catalogue_version_flags_version_id_fkey"
+ columns: ["version_id"]
+ isOneToOne: false
+ referencedRelation: "catalogue_versions"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "catalogue_version_flags_version_id_fkey"
+ columns: ["version_id"]
+ isOneToOne: false
+ referencedRelation: "published_course_summaries"
+ referencedColumns: ["version_id"]
+ },
+ ]
+ }
catalogue_version_provenance: {
Row: {
academic_year_id: number
@@ -4272,4 +4317,3 @@ export const Constants = {
Enums: {},
},
} as const
-
diff --git a/apps/web/ui/admin/catalogue/changes/changes-panel.tsx b/apps/web/ui/admin/catalogue/changes/changes-panel.tsx
index 590d21b0..2f73ac74 100644
--- a/apps/web/ui/admin/catalogue/changes/changes-panel.tsx
+++ b/apps/web/ui/admin/catalogue/changes/changes-panel.tsx
@@ -1,8 +1,10 @@
import Link from "next/link";
import type { SnapshotChange } from "@/lib/catalogue-import/changes";
+import type { summariseReviewNotes } from "@/lib/catalogue/review-notes";
import type { SourceReview } from "@/lib/catalogue/source-review-store";
import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty";
+import { ModelNotes } from "./model-notes";
import { SourceChangeCard } from "./source-change-card";
import { UnpublishedChanges } from "./unpublished-changes";
@@ -72,6 +74,7 @@ export function CatalogueChangesPanel({
isPublished,
kindLabel,
latestSync = null,
+ notes = null,
}: {
review: SourceReview | null;
unpublished: SnapshotChange[];
@@ -83,6 +86,8 @@ export function CatalogueChangesPanel({
kindLabel: string;
/** The check these changes came out of, for readers allowed to open it. */
latestSync?: { id: string; completedAt: string | null } | null;
+ /** What the model flagged on the latest ANU version. */
+ notes?: ReturnType | null;
}) {
const conflicts = review?.conflicts ?? [];
const incoming = review?.incoming ?? [];
@@ -117,6 +122,7 @@ export function CatalogueChangesPanel({
) : null}
+ {notes ? : null}
{conflicts.length === 0 && incoming.length === 0 ? (
) : null}
diff --git a/apps/web/ui/admin/catalogue/changes/model-notes.tsx b/apps/web/ui/admin/catalogue/changes/model-notes.tsx
new file mode 100644
index 00000000..17cc30ac
--- /dev/null
+++ b/apps/web/ui/admin/catalogue/changes/model-notes.tsx
@@ -0,0 +1,107 @@
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from "@coursemap/ui/components/alert";
+import { Badge } from "@coursemap/ui/components/badge";
+import { CircleAlert, Gauge, TriangleAlert } from "lucide-react";
+import type { ReviewNote, UncertainField } from "@/lib/catalogue/review-notes";
+
+function NoteList({ notes }: { notes: readonly ReviewNote[] }) {
+ return (
+
+ {notes.map((note, index) => (
+ -
+ {note.label}:{" "}
+ {note.message}
+
+ ))}
+
+ );
+}
+
+function plural(count: number, singular: string, pluralForm: string) {
+ return `${count} ${count === 1 ? singular : pluralForm}`;
+}
+
+/**
+ * What the model flagged on the latest ANU check, and the fields it was least
+ * sure of. Review is the only check on a model-owned sync, so this leads the
+ * Changes tab: it says where to look before any change is accepted. Nothing
+ * is shown when the model flagged nothing.
+ */
+export function ModelNotes({
+ errors,
+ warnings,
+ uncertain,
+}: {
+ errors: readonly ReviewNote[];
+ warnings: readonly ReviewNote[];
+ uncertain: readonly UncertainField[];
+}) {
+ if (!errors.length && !warnings.length && !uncertain.length) return null;
+ return (
+
+
+ What to check
+
+ {errors.length ? (
+
+
+
+ {plural(errors.length, "part", "parts")} could not be read and{" "}
+ {errors.length === 1 ? "was" : "were"} left empty
+
+
+
+
+
+ ) : null}
+ {warnings.length ? (
+
+
+
+ {plural(warnings.length, "thing", "things")} to compare with the ANU
+ page
+
+
+
+
+
+ ) : null}
+ {uncertain.length ? (
+
+
+ Least certain fields
+
+
+ {uncertain.map((field) => (
+ -
+
+
+ {field.label}
+
+
+ {Math.round(field.confidence * 100)}% sure
+
+
+ {field.excerpt ? (
+
+ {field.excerpt}
+
+ ) : null}
+
+ ))}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx
index a88762b8..8f58ea33 100644
--- a/apps/web/ui/admin/catalogue/record-page.tsx
+++ b/apps/web/ui/admin/catalogue/record-page.tsx
@@ -13,8 +13,10 @@ import { loadCatalogueChangelog } from "@/lib/coursemap/admin-catalogue-changelo
import {
loadCatalogueRecord,
loadVersionCoursePreview,
+ loadVersionReviewNotes,
loadVersionWrite,
} from "@/lib/coursemap/admin-catalogue-record";
+import { summariseReviewNotes } from "@/lib/catalogue/review-notes";
import { courseDetailsFromWrite } from "@/lib/coursemap/course-version-view";
import {
ADMIN_CATALOGUE_OPERATIONS_PATH,
@@ -106,7 +108,14 @@ export async function CatalogueRecordPage({
const publishedPreview = studentContent
? { course: studentCourse, content: studentCourse ? null : studentContent }
: null;
- const review = await loadSourceReview(record.recordId, draft.content);
+ const [review, notes] = await Promise.all([
+ loadSourceReview(record.recordId, draft.content),
+ record.latestSourceVersionId
+ ? loadVersionReviewNotes(record.latestSourceVersionId).then(
+ summariseReviewNotes,
+ )
+ : null,
+ ]);
const unpublished = hasChanges
? diffSnapshotWrites(studentContent, draft.content)
: [];
@@ -183,6 +192,7 @@ export async function CatalogueRecordPage({
hasEverSynced={record.syncs.length > 0}
isPublished={record.publishedVersionId !== null}
kindLabel={labels.singular.toLowerCase()}
+ notes={notes}
latestSync={
canManageImports && record.syncs[0]
? {
diff --git a/supabase/migrations/011_catalogue_version_flags.sql b/supabase/migrations/011_catalogue_version_flags.sql
new file mode 100644
index 00000000..647caa9e
--- /dev/null
+++ b/supabase/migrations/011_catalogue_version_flags.sql
@@ -0,0 +1,42 @@
+-- The review notes the model left on a source version: parts of its response
+-- that did not fit the contract, wording the ANU page does not contain, and
+-- requirement branches it kept as text. They are stored with the version
+-- they describe, sealed with it, and read on the record's Changes tab. They
+-- are not content, so they take no part in the version's content hash.
+
+create table public.catalogue_version_flags (
+ id bigint generated always as identity primary key,
+ version_id bigint not null
+ references public.catalogue_versions (id) on delete cascade,
+ position integer not null,
+ field_path text,
+ severity text not null,
+ code text not null,
+ message text not null,
+ constraint catalogue_version_flags_position_check check (position > 0),
+ constraint catalogue_version_flags_severity_check check (
+ severity = any (array['warning'::text, 'error'::text])
+ ),
+ constraint catalogue_version_flags_code_check check (btrim(code) <> ''),
+ constraint catalogue_version_flags_message_check check (btrim(message) <> ''),
+ constraint catalogue_version_flags_position_unique unique (version_id, position)
+);
+
+create trigger catalogue_version_flags_guard_sealed
+ before insert or delete or update on public.catalogue_version_flags
+ for each row execute function private.guard_snapshot_child_mutation();
+
+alter table public.catalogue_version_flags enable row level security;
+
+create policy catalogue_version_flags_read on public.catalogue_version_flags
+ for select to authenticated
+ using ((select private.can_read_catalogue_drafts() as can_read_catalogue_drafts));
+
+revoke all on table public.catalogue_version_flags
+ from public, anon, authenticated, service_role;
+revoke all on sequence public.catalogue_version_flags_id_seq
+ from public, anon, authenticated, service_role;
+
+grant all on table public.catalogue_version_flags to service_role;
+grant select on table public.catalogue_version_flags to authenticated;
+grant all on sequence public.catalogue_version_flags_id_seq to service_role;
diff --git a/supabase/tests/database/catalogue_version_flags.sql b/supabase/tests/database/catalogue_version_flags.sql
new file mode 100644
index 00000000..6dd90511
--- /dev/null
+++ b/supabase/tests/database/catalogue_version_flags.sql
@@ -0,0 +1,70 @@
+-- The model's review notes on a source version are for the people who review
+-- catalogue changes. No end-user role writes them, anonymous readers never
+-- see them, and they are sealed with the version they describe.
+
+begin;
+
+create extension if not exists pgtap with schema extensions;
+
+select extensions.plan(8);
+
+select extensions.has_table(
+ 'public', 'catalogue_version_flags', 'model review notes are stored'
+);
+
+select extensions.ok(
+ (
+ select relrowsecurity from pg_class
+ where oid = 'public.catalogue_version_flags'::regclass
+ ),
+ 'row level security is enabled on model review notes'
+);
+
+select extensions.ok(
+ not has_table_privilege(
+ 'authenticated', 'public.catalogue_version_flags', 'insert, update, delete'
+ ),
+ 'authenticated clients cannot write model review notes'
+);
+
+select extensions.ok(
+ has_table_privilege('authenticated', 'public.catalogue_version_flags', 'select'),
+ 'authenticated clients may read notes their policy allows'
+);
+
+select extensions.ok(
+ not has_table_privilege('anon', 'public.catalogue_version_flags', 'select'),
+ 'anonymous readers cannot see model review notes'
+);
+
+select extensions.is(
+ (
+ select array_agg(policyname::text order by policyname)
+ from pg_policies
+ where schemaname = 'public' and tablename = 'catalogue_version_flags'
+ ),
+ array['catalogue_version_flags_read'],
+ 'the only policy is the catalogue reviewers read policy'
+);
+
+select extensions.ok(
+ exists (
+ select 1 from pg_trigger
+ where tgrelid = 'public.catalogue_version_flags'::regclass
+ and tgname = 'catalogue_version_flags_guard_sealed'
+ ),
+ 'notes are sealed with their version'
+);
+
+select extensions.ok(
+ exists (
+ select 1 from pg_constraint
+ where conrelid = 'public.catalogue_version_flags'::regclass
+ and conname = 'catalogue_version_flags_severity_check'
+ ),
+ 'a note is either a warning or an error'
+);
+
+select * from extensions.finish();
+
+rollback;