Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bbc69ee
test(acquisition): reject non-canonical evidence timestamps
seonghobae Aug 21, 2026
3c45bb7
fix(acquisition): validate canonical evidence timestamps
seonghobae Aug 21, 2026
7555706
test(acquisition): cover ISO timestamp parse boundaries
seonghobae Aug 21, 2026
f2530a5
test(acquisition): reject future-dated evidence
seonghobae Aug 21, 2026
f8ad88b
fix(acquisition): reject future-dated evidence
seonghobae Aug 21, 2026
653e31c
test(acquisition): reject invalid evidence age authority
seonghobae Aug 21, 2026
d095ef4
fix(acquisition): fail closed on invalid evidence age config
seonghobae Aug 21, 2026
912ca90
test(acquisition): reject coerced revenue metrics
seonghobae Aug 21, 2026
b441219
fix(acquisition): require typed revenue metrics
seonghobae Aug 21, 2026
836dd96
test(acquisition): isolate revenue metric audit env
seonghobae Aug 21, 2026
ea3d8d6
test(acquisition): reject coercible release asset sizes
seonghobae Aug 21, 2026
754ddf4
fix(acquisition): require typed release asset sizes
seonghobae Aug 21, 2026
529d7c1
test(acquisition): preserve canonical date-only evidence
seonghobae Aug 21, 2026
adfc7dc
docs(changelog): record acquisition fail-closed behavior
seonghobae Aug 21, 2026
1db74b4
merge main into fix/acquisition-evidence-iso-date
seonghobae Aug 21, 2026
1d5c39f
merge protected main into fix/acquisition-evidence-iso-date
seonghobae Aug 21, 2026
8f4a78e
test(acquisition): preserve valid ahead-of-UTC civil dates
seonghobae Aug 21, 2026
b9758cd
fix(acquisition): bound date-only civil-date future check
seonghobae Aug 21, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- 인수 준비성 감사의 buyer/data-room 증거 경계를 실패-폐쇄로 강화한다. `updated_at`은 canonical ISO date 또는 timezone-bearing timestamp만 허용하고 존재하지 않는 달력값·비정규 형식·미래 시각을 거부하며, 명시된 evidence max-age 설정과 매출·고객·LOI·비율 지표는 coercion 없이 유효 도메인을 만족해야 한다. immutable release asset의 `bytes`도 실제 양의 safe-integer JSON number만 인정해 문자열·범위 밖 값이 매각 준비 증거를 제조하지 못하게 한다.
- 루트 `README.md`를 구매자/운영자 표면으로 재작성한다. 제품 정의, 단독 실행, 호스트 호출(HTTP API·`contracts/orchestrator-gateway.json`), 운영 설정을 앞에 두고 naruon·곁(gyeot) 허브-리프 호출을 지원 경로로 문서화한다. CloudAgent/OpenCode 절차, PR 스택, exact-head CI, writer/agent 경계는 `CONTRIBUTING.md`와 `docs/development/contributor-and-agent-procedure.md`로 옮기며 삭제하지 않는다. `test/readme-customer-facing.test.ts`가 README에 봇 매뉴얼 잔여가 남지 않는지 고정한다.
- 비리뷰 LLM 작업인 `hourly-product-development`를 리뷰와 동일한 `contextual-orchestrator` 게이트웨이 계약(`NOEMA_LLM_API_URL` `/v1`, 모델 별칭 `contextual-orchestrator`, 전용 `NOEMA_LLM_API_KEY`)으로 전환한다. Llama Nemotron → Nemotron Super → DeepSeek 순차 NIM 후보 폴백과 `NVIDIA_NIM_API_KEY` 직접 호출을 제거하고, 공유 `scripts/verify-orchestrator-gateway.mjs`가 `/healthz` 신원과 직접 공급자 호스트를 실패-폐쇄한다. 리뷰어의 `NOEMA_FALLBACK_*` / PydanticAI `FallbackModel` 순차 폴백도 제거해 남은 설정은 실패-폐쇄한다. 동일 계약을 `contracts/orchestrator-gateway.json`으로 공개해 `ContextualWisdomLab/naruon` 판단·결정 에이전트가 1급 소비자로 재사용할 수 있게 한다. naruon 배선은 별도 저장소 PR이다. 상위 공급자 키는 오케스트레이터 KV에 남기며 OIDC 토큰 중개·App 신원·3-runner 샌드박스 경계는 유지한다.
- 검증된 active-orphan 워크플로 하나를 운영자가 호출할 수 있는 `operations:workflow-registry-disable` 경로를 추가한다. 저장소와 워크플로 ID를 `NOEMA_MAINTAINER_TOKEN_PATH` 위임 토큰 파일 읽기 전에 검사하고, 신선한 전체 레지스트리 감사·즉시 live refresh·프로세스 로컬 plan·보호된 main/워크플로 재검증·사후 전체 감사 봉투(`schema_version` 1, `PASS`/`FAIL`, `remaining_failure_codes`, `remaining_active_orphan_ids`)를 통과한 뒤에만 영수증을 유지한다. 성공 종료와 `post_audit_status: FAIL`은 해당 ID만 `disabled_manually`가 되었고 레지스트리는 아직 더럽을 수 있음을 뜻하므로, 운영자는 영수증의 `remaining_active_orphan_ids`로 다음 단일 호출을 이어간다. 배치 비활성화·자가 수리 워크플로·거버넌스 완화는 추가하지 않으며 호출 계약은 doctoring에 기록한다.
Expand Down
102 changes: 88 additions & 14 deletions scripts/acquisition-readiness-audit.mjs
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { evaluatePilotReadinessText } from "./lib/pilot-readiness.mjs";
import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs";

const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true });
const isoDateOrTimestampRegex = /^(\d{4}-\d{2}-\d{2})(?:T(?:[01]\d|2[0-3]):\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Timestamp regex defers minute/second bounds to Date.parse

isoDateOrTimestampRegex validates hours strictly but accepts any \d{2} for minutes, seconds, and offset. Out-of-range values like 12:60:00 and +99:99 are rejected only because V8's strict ISO Date.parse returns NaN at acquisition-readiness-audit.mjs. Correct, but the rejection depends on Date.parse, not the regex.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const MAX_ISO_UTC_OFFSET_MS = 14 * 60 * 60 * 1000;
const now = new Date().toISOString();
const outputDir = process.env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR
|| join(process.cwd(), "artifacts", "acquisition-readiness", now.slice(0, 10).replace(/-/g, ""));
Expand All @@ -31,7 +33,11 @@ const saleableEvidencePath = process.env.NOEMA_SALEABLE_AUDIT_PATH
|| latestSaleableAuditPath();
const dataRoomManifestPath = process.env.NOEMA_DATA_ROOM_MANIFEST_PATH
|| join(outputDir, "data-room-manifest.json");
const evidenceMaxAgeDays = parsePositiveNumber(process.env.NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS, 45);
const evidenceMaxAgeDays = parsePositiveNumber(
process.env.NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS,
45,
"NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS",
);
const checks = [];

function latestSaleableAuditPath() {
Expand All @@ -53,9 +59,33 @@ function record(name, pass, details = {}) {
checks.push({ name, pass, details });
}

function parsePositiveNumber(raw, fallback) {
function parsePositiveNumber(raw, fallback, fieldName) {
const value = Number(raw ?? fallback);
return Number.isFinite(value) && value > 0 ? value : fallback;
if (!Number.isFinite(value) || value <= 0) {
if (raw !== undefined) {
console.error(`${fieldName} must be a positive finite number.`);
process.exit(1);
}
return fallback;
}
return value;
}
Comment on lines +62 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Empty max-age env var now exits instead of using default

In parsePositiveNumber, an env var set to an empty string is now treated as explicitly invalid and exits with status 1, where the old code fell back to the default of 45. Ambiguous but fail-closed and consistent with intent.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


function parseIsoDateOrTimestamp(value) {
const match = isoDateOrTimestampRegex.exec(value);
if (!match) return Number.NaN;

const datePart = match[1];
const calendarDate = new Date(`${datePart}T00:00:00.000Z`);
if (Number.isNaN(calendarDate.getTime()) || calendarDate.toISOString().slice(0, 10) !== datePart) {
return Number.NaN;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}

function isDateOnlyIsoDate(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
}

function readJson(path) {
Expand Down Expand Up @@ -149,9 +179,12 @@ function validateEvidenceRefs(value, field) {
function validateEvidenceMetadata(value) {
const failures = [];
const updatedAt = typeof value.updated_at === "string" ? value.updated_at.trim() : "";
const updatedAtMs = Date.parse(updatedAt);
const updatedAtMs = parseIsoDateOrTimestamp(updatedAt);
const nowMs = Date.now();
const maxAgeMs = evidenceMaxAgeDays * 24 * 60 * 60 * 1000;
const futureBoundaryMs = isDateOnlyIsoDate(updatedAt)
? nowMs + MAX_ISO_UTC_OFFSET_MS
: nowMs;

if (!isNonEmptyString(value.owner)) {
failures.push("owner required");
Expand All @@ -162,7 +195,7 @@ function validateEvidenceMetadata(value) {
failures.push(...sourceDocuments.failures);
if (!updatedAt || Number.isNaN(updatedAtMs)) {
failures.push("updated_at must be an ISO date or timestamp");
} else if (updatedAtMs - nowMs > 24 * 60 * 60 * 1000) {
} else if (updatedAtMs > futureBoundaryMs) {
failures.push("updated_at cannot be in the future");
} else if (nowMs - updatedAtMs > maxAgeMs) {
failures.push(`updated_at is older than ${evidenceMaxAgeDays} days`);
Comment on lines 195 to 201

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Stricter updated_at parsing affects revenue and transfer gates

validateEvidenceMetadata now parses timestamps via parseIsoDateOrTimestamp, which rejects formats Date.parse accepted (no-timezone timestamps, space-separated dates). It gates both revenue and transfer evidence, so any producer emitting a non-canonical updated_at now fails closed. No in-repo files are affected.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand All @@ -174,6 +207,33 @@ function validateEvidenceMetadata(value) {
};
}

function validateRevenueMetrics(value) {
const failures = [];
const nonNegativeSafeIntegerFields = [
"arr_krw",
"paid_customers",
"pipeline_weighted_krw",
"loi_count",
];
for (const field of nonNegativeSafeIntegerFields) {
const metric = value[field];
if (metric === undefined) continue;
if (typeof metric !== "number" || !Number.isSafeInteger(metric) || metric < 0) {
failures.push(`${field} must be a non-negative safe integer JSON number`);
}
}

for (const field of ["gross_margin", "customer_concentration_top1"]) {
const metric = value[field];
if (metric === undefined) continue;
if (typeof metric !== "number" || !Number.isFinite(metric) || metric < 0 || metric > 1) {
failures.push(`${field} must be a finite JSON number from 0 through 1`);
}
}

return { pass: failures.length === 0, failures };
}

function isCanonicalEvidencePath(value) {
if (!isNonEmptyString(value) || value.includes("\\") || value.startsWith("/")) return false;
if (/^[A-Za-z]:/.test(value) || value.includes("\0")) return false;
Expand Down Expand Up @@ -522,7 +582,7 @@ function validateReleasePublicationReceipt(value, expectedTag) {
if (asset?.apiDigest !== `sha256:${asset?.sha256 ?? ""}`) {
failures.push(`asset ${String(asset?.name ?? "unknown")} API digest mismatch`);
}
if (!(Number(asset?.bytes) > 0)) {
if (!Number.isSafeInteger(asset?.bytes) || asset.bytes <= 0) {
Comment thread
seonghobae marked this conversation as resolved.
failures.push(`asset ${String(asset?.name ?? "unknown")} byte size invalid`);
}
}
Expand Down Expand Up @@ -609,20 +669,34 @@ if (!revenue.ok) {
} else {
const value = revenue.value;
const metadata = validateEvidenceMetadata(value);
const metrics = validateRevenueMetrics(value);
const qnaEvidence = validateEvidenceRefs(value.buyer_due_diligence_qna, "buyer_due_diligence_qna");
const arrRoute = Number(value.arr_krw) >= 300_000_000
&& Number(value.gross_margin) >= 0.7
&& Number(value.paid_customers) >= 3
&& Number(value.customer_concentration_top1) < 0.6;
const pipelineRoute = Number(value.pipeline_weighted_krw) >= 500_000_000
&& Number(value.loi_count) >= 3
&& Number(value.paid_customers) >= 1
const arrRoute = metrics.pass
&& Number.isSafeInteger(value.arr_krw)
&& value.arr_krw >= 300_000_000
&& typeof value.gross_margin === "number"
&& Number.isFinite(value.gross_margin)
&& value.gross_margin >= 0.7
&& Number.isSafeInteger(value.paid_customers)
&& value.paid_customers >= 3
&& typeof value.customer_concentration_top1 === "number"
&& Number.isFinite(value.customer_concentration_top1)
&& value.customer_concentration_top1 >= 0
&& value.customer_concentration_top1 < 0.6;
const pipelineRoute = metrics.pass
&& Number.isSafeInteger(value.pipeline_weighted_krw)
&& value.pipeline_weighted_krw >= 500_000_000
&& Number.isSafeInteger(value.loi_count)
&& value.loi_count >= 3
&& Number.isSafeInteger(value.paid_customers)
&& value.paid_customers >= 1
&& qnaEvidence.pass;
Comment on lines +674 to 693

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: metrics.pass cross-gates route fields

Both arrRoute and pipelineRoute now require metrics.pass, which validates every present metric. A present-but-invalid field unused by a route (e.g. an out-of-range gross_margin in pipeline evidence) now blocks that route where it was previously ignored. Absent fields are skipped, so only present-invalid values are affected.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

record("revenue evidence supports 2B target", (arrRoute || pipelineRoute) && metadata.pass, {
record("revenue evidence supports 2B target", (arrRoute || pipelineRoute) && metadata.pass && metrics.pass, {
path: revenueEvidencePath,
targetKrw,
route: arrRoute ? "ARR" : pipelineRoute ? "strategic_pipeline" : "none",
metadataFailures: metadata.failures,
metricFailures: metrics.failures,
buyerQnaFailures: qnaEvidence.failures,
arr_krw: value.arr_krw,
gross_margin: value.gross_margin,
Expand Down
33 changes: 33 additions & 0 deletions test/acquisition-evidence-age-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";

function runAudit(configuredMaxAgeDays: string) {
const outputDir = mkdtempSync(join(tmpdir(), "noema-acquisition-age-config-"));
const result = spawnSync(process.execPath, ["scripts/acquisition-readiness-audit.mjs"], {
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDir,
NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS: configuredMaxAgeDays,
},
});
rmSync(outputDir, { recursive: true, force: true });
return result;
}

describe("acquisition evidence age configuration", () => {
for (const configuredMaxAgeDays of ["Infinity", "NaN", "0", "-1"]) {
it(`fails closed for explicit invalid max-age ${configuredMaxAgeDays}`, () => {
const result = runAudit(configuredMaxAgeDays);

expect(result.status).toBe(1);
expect(result.stderr).toContain(
"NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS must be a positive finite number",
);
});
}
});
177 changes: 177 additions & 0 deletions test/acquisition-evidence-iso-date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { describe, expect, it } from "vitest";

const auditScript = resolve("scripts/acquisition-readiness-audit.mjs");

function writeFixture(root: string, relativePath: string, content: string): string {
const path = join(root, relativePath);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, "utf8");
return path;
}

function prepareAuditRoot(prefix: string): string {
const root = mkdtempSync(join(tmpdir(), prefix));
writeFixture(
root,
"docs/acquisition-readiness-2b.md",
"NOEMA-GOAL-ACQUISITION-2B-2026-07-02\nKRW 2,000,000,000\nRevenue_PASS\nTransfer_PASS\n",
);
writeFixture(
root,
"docs/buyer-due-diligence-index.md",
"npm run acquisition:audit\nartifacts/acquisition/revenue-evidence.json\nartifacts/acquisition/transfer-evidence.json\n",
);
writeFixture(
root,
"docs/library-boundary-decision.md",
"현재는 submodule을 만들지 않는다\nnpm workspaces\nSplit Triggers\n",
);
writeFixture(
root,
"scripts/acquisition-data-room-manifest.mjs",
"// finalGatePassed data-room-manifest.json release-publication-receipt\n",
);
writeFixture(root, "docs/saleable-program-goal-registry.md", "NOEMA-GOAL-SALEABLE-2026-07-02\n");
writeFixture(root, "docs/pricing-draft.md", "pricing draft\n");
writeFixture(root, "docs/terms-draft.md", "terms draft\n");
writeFixture(root, "docs/sla-and-support.md", "support draft\n");
return root;
}

function runAuditWithRevenueTimestamp(root: string, updatedAt: string, nowMs?: number) {
const revenuePath = writeFixture(root, "revenue.json", JSON.stringify({
arr_krw: 300_000_000,
gross_margin: 0.75,
paid_customers: 3,
pipeline_weighted_krw: 0,
loi_count: 0,
customer_concentration_top1: 0.5,
updated_at: updatedAt,
owner: "finance",
source_documents: ["crm:noema-arr-report"],
}));
const outputDir = join(root, "audit-output");
const inheritedEnvironment = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("NOEMA_")),
);
const nodeArgs = [auditScript];
if (nowMs !== undefined) {
const preloadPath = writeFixture(root, "freeze-now.mjs", `Date.now = () => ${nowMs};\n`);
nodeArgs.unshift("--import", preloadPath);
}
const result = spawnSync(process.execPath, nodeArgs, {
cwd: root,
env: {
...inheritedEnvironment,
NOEMA_AUDIT_REPORT_ONLY: "1",
NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDir,
NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS: "36500",
NOEMA_REVENUE_EVIDENCE_PATH: revenuePath,
NOEMA_TRANSFER_EVIDENCE_PATH: join(root, "missing-transfer.json"),
NOEMA_PILOT_LOG_PATH: join(root, "missing-pilot.md"),
NOEMA_SALEABLE_AUDIT_PATH: join(root, "missing-saleable.json"),
NOEMA_DATA_ROOM_MANIFEST_PATH: join(root, "missing-data-room.json"),
},
encoding: "utf8",
});
const audit = JSON.parse(readFileSync(join(outputDir, "acquisition-audit.json"), "utf8"));
return { result, audit };
}

function revenueMetadataFailures(audit: { checks: Array<{ name: string; details: { metadataFailures: string[] } }> }) {
return audit.checks.find(
(check) => check.name === "revenue evidence supports 2B target",
)!.details.metadataFailures;
}

describe("acquisition evidence timestamp integrity", () => {
for (const updatedAt of [
"08/21/2026",
"2026-02-30",
"2026-08-21 12:00:00",
"2026-08-21T12:00:00+99:99",
]) {
it(`rejects non-canonical updated_at ${updatedAt}`, () => {
const root = prepareAuditRoot("noema-acq-iso-date-");
try {
const { result, audit } = runAuditWithRevenueTimestamp(root, updatedAt);
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(revenueMetadataFailures(audit)).toContain(
"updated_at must be an ISO date or timestamp",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}

it("rejects future-dated evidence instead of granting a one-day freshness grace period", () => {
const root = prepareAuditRoot("noema-acq-future-evidence-");
try {
const updatedAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const { result, audit } = runAuditWithRevenueTimestamp(root, updatedAt);
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(revenueMetadataFailures(audit)).toContain("updated_at cannot be in the future");
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it("accepts a date-only civil date that is current in a valid UTC+ timezone", () => {
const root = prepareAuditRoot("noema-acq-valid-ahead-date-");
try {
const nowMs = Date.parse("2026-08-21T16:00:00.000Z");
const { result, audit } = runAuditWithRevenueTimestamp(root, "2026-08-22", nowMs);
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(revenueMetadataFailures(audit)).not.toContain("updated_at cannot be in the future");
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it("rejects a date-only civil date beyond the maximum ISO UTC+14 offset", () => {
const root = prepareAuditRoot("noema-acq-invalid-ahead-date-");
try {
const nowMs = Date.parse("2026-08-21T09:59:59.999Z");
const { result, audit } = runAuditWithRevenueTimestamp(root, "2026-08-22", nowMs);
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(revenueMetadataFailures(audit)).toContain("updated_at cannot be in the future");
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it("accepts a canonical ISO date", () => {
const root = prepareAuditRoot("noema-acq-valid-iso-date-");
try {
const updatedAt = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const { result, audit } = runAuditWithRevenueTimestamp(root, updatedAt);
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(revenueMetadataFailures(audit)).not.toContain(
"updated_at must be an ISO date or timestamp",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it("accepts a canonical timezone-bearing ISO timestamp", () => {
const root = prepareAuditRoot("noema-acq-valid-iso-timestamp-");
try {
const updatedAt = new Date(Date.now() - 60_000).toISOString();
const { result, audit } = runAuditWithRevenueTimestamp(root, updatedAt);
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(revenueMetadataFailures(audit)).not.toContain(
"updated_at must be an ISO date or timestamp",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
Loading
Loading