-
Notifications
You must be signed in to change notification settings - Fork 0
fix(acquisition): require canonical evidence timestamps #439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bbc69ee
3c45bb7
7555706
f2530a5
f8ad88b
653e31c
d095ef4
912ca90
b441219
836dd96
ea3d8d6
754ddf4
529d7c1
adfc7dc
1db74b4
1d5c39f
8f4a78e
b9758cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}))?$/; | ||
| 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, "")); | ||
|
|
@@ -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() { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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) { | ||
|
|
@@ -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"); | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Stricter updated_at parsing affects revenue and transfer gates
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
@@ -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; | ||
|
|
@@ -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) { | ||
|
seonghobae marked this conversation as resolved.
|
||
| failures.push(`asset ${String(asset?.name ?? "unknown")} byte size invalid`); | ||
| } | ||
| } | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: metrics.pass cross-gates route fields Both 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, | ||
|
|
||
| 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", | ||
| ); | ||
| }); | ||
| } | ||
| }); |
| 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 }); | ||
| } | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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
isoDateOrTimestampRegexvalidates hours strictly but accepts any\d{2}for minutes, seconds, and offset. Out-of-range values like12:60:00and+99:99are rejected only because V8's strict ISODate.parsereturns NaN at acquisition-readiness-audit.mjs. Correct, but the rejection depends on Date.parse, not the regex.Was this helpful? React with 👍 or 👎 to provide feedback.