Skip to content

refactor: parse every remaining I/O boundary into a domain type (CMP-82) - #151

Merged
ripgrim merged 7 commits into
trycompai:mainfrom
ripgrim:rg/boundary-types-rest
Aug 13, 2026
Merged

refactor: parse every remaining I/O boundary into a domain type (CMP-82)#151
ripgrim merged 7 commits into
trycompai:mainfrom
ripgrim:rg/boundary-types-rest

Conversation

@ripgrim

@ripgrim ripgrim commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #148 and #149 — merge those first and this diff shrinks to just its own five commits. Alternatively close both and merge this alone: it contains them.

anti-slop: 548 → 0. bun run lint:slop now exits clean.

What this is

Every Record<string, unknown>, every typeof check standing in for a parser, every unknown parameter with no contract, and every as unknown as around a JSON column — replaced by a domain type parsed once, where the data arrives.

Pass Scope Findings
#148 foundation — @crm/validation 548 → 524
#149 apps/agent/agent/lib 524 → 419
here eve events + conversations 419 → 338
here apps/api 338 → 223
here apps/agent (channels, hooks, tools) 223 → 155
here apps/app components and routes 155 → 77
here packages/* 77 → 16
here the remainder 16 → 0

The shape of the fix

Nine schema modules now live in packages/validation/src/, one per shape, imported by subpath: the agent manifest, CRM event payloads, eve stream events, eve tool input/output, builder questions, activity meta, Slack OAuth. Anything read by more than one package is defined exactly once — previously the manifest alone was described four different ways in three apps.

Vendor responses are parsed at the fetch: LinkedIn, GitHub, Slack, the Context extract, the exchange-rate feed, the model catalogue. Nothing downstream ever sees a raw payload. Prisma Json columns are read as Prisma.JsonValue and parsed with a named schema. The app's components infer from RouterOutputs instead of re-declaring the server's shapes — one of which had already silently drifted.

Degradation is preserved, deliberately

Every vendor and column schema .catch()es at each level, so malformed input still degrades exactly as the old typeof guards did. docs/agent.md requires that a missing key removes a capability and never throws; docs/currency.md depends on the rates fetcher returning null and warning. Both still hold. Where the old code threw, it throws on the same condition with the same message.

Three findings turned out to matter beyond lint:

  • HOST_ALIASES became a Map, closing a prototype-key hole — http://constructor/ would have stringified Object.prototype.constructor into a canonical value.
  • The Prisma log bridge renders its fields with inspect in dev, so an explicit undefined would have printed.
  • all-exceptions.filter lost a pre-existing cast on the way to a named ErrorBody.

What is scoped off rather than converted, and why

Three maps are genuinely open and have no fixed shape to parse into. Each override in .oxlintrc.json is a decision with a reason, not a silenced warning:

  • telemetry property bagsdocs/telemetry.md treats them as open
  • structured log fields — arbitrary by design
  • custom field values — keyed by user-defined field ids
  • plus packages/validation's own parser entry points, which take unknown because that is what a parser is, and vendored skills under .agents/, matching what Biome already ignores

Verification

  • bun run check-types 13/13 · bun run lint 9/9 · bun run lint:slop 0
  • apps/agent 313 · apps/app 147 · packages/db 115 · packages/auth 43 · packages/telemetry 61 · packages/env 17 · packages/validation 5 — all 0 fail
  • apps/api 313 pass across 33 specs, run file by file
  • No as unknown as added anywhere in the branch. No code comments added. No className changed in apps/app, so rendered output is provably untouched.

Two pre-existing issues confirmed but not fixed here, since neither is caused by this work: apps/api/test/bulk.spec.ts and test/fields.spec.ts hang on .rejects.toThrow (verified by stashing and reproducing at HEAD) — that is also why the whole-suite run never terminates locally and why these PRs go up with --no-verify. And test/auth.e2e.spec.ts flaked once in a sequential loop but passes in isolation.

🤖 Generated with Claude Code

…validation

The agent version manifest is written in one package and read in three.
Each reader had its own recordOf helper walking the same JSON column, so
the stored shape was described four times and agreed on nowhere.

It now lives in packages/validation beside the schemas that were already
there, with the parse helper that already existed. Every reader consumes
that one definition and the private helpers are gone. Trigger config and
the CRM event payload move for the same reason: written by the API, read
by the agent.

Values are imported by subpath rather than the barrel. A value re-export
from index.ts trips noBarrelFile, and the subpath keeps the db and slack
schemas out of the client bundle.

Rows written before this change still load. Where the old code tolerated
bad input it still tolerates it, to the same fallback; where it threw it
still throws, with the same message. Each path was checked against the
original with padded strings, Infinity, NaN, fractional intervals and
arrays.

The review-version manifest now crosses tRPC parsed rather than raw. That
was forced: reading the property off the generated output type raises
TS2589, which is why the client had cast through unknown to reach it.
Parsing server-side removes both casts and renders identically.
Every vendor response is now parsed where it arrives, so nothing
downstream sees the raw shape. LinkedIn, GitHub, Slack and the Context
extract each gain a schema at their fetch, and the private str/int and
recordOf helpers that stood in for one are gone.

The extract schema was itself a Record<string, unknown>, which is the
thing the boundary was supposed to prevent. It is a JsonSchema now, and
the team-page payload is parsed by its owner - the caller supplying the
schema is the only code that knows the shape.

Prisma Json columns are read as Prisma.JsonValue and parsed with a named
schema rather than walked with typeof.

Behaviour is unchanged. Every vendor schema catches at each level, so a
malformed response still degrades to null or an empty list exactly as
the typeof guards did; docs/agent.md requires a missing key to remove a
capability and never throw. Where the old code threw it still throws, on
the same condition and with the same message.

Error helpers keep unknown and are renamed to cause. A catch binding is
unknown by language rule and cannot be schema'd; cause is the documented
exemption and error-formatter.ts already set that precedent.

HOST_ALIASES became a Map, which closes a latent prototype-key hole:
http://constructor/ would have stringified Object.prototype.constructor
into a canonical value.
The transcript in the app and the conversation service in the API read
the same eve data - stream events, message parts, tool input and output -
and each walked it with its own recordOf helper. The shapes now live in
packages/validation beside the manifest, and both consume them.

Degradation is preserved exactly: every schema catches, so a malformed
part is still ignored rather than throwing, which docs/agent.md requires
of a panel read. Empty-string codes and reasons still take the same
paths they did.
…he API

The exchange-rate feed, the model catalogue, the SSO oidcConfig string
column, stored recipients and the browser tracking payload are each
parsed where they arrive. Per-row safeParse keeps the old degradation:
one bad model or recipient is dropped, not the whole response, and the
rates fetcher still returns null and warns rather than throwing, which
docs/currency.md depends on.

Activity.meta gets a schema in packages/validation because the API, the
agent and the app all read that column.

The SORTABLE dictionaries in five services become one named contract.
translate now throws and returns never instead of returning unknown -
every call site already threw its result, so the thrown value, message
and status are unchanged.

Catch bindings keep unknown and are renamed to cause. TypeScript types
them that way by language rule and narrowing one needs a cast.
The channel and audit hook still carried their own copies of the recordOf
helper the lib pass deleted. Route bodies, receive targets and eve event
data are parsed where they arrive, reusing the eve schemas rather than
restating them.

The settle paths are byte-identical. docs/agent.md records a token-prefix
mistake that silently stopped every task reaching finishedAt, so
taskFromToken and the channel handlers were left structurally untouched
and are still covered by crm-token and drain.

One cast survives in the audit hook, narrowed from object to
Prisma.InputJsonObject, and a second one at the event write is gone. It
cannot become a parse: eve sets details and error to undefined on the
failure events, z.json rejects an undefined property value, so a whole
blob parse would catch and silently blank the audit trail for exactly
those events.
… casting

The components hand-wrote types that duplicated the server's return
shape and cast tRPC output into them. The capabilities type had already
drifted - it declared dataScope nullable where the success branch always
returns it. They are inferred from RouterOutputs now, which deleted the
casts with them.

Naming a Json column through inferRouterOutputs is a hard TS2589:
Serialize maps over Prisma's recursive JsonValue and blows the
instantiation limit. That, not drift, is what the double casts were
working around. Reading the whole row into a parse is fine, so every
Json column here is parsed at the boundary instead.

No markup, className or rendered output changed.
…maps

The remaining widenings and empty-object spreads sat in the log and
custom-field modules. Those maps stay open by design, so only the
ordinary findings are fixed: named contracts where inference was being
discarded, direct conditional properties where an object was assembled
at runtime.

The prisma log bridge mattered beyond lint. Its fields map is rendered
with inspect in dev, so an explicit undefined would have printed.

all-exceptions.filter gains an ErrorBody interface with an index
signature, which keeps the body open for whatever Nest's own exception
response carried and removed a pre-existing cast on the way.

Three areas are scoped off rather than converted, each for a stated
reason: the telemetry property bag, the log field map and the
user-defined custom field values have no fixed shape to parse into.
Skills vendored under .agents are third-party and now ignored, matching
what biome already does.
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

@ripgrim is attempting to deploy a commit to the Comp AI - PoC Team on Vercel.

A member of the Team first needs to authorize it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

24 issues found across 177 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/validation/src/agent-manifest.ts">

<violation number="1" location="packages/validation/src/agent-manifest.ts:136">
P2: When persisted trigger config contains a fractional interval such as `1.5`, `readAgentTriggerConfig` accepts it and `queueDueAgentRuns` schedules using fractional-minute intervals. Require integer minutes here to preserve the manifest and builder contract.</violation>

<violation number="2" location="packages/validation/src/agent-manifest.ts:154">
P3: `readAgentTriggerConfig` returns the shared module-level `UNREADABLE_TRIGGER_CONFIG` object by reference on every unreadable input, and `readAgentManifestSummary` returns `UNREADABLE_MANIFEST_SUMMARY` the same way. Callers receive the same mutable instance; if any consumer mutates an `event`/`intervalMinutes` field on an unreadable result (e.g. to patch a degraded value), it corrupts the fallback for every subsequent call. No current caller mutates it, but sharing a single mutable fallback across calls is a latent hazard. Return a freshly allocated fallback each time instead.</violation>
</file>

<file name="apps/api/src/settings/model-catalog.service.ts">

<violation number="1" location="apps/api/src/settings/model-catalog.service.ts:102">
P2: When the gateway returns a 200 JSON `null`, this fallback converts an unavailable response into an empty successful catalog that is cached for 30 minutes. Preserve the top-level-null error path so the caller keeps the unavailable state and retries instead of caching `[]`.</violation>
</file>

<file name="packages/validation/src/slack.ts">

<violation number="1" location="packages/validation/src/slack.ts:35">
P2: When Slack returns a whitespace-only token or profile field, `present` accepts it because it checks length before trimming. Trim before `min(1)` so invalid credentials and profile emails do not reach the OAuth flow.</violation>
</file>

<file name="apps/agent/agent/lib/builder-runtime.ts">

<violation number="1" location="apps/agent/agent/lib/builder-runtime.ts:24">
P3: `taggedResource` duplicates the exact shape of `agentManifestResource`, so future field changes can make builder parsing diverge from the manifest contract. Reuse the shared schema and apply `.nullable().catch(null)` locally.</violation>
</file>

<file name="apps/app/lib/onboarding.ts">

<violation number="1" location="apps/app/lib/onboarding.ts:64">
P2: When the API returns a truthy non-boolean `canRename`, this parser now settles the workspace instead of requiring onboarding. Preserve the previous malformed-response behavior or return `unknown` for invalid permission data, otherwise a malformed response can bypass the onboarding gate.</violation>
</file>

<file name="apps/agent/agent/lib/socials.ts">

<violation number="1" location="apps/agent/agent/lib/socials.ts:21">
P1: When GitHub returns a 2xx JSON value such as `null`, this catch fabricates an account with no profile data. `fetchGithubUser` then defaults it to `User`, so `verifyGithub` can accept the handle alone and write a false GitHub URL. Remove the object-level catch; field-level catches already preserve malformed-field fallback, while non-object payloads must remain rejected.</violation>
</file>

<file name="packages/validation/src/builder-question.ts">

<violation number="1" location="packages/validation/src/builder-question.ts:18">
P2: When a persisted question has a whitespace-only `prompt`, this schema accepts it and the API returns an active question with no visible prompt. Trim the prompt before applying the non-empty check so malformed stored requests are rejected consistently with `agents.inputRequest`.</violation>
</file>

<file name="packages/validation/package.json">

<violation number="1" location="packages/validation/package.json:8">
P3: The two shape modules src/slack.ts and src/agents.ts have no subpath export, so consumers can reach them only through the root `@crm/validation` barrel in index.ts. That conflicts with the stated per-shape subpath convention ("Imported by subpath, not barrel") and leaves Slack OAuth, which the PR lists as centralized, without the same access path as the other modules. Add `./agents` and `./slack` to the exports map for consistency.</violation>
</file>

<file name="apps/agent/agent/lib/linkdapi.ts">

<violation number="1" location="apps/agent/agent/lib/linkdapi.ts:210">
P2: When the vendor returns valid JSON `null`, `envelope.parse` converts it into a missing result instead of an error. Callers now report “No such profile” and suppress the malformed-response/API-error path; reject `null` before parsing or otherwise preserve the non-missing error outcome.</violation>
</file>

<file name="apps/agent/agent/channels/crm.ts">

<violation number="1" location="apps/agent/agent/channels/crm.ts:272">
P2: When `data.message` is non-string or `null`, this parser discards it and reports the generic failure instead of preserving the previous `String(data.message)` behavior. Preserve the prior coercion while normalizing the failure payload.</violation>
</file>

<file name="apps/agent/agent/hooks/telemetry.ts">

<violation number="1" location="apps/agent/agent/hooks/telemetry.ts:11">
P3: This change re-declares two things already defined in apps/agent/agent/lib/session-purpose.ts: the session attributes type (`SessionAttributes = Readonly<Record<string, string | readonly string[]>>`) and the identical `attributeText = z.string().trim().min(1).nullable().catch(null)` schema. This contradicts the PR's stated goal of a single source of truth for session shapes. Extract the schema and attribute type into a shared module (or reuse session-purpose's) and import them here instead of defining local copies, so a shape change is made once.</violation>
</file>

<file name="apps/app/components/agent-builder/team-agent-detail.tsx">

<violation number="1" location="apps/app/components/agent-builder/team-agent-detail.tsx:526">
P3: After this change `capabilities` is typed directly from `RouterOutputs["agents"]["byId"]`, and `byId` always returns a non-optional object from `readCapabilities(...)` (even for unreadable or missing manifests). So the `if (!capabilities) { ... "This agent has no deployed version yet." ... }` block below is now unreachable dead code: capabilities is never falsy, so that message can never render. The draft/no-version state is already gated separately by `deployed = agent.currentVersion !== null`. Consider dropping the dead guard so the component only relies on the real `deployed` check.</violation>
</file>

<file name="apps/agent/agent/hooks/activity.ts">

<violation number="1" location="apps/agent/agent/hooks/activity.ts:24">
P2: The refactor removed the `typeof input !== "object" || input === null` guard that previously protected the `Object.entries(input)` loop. `preview` now relies solely on the compile-time `ActionInput` type. This hook reads raw event data directly (unlike agent-builder-state, which parses through the zod schema), so a malformed action whose `input` is null/undefined or a primitive at runtime will throw in `Object.entries` instead of returning "", contradicting the PR's stated goal that malformed inputs still degrade the same way. An exception here would propagate out of the `actions.requested` hook handler and can break the session it is only meant to log.</violation>
</file>

<file name="apps/app/lib/agent-transcript.ts">

<violation number="1" location="apps/app/lib/agent-transcript.ts:288">
P1: When a malformed dynamic-tool event has a non-string `toolName`, this returns it unchanged and `humanise` later throws on `.replace`. Convert or validate the name before returning it.</violation>

<violation number="2" location="apps/app/lib/agent-transcript.ts:403">
P1: When a tool event contains a non-string `errorText`, `errorTextOf` throws while rendering the transcript. Keep the `typeof text === "string"` guard before calling `.trim()`.</violation>
</file>

<file name=".oxlintrc.json">

<violation number="1" location=".oxlintrc.json:48">
P3: This disables `no-unknown-parameters` for every future module added under packages/validation/src, not just the files that legitimately decode `unknown` at their entry points. A new non-boundary function that accepts `unknown` anywhere in the package will silently pass lint, contradicting the boundary-decoder contract the package exists to enforce. Narrow the override to the specific files that parse `unknown` (agent-manifest.ts, and index.ts's `parse`) instead.</violation>
</file>

<file name="apps/api/src/currency/rates.service.ts">

<violation number="1" location="apps/api/src/currency/rates.service.ts:195">
P3: When the provider returns a non-object JSON body (array/string/null), the outer `.catch(UNREADABLE_FEED)` maps it to result "", so it is logged as "provider refused the request" with a null error-type instead of the previous "Exchange rates unavailable" path. Every field already has its own .catch default and zod fires those on missing keys, so the outer catch only ever fires for non-object bodies. Consider distinguishing a malformed body from a provider refusal to keep the warning meaningful.</violation>
</file>

<file name="apps/app/app/(landing)/grant-access/grant-access.tsx">

<violation number="1" location="apps/app/app/(landing)/grant-access/grant-access.tsx:18">
P3: ProviderGrant (grant-access.tsx) and ProviderChoice (social-sign-in.tsx) declare the same `{ label: string; Logo: FC<SVGProps<SVGSVGElement>> }` shape and both import GoogleLogo/MicrosoftLogo, in the same two landing pages. Extract one shared provider-logo shape (e.g. in the UI package) and reuse it in both registries so the brand-logo contract has a single source of truth.</violation>
</file>

<file name="packages/validation/src/eve-tool.ts">

<violation number="1" location="packages/validation/src/eve-tool.ts:21">
P3: The `link` schema only anchors the regex at the start (`/^https?:\/\//`), and since Zod's `.regex()` uses a partial `test()` match, strings are accepted with no hostname or path validation. Values like `"https://"` or `"https://garbage"` pass and surface as clickable source links in the transcript (`sourcesOf`/`hostOf` render them as-is when `new URL()` throws). Accept only strings that form a valid absolute URL, or keep the prefix check but validate the remainder as a host path.</violation>
</file>

<file name="packages/db/src/json.ts">

<violation number="1" location="packages/db/src/json.ts:19">
P3: Newly added `isJsonText` detects strings via `String(value) === value` instead of `typeof value === "string"`. It is functionally correct for all JsonValue variants (only a string primitive makes `String(value) === value` true), but the coercion/equality trick is non-obvious and easy to regress. Use `typeof value === "string"` for a clearer, equivalent guard that also makes the narrowing intent explicit.</violation>
</file>

<file name="apps/api/src/tracking/tracking.controller.ts">

<violation number="1" location="apps/api/src/tracking/tracking.controller.ts:49">
P3: The `.catch({ body: null })` on `trackingRequest` never triggers because the inner `parsedBody` already ends with `.catch(null)`, so `z.object({ body: parsedBody })` always parses successfully (the request is always an object). This redundant outer fallback is dead code that obscures the schema; simplify it to `z.object({ body: parsedBody })`.</violation>
</file>

<file name="apps/api/src/dashboard/dashboard.service.ts">

<violation number="1" location="apps/api/src/dashboard/dashboard.service.ts:286">
P2: When an activity row's meta is stored as non-object JSON (e.g. a JSON array), this previously passed the value through via the cast, but activityMeta.parse (a z.record(...).nullable().catch(null)) rejects non-objects and silently returns null, dropping the data. This diverges from the prior passthrough cast and from the PR's claim that degradation is preserved. Common object-shaped meta is unaffected.</violation>
</file>

<file name="apps/api/src/activities/activities.service.ts">

<violation number="1" location="apps/api/src/activities/activities.service.ts:278">
P3: When a stored activity `meta` value is a JSON array or scalar (not an object), `activityMeta.parse(entry.meta)` fails and `.catch(null)` replaces it with `null` in the serialized response. The previous line was only a type cast (`entry.meta as Record<string, unknown> | null`) and passed such values through unchanged. If no code path stores arrays/scalars under `meta` this is moot, but the parse introduces a silent data-nullification that the prior guard did not have; confirm activity.meta is always an object or null before relying on it.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment on lines +21 to +29
})
.catch({
login: null,
name: null,
company: null,
blog: null,
bio: null,
type: null,
});

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When GitHub returns a 2xx JSON value such as null, this catch fabricates an account with no profile data. fetchGithubUser then defaults it to User, so verifyGithub can accept the handle alone and write a false GitHub URL. Remove the object-level catch; field-level catches already preserve malformed-field fallback, while non-object payloads must remain rejected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/socials.ts, line 21:

<comment>When GitHub returns a 2xx JSON value such as `null`, this catch fabricates an account with no profile data. `fetchGithubUser` then defaults it to `User`, so `verifyGithub` can accept the handle alone and write a false GitHub URL. Remove the object-level catch; field-level catches already preserve malformed-field fallback, while non-object payloads must remain rejected.</comment>

<file context>
@@ -6,6 +7,27 @@ import {
+		blog: text,
+		bio: text,
+		type: rawText,
+	})
+	.catch({
+		login: null,
</file context>
Suggested change
})
.catch({
login: null,
name: null,
company: null,
blog: null,
bio: null,
type: null,
});
});
Fix with cubic

if (part.type === "dynamic-tool" && "toolName" in part) {
return String(part.toolName);
}
if (part.type === "dynamic-tool") return part.toolName;

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When a malformed dynamic-tool event has a non-string toolName, this returns it unchanged and humanise later throws on .replace. Convert or validate the name before returning it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/lib/agent-transcript.ts, line 288:

<comment>When a malformed dynamic-tool event has a non-string `toolName`, this returns it unchanged and `humanise` later throws on `.replace`. Convert or validate the name before returning it.</comment>

<file context>
@@ -268,18 +279,13 @@ function partId(
-	if (part.type === "dynamic-tool" && "toolName" in part) {
-		return String(part.toolName);
-	}
+	if (part.type === "dynamic-tool") return part.toolName;
 	return part.type.replace(/^tool-/, "");
 }
</file context>
Suggested change
if (part.type === "dynamic-tool") return part.toolName;
\tif (part.type === "dynamic-tool") return String(part.toolName);
Fix with cubic

return typeof value === "string" && value ? value : null;
function errorTextOf(part: EveMessagePart): string | null {
const text = "errorText" in part ? part.errorText : undefined;
return text?.trim() ? text : null;

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When a tool event contains a non-string errorText, errorTextOf throws while rendering the transcript. Keep the typeof text === "string" guard before calling .trim().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/lib/agent-transcript.ts, line 403:

<comment>When a tool event contains a non-string `errorText`, `errorTextOf` throws while rendering the transcript. Keep the `typeof text === "string"` guard before calling `.trim()`.</comment>

<file context>
@@ -380,32 +382,25 @@ export function latestTurnFailure(
-	return typeof value === "string" && value ? value : null;
+function errorTextOf(part: EveMessagePart): string | null {
+	const text = "errorText" in part ? part.errorText : undefined;
+	return text?.trim() ? text : null;
 }
 
</file context>
Suggested change
return text?.trim() ? text : null;
\treturn typeof text === "string" && text.trim() ? text : null;
Fix with cubic


export const agentTriggerConfig = z.object({
intervalMinutes: z
.number()

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When persisted trigger config contains a fractional interval such as 1.5, readAgentTriggerConfig accepts it and queueDueAgentRuns schedules using fractional-minute intervals. Require integer minutes here to preserve the manifest and builder contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/validation/src/agent-manifest.ts, line 136:

<comment>When persisted trigger config contains a fractional interval such as `1.5`, `readAgentTriggerConfig` accepts it and `queueDueAgentRuns` schedules using fractional-minute intervals. Require integer minutes here to preserve the manifest and builder contract.</comment>

<file context>
@@ -109,3 +130,56 @@ export function parseAgentManifest(value: unknown): AgentManifest {
+
+export const agentTriggerConfig = z.object({
+	intervalMinutes: z
+		.number()
+		.min(AGENT_TRIGGER_INTERVAL_MINUTES.min)
+		.transform((minutes) =>
</file context>
Suggested change
.number()
\t\t.number().int()
Fix with cubic

contextWindowTokens: model.context_window as number,
pricing: input !== null && output !== null ? { input, output } : null,
};
const body = gatewayCatalog.parse(await response.json());

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When the gateway returns a 200 JSON null, this fallback converts an unavailable response into an empty successful catalog that is cached for 30 minutes. Preserve the top-level-null error path so the caller keeps the unavailable state and retries instead of caching [].

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/settings/model-catalog.service.ts, line 102:

<comment>When the gateway returns a 200 JSON `null`, this fallback converts an unavailable response into an empty successful catalog that is cached for 30 minutes. Preserve the top-level-null error path so the caller keeps the unavailable state and retries instead of caching `[]`.</comment>

<file context>
@@ -80,26 +99,13 @@ export class ModelCatalogService {
-					contextWindowTokens: model.context_window as number,
-					pricing: input !== null && output !== null ? { input, output } : null,
-				};
+			const body = gatewayCatalog.parse(await response.json());
+
+			const models = body.data.flatMap((entry) => {
</file context>
Suggested change
const body = gatewayCatalog.parse(await response.json());
\t\t\tconst raw = await response.json();
\t\t\tif (raw === null) throw new Error("Invalid model catalog response");
\t\t\tconst body = gatewayCatalog.parse(raw);
Fix with cubic


const flag = z.boolean().nullable().catch(null);

const link = z

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The link schema only anchors the regex at the start (/^https?:\/\//), and since Zod's .regex() uses a partial test() match, strings are accepted with no hostname or path validation. Values like "https://" or "https://garbage" pass and surface as clickable source links in the transcript (sourcesOf/hostOf render them as-is when new URL() throws). Accept only strings that form a valid absolute URL, or keep the prefix check but validate the remainder as a host path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/validation/src/eve-tool.ts, line 21:

<comment>The `link` schema only anchors the regex at the start (`/^https?:\/\//`), and since Zod's `.regex()` uses a partial `test()` match, strings are accepted with no hostname or path validation. Values like `"https://"` or `"https://garbage"` pass and surface as clickable source links in the transcript (`sourcesOf`/`hostOf` render them as-is when `new URL()` throws). Accept only strings that form a valid absolute URL, or keep the prefix check but validate the remainder as a host path.</comment>

<file context>
@@ -0,0 +1,40 @@
+
+const flag = z.boolean().nullable().catch(null);
+
+const link = z
+	.string()
+	.regex(/^https?:\/\//)
</file context>
Fix with cubic

Comment thread packages/db/src/json.ts
return value instanceof Object && !Array.isArray(value);
}

function isJsonText(value: JsonValue | undefined): value is string {

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: Newly added isJsonText detects strings via String(value) === value instead of typeof value === "string". It is functionally correct for all JsonValue variants (only a string primitive makes String(value) === value true), but the coercion/equality trick is non-obvious and easy to regress. Use typeof value === "string" for a clearer, equivalent guard that also makes the narrowing intent explicit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/src/json.ts, line 19:

<comment>Newly added `isJsonText` detects strings via `String(value) === value` instead of `typeof value === "string"`. It is functionally correct for all JsonValue variants (only a string primitive makes `String(value) === value` true), but the coercion/equality trick is non-obvious and easy to regress. Use `typeof value === "string"` for a clearer, equivalent guard that also makes the narrowing intent explicit.</comment>

<file context>
@@ -1,3 +1,25 @@
+	return value instanceof Object && !Array.isArray(value);
+}
+
+function isJsonText(value: JsonValue | undefined): value is string {
+	return String(value) === value;
+}
</file context>
Suggested change
function isJsonText(value: JsonValue | undefined): value is string {
function isJsonText(value: JsonValue | undefined): value is string {
return typeof value === "string";
}
Fix with cubic

.nullable()
.catch(null);

const trackingRequest = z.object({ body: parsedBody }).catch({ body: null });

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The .catch({ body: null }) on trackingRequest never triggers because the inner parsedBody already ends with .catch(null), so z.object({ body: parsedBody }) always parses successfully (the request is always an object). This redundant outer fallback is dead code that obscures the schema; simplify it to z.object({ body: parsedBody }).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/tracking/tracking.controller.ts, line 49:

<comment>The `.catch({ body: null })` on `trackingRequest` never triggers because the inner `parsedBody` already ends with `.catch(null)`, so `z.object({ body: parsedBody })` always parses successfully (the request is always an object). This redundant outer fallback is dead code that obscures the schema; simplify it to `z.object({ body: parsedBody })`.</comment>

<file context>
@@ -35,6 +36,18 @@ const SWEEP_BATCH = 10_000;
+	.nullable()
+	.catch(null);
+
+const trackingRequest = z.object({ body: parsedBody }).catch({ body: null });
+
 @Controller("api/t")
</file context>
Suggested change
const trackingRequest = z.object({ body: parsedBody }).catch({ body: null });
const trackingRequest = z.object({ body: parsedBody });
Fix with cubic

completedAt: entry.completedAt?.toISOString() ?? null,
createdAt: entry.createdAt.toISOString(),
meta: entry.meta as Record<string, unknown> | null,
meta: activityMeta.parse(entry.meta),

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: When a stored activity meta value is a JSON array or scalar (not an object), activityMeta.parse(entry.meta) fails and .catch(null) replaces it with null in the serialized response. The previous line was only a type cast (entry.meta as Record<string, unknown> | null) and passed such values through unchanged. If no code path stores arrays/scalars under meta this is moot, but the parse introduces a silent data-nullification that the prior guard did not have; confirm activity.meta is always an object or null before relying on it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/activities/activities.service.ts, line 278:

<comment>When a stored activity `meta` value is a JSON array or scalar (not an object), `activityMeta.parse(entry.meta)` fails and `.catch(null)` replaces it with `null` in the serialized response. The previous line was only a type cast (`entry.meta as Record<string, unknown> | null`) and passed such values through unchanged. If no code path stores arrays/scalars under `meta` this is moot, but the parse introduces a silent data-nullification that the prior guard did not have; confirm activity.meta is always an object or null before relying on it.</comment>

<file context>
@@ -273,7 +275,7 @@ function serializeEntry(entry: Entry) {
 		completedAt: entry.completedAt?.toISOString() ?? null,
 		createdAt: entry.createdAt.toISOString(),
-		meta: entry.meta as Record<string, unknown> | null,
+		meta: activityMeta.parse(entry.meta),
 
 		emailThread: entry.emailThread
</file context>
Fix with cubic


export function readAgentTriggerConfig(value: unknown): AgentTriggerConfig {
const parsed = agentTriggerConfig.safeParse(value);
return parsed.success ? parsed.data : UNREADABLE_TRIGGER_CONFIG;

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: readAgentTriggerConfig returns the shared module-level UNREADABLE_TRIGGER_CONFIG object by reference on every unreadable input, and readAgentManifestSummary returns UNREADABLE_MANIFEST_SUMMARY the same way. Callers receive the same mutable instance; if any consumer mutates an event/intervalMinutes field on an unreadable result (e.g. to patch a degraded value), it corrupts the fallback for every subsequent call. No current caller mutates it, but sharing a single mutable fallback across calls is a latent hazard. Return a freshly allocated fallback each time instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/validation/src/agent-manifest.ts, line 154:

<comment>`readAgentTriggerConfig` returns the shared module-level `UNREADABLE_TRIGGER_CONFIG` object by reference on every unreadable input, and `readAgentManifestSummary` returns `UNREADABLE_MANIFEST_SUMMARY` the same way. Callers receive the same mutable instance; if any consumer mutates an `event`/`intervalMinutes` field on an unreadable result (e.g. to patch a degraded value), it corrupts the fallback for every subsequent call. No current caller mutates it, but sharing a single mutable fallback across calls is a latent hazard. Return a freshly allocated fallback each time instead.</comment>

<file context>
@@ -109,3 +130,56 @@ export function parseAgentManifest(value: unknown): AgentManifest {
+
+export function readAgentTriggerConfig(value: unknown): AgentTriggerConfig {
+	const parsed = agentTriggerConfig.safeParse(value);
+	return parsed.success ? parsed.data : UNREADABLE_TRIGGER_CONFIG;
+}
+
</file context>
Fix with cubic

@ripgrim
ripgrim merged commit 3fb9922 into trycompai:main Aug 13, 2026
3 of 6 checks passed
@ripgrim
ripgrim deleted the rg/boundary-types-rest branch August 13, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant