diff --git a/tools/v1/team/email-ownership-tracker/docs/SAFETY_LIMITS.md b/tools/v1/team/email-ownership-tracker/docs/SAFETY_LIMITS.md
new file mode 100644
index 000000000..b78e4deeb
--- /dev/null
+++ b/tools/v1/team/email-ownership-tracker/docs/SAFETY_LIMITS.md
@@ -0,0 +1,54 @@
+# Email Ownership Tracker Safety Limits
+
+## Threat Assumptions
+
+Email Ownership Tracker will eventually receive shared-inbox events, ownership
+updates, attachment metadata, and team member data from the mail product. Until
+an integration issue exists, every external value in this tool folder is treated
+as untrusted:
+
+- event ids and message ids can contain path traversal, URL fragments, HTML, or
+ whitespace that should never become storage keys.
+- owner, actor, and team member emails can contain malformed addresses or CRLF
+ header-injection payloads.
+- owner names, reasons, and tags can contain control characters, HTML-like text,
+ or oversized strings.
+- attachment metadata can be malformed, negative, or inflated to force expensive
+ accounting.
+- ownership histories can contain many stale events from shared mailbox imports.
+
+## Unsafe Input Handling
+
+`guards/ownership-boundaries.mjs` provides pure, synchronous helpers that future
+services or hooks can call before rendering, storage, notifications, or network
+work. The guards:
+
+- reject unsafe ids before they can be used as keys or paths.
+- normalize emails to lowercase and reject CRLF or null-byte payloads.
+- allow only known ownership actions: `claim`, `assign`, `transfer`, `release`,
+ and `escalate`.
+- sanitize primitive display text by removing control characters and HTML-like
+ tags, then truncating to field budgets.
+- validate attachment metadata only; this tool must not inspect attachment file
+ bodies in the isolated folder.
+- require pagination for oversized histories and team member lists.
+
+The guard layer intentionally does not HTML-escape for a specific renderer. Any
+future UI must still escape output according to the rendering framework.
+
+## Performance Limits
+
+Default budgets are deliberately small enough for a V1 launch tool:
+
+| Budget | Default | Reason |
+| ----------------- | ------: | ------------------------------------------------ |
+| Ownership history | 500 | Bounds one-pass current-owner derivation. |
+| Team members | 250 | Prevents scanning large directories in one view. |
+| Attachments | 30 | Keeps metadata checks cheap. |
+| Attachment bytes | 50 MB | Caps aggregate accounting for one event. |
+| Reason text | 1,200 | Avoids rendering imported email threads. |
+| Tags | 20 | Prevents unbounded filters and chips. |
+
+Future integration should run these guards before any inbox read, ownership
+write, notification fanout, background job, or analytics update. Larger
+histories should be paginated and summarized outside the request path.
diff --git a/tools/v1/team/email-ownership-tracker/fixtures/hostile-ownership-inputs.json b/tools/v1/team/email-ownership-tracker/fixtures/hostile-ownership-inputs.json
new file mode 100644
index 000000000..d630d7012
--- /dev/null
+++ b/tools/v1/team/email-ownership-tracker/fixtures/hostile-ownership-inputs.json
@@ -0,0 +1,111 @@
+{
+ "validEvents": [
+ {
+ "eventId": "evt-001",
+ "messageId": "msg-2026-001@example.test",
+ "action": "claim",
+ "actorEmail": "lead@example.test",
+ "ownerEmail": "owner@example.test",
+ "ownerDisplayName": "Case Owner",
+ "createdAt": "2026-07-01T09:00:00.000Z",
+ "reason": "Initial ownership claim for a shared inbox message.",
+ "teamId": "support-team-1",
+ "tags": ["vip", "billing", "vip"],
+ "attachments": [
+ {
+ "name": "contract.pdf",
+ "contentType": "application/pdf",
+ "sizeBytes": 1024
+ }
+ ]
+ },
+ {
+ "eventId": "evt-002",
+ "messageId": "msg-2026-001@example.test",
+ "action": "release",
+ "actorEmail": "owner@example.test",
+ "createdAt": "2026-07-01T10:00:00.000Z",
+ "reason": "Released after triage."
+ }
+ ],
+ "hostileFields": [
+ {
+ "id": "path-like-event-id",
+ "field": "eventId",
+ "value": "../../etc/passwd",
+ "reason": "reject path traversal in event identifiers"
+ },
+ {
+ "id": "angle-message-id",
+ "field": "messageId",
+ "value": "",
+ "reason": "reject HTML-like message identifiers"
+ },
+ {
+ "id": "unsupported-action",
+ "field": "action",
+ "value": "delete-everything",
+ "reason": "reject actions outside the ownership allowlist"
+ },
+ {
+ "id": "header-injection-actor",
+ "field": "actorEmail",
+ "value": "actor@example.test\r\nBcc: victim@example.test",
+ "reason": "reject CRLF header injection in actor email"
+ },
+ {
+ "id": "missing-owner",
+ "field": "ownerEmail",
+ "value": "",
+ "reason": "require an owner email for ownership-setting actions"
+ },
+ {
+ "id": "bad-created-at",
+ "field": "createdAt",
+ "value": "not-a-date",
+ "reason": "reject timestamps that cannot be parsed"
+ },
+ {
+ "id": "unsafe-tag-shape",
+ "field": "tags",
+ "value": ["finance", "../private"],
+ "reason": "reject unsafe tag characters"
+ },
+ {
+ "id": "bad-attachment-size",
+ "field": "attachments",
+ "value": [
+ {
+ "name": "invoice.pdf",
+ "contentType": "application/pdf",
+ "sizeBytes": -10
+ }
+ ],
+ "reason": "reject negative attachment sizes"
+ }
+ ],
+ "textCases": [
+ {
+ "id": "html-display-name",
+ "input": "Alice\u0000 Smith",
+ "expected": "Alice Smith"
+ },
+ {
+ "id": "long-reason",
+ "inputLength": 1400,
+ "expectedLength": 1200
+ }
+ ],
+ "teamMembers": [
+ {
+ "id": "member-1",
+ "email": "Lead@Example.Test",
+ "displayName": "Lead Reviewer"
+ },
+ {
+ "id": "member-2",
+ "email": "backup@example.test",
+ "displayName": "Backup Reviewer"
+ }
+ ]
+}
diff --git a/tools/v1/team/email-ownership-tracker/guards/ownership-boundaries.mjs b/tools/v1/team/email-ownership-tracker/guards/ownership-boundaries.mjs
new file mode 100644
index 000000000..79e5c1019
--- /dev/null
+++ b/tools/v1/team/email-ownership-tracker/guards/ownership-boundaries.mjs
@@ -0,0 +1,351 @@
+const ALLOWED_ACTIONS = new Set(["claim", "assign", "transfer", "release", "escalate"]);
+
+const LIMITS = {
+ MAX_EVENT_ID_LENGTH: 128,
+ MAX_MESSAGE_ID_LENGTH: 160,
+ MAX_OWNER_EMAIL_LENGTH: 254,
+ MAX_ACTOR_EMAIL_LENGTH: 254,
+ MAX_DISPLAY_NAME_LENGTH: 120,
+ MAX_REASON_LENGTH: 1_200,
+ MAX_TEAM_ID_LENGTH: 96,
+ MAX_TAG_COUNT: 20,
+ MAX_TAG_LENGTH: 48,
+ MAX_ATTACHMENT_COUNT: 30,
+ MAX_ATTACHMENT_NAME_LENGTH: 180,
+ MAX_ATTACHMENT_TYPE_LENGTH: 120,
+ MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
+ MAX_HISTORY_EVENTS: 500,
+ MAX_TEAM_MEMBERS: 250,
+};
+
+const ID_PATTERN = /^[A-Za-z0-9._:@-]+$/;
+const EMAIL_PATTERN = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
+const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
+const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g;
+const HTML_TAGS = /<[^>]*>/g;
+
+export class OwnershipBoundaryError extends Error {
+ constructor(message, field) {
+ super(message);
+ this.name = "OwnershipBoundaryError";
+ this.field = field;
+ }
+}
+
+function withLimits(overrides = {}) {
+ return { ...LIMITS, ...overrides };
+}
+
+function isPlainObject(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+export function sanitizeText(value, maxLength, field = "text") {
+ if (value === undefined || value === null) {
+ return "";
+ }
+
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
+ throw new OwnershipBoundaryError(`${field} must be a primitive value`, field);
+ }
+
+ let text = String(value).replace(CONTROL_CHARS, "").replace(HTML_TAGS, "").trim();
+ text = text.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n");
+
+ if (text.length > maxLength) {
+ text = text.slice(0, maxLength);
+ }
+
+ return text;
+}
+
+export function validateTrackerId(value, field = "id", maxLength = LIMITS.MAX_EVENT_ID_LENGTH) {
+ if (typeof value !== "string") {
+ throw new OwnershipBoundaryError(`${field} must be a string`, field);
+ }
+
+ const id = value.trim();
+ if (id.length === 0) {
+ throw new OwnershipBoundaryError(`${field} must not be empty`, field);
+ }
+
+ if (id.length > maxLength) {
+ throw new OwnershipBoundaryError(`${field} exceeds ${maxLength} characters`, field);
+ }
+
+ if (id.includes("..") || /[\\/<>#?%&\s]/.test(id) || !ID_PATTERN.test(id)) {
+ throw new OwnershipBoundaryError(`${field} contains unsafe characters`, field);
+ }
+
+ return id;
+}
+
+export function normalizeEmail(value, field = "email", maxLength = LIMITS.MAX_OWNER_EMAIL_LENGTH) {
+ if (typeof value !== "string") {
+ throw new OwnershipBoundaryError(`${field} must be a string`, field);
+ }
+
+ const email = value.trim().toLowerCase();
+ if (email.length === 0) {
+ throw new OwnershipBoundaryError(`${field} must not be empty`, field);
+ }
+
+ if (email.length > maxLength) {
+ throw new OwnershipBoundaryError(`${field} exceeds ${maxLength} characters`, field);
+ }
+
+ if (/[\r\n\0]/.test(email) || !EMAIL_PATTERN.test(email)) {
+ throw new OwnershipBoundaryError(`${field} is not a safe email address`, field);
+ }
+
+ return email;
+}
+
+export function validateOwnershipAction(value) {
+ if (typeof value !== "string") {
+ throw new OwnershipBoundaryError("action must be a string", "action");
+ }
+
+ const action = value.trim().toLowerCase();
+ if (!ALLOWED_ACTIONS.has(action)) {
+ throw new OwnershipBoundaryError(
+ `action must be one of: ${[...ALLOWED_ACTIONS].join(", ")}`,
+ "action",
+ );
+ }
+
+ return action;
+}
+
+export function normalizeTimestamp(value, field = "createdAt") {
+ if (typeof value !== "string") {
+ throw new OwnershipBoundaryError(`${field} must be an ISO timestamp string`, field);
+ }
+
+ const timestamp = value.trim();
+ const parsed = Date.parse(timestamp);
+ if (!ISO_TIMESTAMP_PATTERN.test(timestamp) || !Number.isFinite(parsed)) {
+ throw new OwnershipBoundaryError(`${field} must be a valid UTC ISO timestamp`, field);
+ }
+
+ return new Date(parsed).toISOString();
+}
+
+export function sanitizeTags(value, limits = LIMITS) {
+ if (value === undefined || value === null) {
+ return [];
+ }
+
+ if (!Array.isArray(value)) {
+ throw new OwnershipBoundaryError("tags must be an array", "tags");
+ }
+
+ if (value.length > limits.MAX_TAG_COUNT) {
+ throw new OwnershipBoundaryError(`tags exceed safe limit of ${limits.MAX_TAG_COUNT}`, "tags");
+ }
+
+ const seen = new Set();
+ const tags = [];
+
+ for (const tag of value) {
+ const clean = sanitizeText(tag, limits.MAX_TAG_LENGTH, "tags").toLowerCase();
+ if (clean.length === 0) {
+ continue;
+ }
+ if (/[\\/<>#?%&]/.test(clean)) {
+ throw new OwnershipBoundaryError("tag contains unsafe characters", "tags");
+ }
+ if (!seen.has(clean)) {
+ seen.add(clean);
+ tags.push(clean);
+ }
+ }
+
+ return tags;
+}
+
+export function sanitizeAttachmentMetadata(value, options = {}) {
+ const limits = withLimits(options);
+
+ if (value === undefined || value === null) {
+ return [];
+ }
+
+ if (!Array.isArray(value)) {
+ throw new OwnershipBoundaryError("attachments must be an array", "attachments");
+ }
+
+ if (value.length > limits.MAX_ATTACHMENT_COUNT) {
+ throw new OwnershipBoundaryError(
+ `attachments exceed safe limit of ${limits.MAX_ATTACHMENT_COUNT}`,
+ "attachments",
+ );
+ }
+
+ let totalBytes = 0;
+ return value.map((attachment, index) => {
+ if (!isPlainObject(attachment)) {
+ throw new OwnershipBoundaryError(`attachment ${index} must be an object`, "attachments");
+ }
+
+ const name = sanitizeText(
+ attachment.name ?? attachment.filename ?? "",
+ limits.MAX_ATTACHMENT_NAME_LENGTH,
+ "attachments.name",
+ );
+ const contentType = sanitizeText(
+ attachment.contentType ?? attachment.type ?? "application/octet-stream",
+ limits.MAX_ATTACHMENT_TYPE_LENGTH,
+ "attachments.contentType",
+ ).toLowerCase();
+ const sizeBytes = Number(attachment.sizeBytes ?? attachment.size ?? 0);
+
+ if (!Number.isInteger(sizeBytes) || sizeBytes < 0) {
+ throw new OwnershipBoundaryError(
+ `attachment ${index} has invalid size`,
+ "attachments.sizeBytes",
+ );
+ }
+
+ totalBytes += sizeBytes;
+ if (totalBytes > limits.MAX_ATTACHMENT_BYTES) {
+ throw new OwnershipBoundaryError(
+ `attachments exceed byte budget of ${limits.MAX_ATTACHMENT_BYTES}`,
+ "attachments.sizeBytes",
+ );
+ }
+
+ return { name, contentType, sizeBytes };
+ });
+}
+
+export function validateTeamMembers(value, options = {}) {
+ const limits = withLimits(options);
+
+ if (!Array.isArray(value)) {
+ throw new OwnershipBoundaryError("teamMembers must be an array", "teamMembers");
+ }
+
+ if (value.length > limits.MAX_TEAM_MEMBERS) {
+ throw new OwnershipBoundaryError(
+ `teamMembers exceed safe limit of ${limits.MAX_TEAM_MEMBERS}`,
+ "teamMembers",
+ );
+ }
+
+ return value.map((member, index) => {
+ if (!isPlainObject(member)) {
+ throw new OwnershipBoundaryError(`team member ${index} must be an object`, "teamMembers");
+ }
+
+ return {
+ id: validateTrackerId(member.id, "teamMembers.id", limits.MAX_TEAM_ID_LENGTH),
+ email: normalizeEmail(member.email, "teamMembers.email"),
+ displayName: sanitizeText(
+ member.displayName ?? member.name ?? "",
+ limits.MAX_DISPLAY_NAME_LENGTH,
+ "teamMembers.displayName",
+ ),
+ };
+ });
+}
+
+export function validateOwnershipEvent(value, options = {}) {
+ const limits = withLimits(options);
+
+ if (!isPlainObject(value)) {
+ throw new OwnershipBoundaryError("ownership event must be an object", "event");
+ }
+
+ const action = validateOwnershipAction(value.action);
+ const normalized = {
+ eventId: validateTrackerId(value.eventId ?? value.id, "eventId", limits.MAX_EVENT_ID_LENGTH),
+ messageId: validateTrackerId(value.messageId, "messageId", limits.MAX_MESSAGE_ID_LENGTH),
+ action,
+ actorEmail: normalizeEmail(value.actorEmail, "actorEmail", limits.MAX_ACTOR_EMAIL_LENGTH),
+ createdAt: normalizeTimestamp(value.createdAt),
+ ownerEmail: null,
+ ownerDisplayName: sanitizeText(
+ value.ownerDisplayName ?? "",
+ limits.MAX_DISPLAY_NAME_LENGTH,
+ "ownerDisplayName",
+ ),
+ reason: sanitizeText(value.reason ?? "", limits.MAX_REASON_LENGTH, "reason"),
+ teamId: value.teamId
+ ? validateTrackerId(value.teamId, "teamId", limits.MAX_TEAM_ID_LENGTH)
+ : null,
+ tags: sanitizeTags(value.tags, limits),
+ attachments: sanitizeAttachmentMetadata(value.attachments, limits),
+ };
+
+ if (action !== "release") {
+ normalized.ownerEmail = normalizeEmail(
+ value.ownerEmail,
+ "ownerEmail",
+ limits.MAX_OWNER_EMAIL_LENGTH,
+ );
+ } else if (
+ value.ownerEmail !== undefined &&
+ value.ownerEmail !== null &&
+ value.ownerEmail !== ""
+ ) {
+ normalized.ownerEmail = normalizeEmail(
+ value.ownerEmail,
+ "ownerEmail",
+ limits.MAX_OWNER_EMAIL_LENGTH,
+ );
+ }
+
+ return normalized;
+}
+
+export function guardOwnershipHistory(value, options = {}) {
+ const limits = withLimits(options);
+
+ if (!Array.isArray(value)) {
+ throw new OwnershipBoundaryError("ownership history must be an array", "history");
+ }
+
+ if (value.length > limits.MAX_HISTORY_EVENTS) {
+ throw new OwnershipBoundaryError(
+ `ownership history exceeds safe limit of ${limits.MAX_HISTORY_EVENTS}`,
+ "history",
+ );
+ }
+
+ return true;
+}
+
+export function prepareOwnershipHistory(value, options = {}) {
+ guardOwnershipHistory(value, options);
+ return value.map((event) => validateOwnershipEvent(event, options));
+}
+
+export function deriveCurrentOwners(value, options = {}) {
+ const events = prepareOwnershipHistory(value, options);
+ const ownersByMessageId = new Map();
+
+ for (const event of events) {
+ if (event.action === "release") {
+ ownersByMessageId.delete(event.messageId);
+ continue;
+ }
+
+ ownersByMessageId.set(event.messageId, {
+ messageId: event.messageId,
+ ownerEmail: event.ownerEmail,
+ ownerDisplayName: event.ownerDisplayName,
+ action: event.action,
+ actorEmail: event.actorEmail,
+ createdAt: event.createdAt,
+ tags: event.tags,
+ });
+ }
+
+ return {
+ count: ownersByMessageId.size,
+ owners: [...ownersByMessageId.values()],
+ };
+}
+
+export { ALLOWED_ACTIONS, LIMITS };
diff --git a/tools/v1/team/email-ownership-tracker/tests/ownership-boundaries.test.mjs b/tools/v1/team/email-ownership-tracker/tests/ownership-boundaries.test.mjs
new file mode 100644
index 000000000..7390da86a
--- /dev/null
+++ b/tools/v1/team/email-ownership-tracker/tests/ownership-boundaries.test.mjs
@@ -0,0 +1,302 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { describe, it } from "node:test";
+import { fileURLToPath } from "node:url";
+
+import {
+ LIMITS,
+ OwnershipBoundaryError,
+ deriveCurrentOwners,
+ guardOwnershipHistory,
+ normalizeEmail,
+ prepareOwnershipHistory,
+ sanitizeAttachmentMetadata,
+ sanitizeTags,
+ sanitizeText,
+ validateOwnershipAction,
+ validateOwnershipEvent,
+ validateTeamMembers,
+ validateTrackerId,
+} from "../guards/ownership-boundaries.mjs";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const fixture = JSON.parse(
+ readFileSync(join(__dirname, "..", "fixtures", "hostile-ownership-inputs.json"), "utf8"),
+);
+
+function validEvent(overrides = {}) {
+ return {
+ eventId: "evt-valid-001",
+ messageId: "msg-valid-001@example.test",
+ action: "claim",
+ actorEmail: "actor@example.test",
+ ownerEmail: "owner@example.test",
+ ownerDisplayName: "Owner Name",
+ createdAt: "2026-07-01T09:00:00.000Z",
+ reason: "Claimed for team handling.",
+ teamId: "support-team",
+ tags: ["billing"],
+ attachments: [],
+ ...overrides,
+ };
+}
+
+describe("validateTrackerId", () => {
+ it("accepts compact ids used as event or message keys", () => {
+ assert.equal(
+ validateTrackerId("msg-2026.001@example.test", "messageId"),
+ "msg-2026.001@example.test",
+ );
+ });
+
+ it("rejects path traversal and path separators", () => {
+ assert.throws(() => validateTrackerId("../secret", "eventId"), OwnershipBoundaryError);
+ assert.throws(() => validateTrackerId("team\\secret", "eventId"), OwnershipBoundaryError);
+ });
+
+ it("rejects oversized ids before they can become keys", () => {
+ assert.throws(
+ () => validateTrackerId("a".repeat(LIMITS.MAX_EVENT_ID_LENGTH + 1), "eventId"),
+ OwnershipBoundaryError,
+ );
+ });
+});
+
+describe("normalizeEmail", () => {
+ it("normalizes safe emails", () => {
+ assert.equal(normalizeEmail(" Owner@Example.Test "), "owner@example.test");
+ });
+
+ it("rejects malformed or injected emails", () => {
+ assert.throws(() => normalizeEmail("owner.example.test"), OwnershipBoundaryError);
+ assert.throws(
+ () => normalizeEmail("owner@example.test\r\nBcc: x@example.test"),
+ OwnershipBoundaryError,
+ );
+ assert.throws(() => normalizeEmail("owner\0@example.test"), OwnershipBoundaryError);
+ });
+});
+
+describe("validateOwnershipAction", () => {
+ it("accepts known ownership actions case-insensitively", () => {
+ assert.equal(validateOwnershipAction("TRANSFER"), "transfer");
+ });
+
+ it("rejects unsupported actions", () => {
+ assert.throws(() => validateOwnershipAction("delete"), OwnershipBoundaryError);
+ });
+});
+
+describe("sanitizeText", () => {
+ it("removes control characters and HTML-like tags", () => {
+ assert.equal(sanitizeText("Alice\0 Smith", 120, "ownerDisplayName"), "Alice Smith");
+ });
+
+ it("rejects object text payloads", () => {
+ assert.throws(() => sanitizeText({ html: "x" }, 50, "reason"), OwnershipBoundaryError);
+ });
+
+ it("truncates long primitive text", () => {
+ assert.equal(sanitizeText("x".repeat(20), 8, "reason").length, 8);
+ });
+});
+
+describe("sanitizeTags", () => {
+ it("deduplicates and normalizes tags", () => {
+ assert.deepEqual(sanitizeTags(["VIP", "vip", "Billing"]), ["vip", "billing"]);
+ });
+
+ it("rejects unsafe tag characters and oversized tag arrays", () => {
+ assert.throws(() => sanitizeTags(["../private"]), OwnershipBoundaryError);
+ assert.throws(
+ () =>
+ sanitizeTags(
+ Array.from({ length: LIMITS.MAX_TAG_COUNT + 1 }, (_, index) => `tag-${index}`),
+ ),
+ OwnershipBoundaryError,
+ );
+ });
+});
+
+describe("sanitizeAttachmentMetadata", () => {
+ it("keeps metadata only and normalizes content types", () => {
+ assert.deepEqual(
+ sanitizeAttachmentMetadata([{ filename: "Invoice.pdf", type: "APPLICATION/PDF", size: 12 }]),
+ [{ name: "Invoice.pdf", contentType: "application/pdf", sizeBytes: 12 }],
+ );
+ });
+
+ it("rejects invalid attachment shapes and negative sizes", () => {
+ assert.throws(() => sanitizeAttachmentMetadata(["invoice.pdf"]), OwnershipBoundaryError);
+ assert.throws(
+ () => sanitizeAttachmentMetadata([{ name: "invoice.pdf", sizeBytes: -1 }]),
+ OwnershipBoundaryError,
+ );
+ });
+
+ it("rejects attachment lists over count or byte budgets", () => {
+ assert.throws(
+ () =>
+ sanitizeAttachmentMetadata(
+ new Array(LIMITS.MAX_ATTACHMENT_COUNT + 1).fill({ name: "a", sizeBytes: 1 }),
+ ),
+ OwnershipBoundaryError,
+ );
+ assert.throws(
+ () =>
+ sanitizeAttachmentMetadata([
+ { name: "big.bin", sizeBytes: LIMITS.MAX_ATTACHMENT_BYTES + 1 },
+ ]),
+ OwnershipBoundaryError,
+ );
+ });
+});
+
+describe("validateTeamMembers", () => {
+ it("normalizes safe team member records", () => {
+ assert.deepEqual(validateTeamMembers(fixture.teamMembers), [
+ {
+ id: "member-1",
+ email: "lead@example.test",
+ displayName: "Lead Reviewer",
+ },
+ {
+ id: "member-2",
+ email: "backup@example.test",
+ displayName: "Backup Reviewer",
+ },
+ ]);
+ });
+
+ it("rejects oversized team directories", () => {
+ assert.throws(
+ () =>
+ validateTeamMembers(
+ Array.from({ length: LIMITS.MAX_TEAM_MEMBERS + 1 }, (_, index) => ({
+ id: `member-${index}`,
+ email: `member-${index}@example.test`,
+ })),
+ ),
+ OwnershipBoundaryError,
+ );
+ });
+});
+
+describe("validateOwnershipEvent", () => {
+ it("normalizes a complete ownership event", () => {
+ const event = validateOwnershipEvent(
+ validEvent({ ownerEmail: "Owner@Example.Test", tags: ["VIP", "vip"] }),
+ );
+ assert.equal(event.ownerEmail, "owner@example.test");
+ assert.deepEqual(event.tags, ["vip"]);
+ assert.equal(event.createdAt, "2026-07-01T09:00:00.000Z");
+ });
+
+ it("allows release events without ownerEmail", () => {
+ const event = validateOwnershipEvent(validEvent({ action: "release", ownerEmail: "" }));
+ assert.equal(event.action, "release");
+ assert.equal(event.ownerEmail, null);
+ });
+
+ it("requires ownerEmail for ownership-setting actions", () => {
+ assert.throws(
+ () => validateOwnershipEvent(validEvent({ action: "assign", ownerEmail: "" })),
+ OwnershipBoundaryError,
+ );
+ });
+
+ it("rejects non-object events and invalid timestamps", () => {
+ assert.throws(() => validateOwnershipEvent(null), OwnershipBoundaryError);
+ assert.throws(
+ () => validateOwnershipEvent(validEvent({ createdAt: "not-a-date" })),
+ OwnershipBoundaryError,
+ );
+ assert.throws(
+ () => validateOwnershipEvent(validEvent({ createdAt: "2026-07-01" })),
+ OwnershipBoundaryError,
+ );
+ });
+});
+
+describe("history guards", () => {
+ it("rejects non-array and oversized histories before mapping events", () => {
+ assert.throws(() => guardOwnershipHistory(null), OwnershipBoundaryError);
+ assert.throws(
+ () => guardOwnershipHistory(new Array(LIMITS.MAX_HISTORY_EVENTS + 1).fill(validEvent())),
+ OwnershipBoundaryError,
+ );
+ });
+
+ it("prepares fixture events", () => {
+ const events = prepareOwnershipHistory(fixture.validEvents);
+ assert.equal(events.length, 2);
+ assert.equal(events[0].ownerEmail, "owner@example.test");
+ });
+
+ it("derives current owners in one bounded pass", () => {
+ const summary = deriveCurrentOwners([
+ validEvent({
+ eventId: "evt-1",
+ messageId: "msg-1@example.test",
+ ownerEmail: "one@example.test",
+ }),
+ validEvent({
+ eventId: "evt-2",
+ messageId: "msg-2@example.test",
+ ownerEmail: "two@example.test",
+ }),
+ validEvent({
+ eventId: "evt-3",
+ messageId: "msg-1@example.test",
+ action: "release",
+ ownerEmail: "",
+ }),
+ ]);
+
+ assert.equal(summary.count, 1);
+ assert.equal(summary.owners[0].messageId, "msg-2@example.test");
+ });
+});
+
+describe("fixture hostile fields", () => {
+ const fieldMutators = {
+ eventId: (value) => validEvent({ eventId: value }),
+ messageId: (value) => validEvent({ messageId: value }),
+ action: (value) => validEvent({ action: value }),
+ actorEmail: (value) => validEvent({ actorEmail: value }),
+ ownerEmail: (value) => validEvent({ ownerEmail: value }),
+ createdAt: (value) => validEvent({ createdAt: value }),
+ tags: (value) => validEvent({ tags: value }),
+ attachments: (value) => validEvent({ attachments: value }),
+ };
+
+ for (const hostile of fixture.hostileFields) {
+ it(`${hostile.id}: ${hostile.reason}`, () => {
+ assert.throws(
+ () => validateOwnershipEvent(fieldMutators[hostile.field](hostile.value)),
+ OwnershipBoundaryError,
+ );
+ });
+ }
+});
+
+describe("fixture text cases", () => {
+ it("sanitizes HTML/control display names", () => {
+ const htmlCase = fixture.textCases.find((entry) => entry.id === "html-display-name");
+ assert.equal(
+ sanitizeText(htmlCase.input, LIMITS.MAX_DISPLAY_NAME_LENGTH, "ownerDisplayName"),
+ htmlCase.expected,
+ );
+ });
+
+ it("truncates long reasons to the documented budget", () => {
+ const longReason = fixture.textCases.find((entry) => entry.id === "long-reason");
+ const output = sanitizeText(
+ "x".repeat(longReason.inputLength),
+ LIMITS.MAX_REASON_LENGTH,
+ "reason",
+ );
+ assert.equal(output.length, longReason.expectedLength);
+ });
+});