Skip to content

✨ feat(api): map domain errors to HTTP status codes - #485

Open
MaloPromyze wants to merge 28 commits into
mainfrom
feat/domain-error-http-status-mapping
Open

MaloPromyze wants to merge 28 commits into
mainfrom
feat/domain-error-http-status-mapping

Conversation

@MaloPromyze

Copy link
Copy Markdown
Contributor

Redo of #462, addressing PackmindHub/packmind-proprietary#773.

The problem

apps/api has no exception filter — no @Catch() anywhere in the tree — so NestJS
falls back to its default handler, which maps every non-HttpException to
500 Internal Server Error and logs it at ERROR with a full stack trace.

A user whose account was deleted, a user acting outside their organization, and a user
without admin rights therefore all get the same answer as a genuine crash: a 500 whose
body reads "Internal server error". The frontend can only show a generic failure, the
CLI can only print one, the on-call engineer sees a stack trace for an ordinary
permission denial, and the service error ratio counts it.

The translation layer that produces these errors was already there and already correct —
every authenticated use case funnels through AbstractMemberUseCase.execute, which
deliberately re-types a UserAccessError into something specific. Nothing consumed its
output. ExpectedAuthError's docstring has asked for this since it was written:

Callers (e.g. NestJS controllers, exception filters) should log instances of this class
at warn level without stack traces, and map them to the appropriate HTTP response.

The behaviour

A domain error is handled by the filter. Everything else is exactly as it was.

what is thrown answer
an error class that declares a kind its mapped status, body { statusCode, message, reason }, logged at warn without a stack
an HttpException unchanged — its own status, its own body
anything else unchanged — Nest's own handling, including its ERROR-level log

The last two rows are not reimplemented. The filter handles the domain case and delegates
everything else to BaseExceptionFilter, so non-domain behaviour is identical to today
by construction rather than by a test that has to track a dependency's internals.

Adoption is opt-in per error class, and that is the whole mechanism

An error declares a kind — never a status number, because the same errors are raised
inside BullMQ workers where a status means nothing — and a stable snake_case reason
for the wire. Once a class declares its kind, every site that throws it answers with the
right status: no change at the throw site, none in the controller. A class that has not
opted in keeps producing exactly the 500-with-stack it produces today. There is no flag
day.

Six classes opt in here, all of them access errors that funnel through
AbstractMemberUseCase, which covers every authenticated endpoint at once:

class kind status
UserAccessError (base) forbidden 403
UserNotFoundError not_found 404
UserNotInOrganizationError forbidden 403
OrganizationAdminRequiredError forbidden 403
SpaceMembershipRequiredError not_found 404
SpaceAdminRequiredError forbidden 403

SpaceMembershipRequiredError answers 404 on anti-enumeration grounds. Dozens of
space-scoped use cases throw it with no controller handling, so a blanket 403 would turn
every one of them into an existence oracle: guess a space id, read the status, learn
whether it exists. 404 makes "no such space" and "not your space" indistinguishable, and
the integration spec asserts the two responses are byte-identical.

User-visible: the access messages are rewritten

These messages have never been seen by anyone, because the body was always
"Internal server error". The moment the filter ships they reach the frontend surfaces
that render error.message and the CLI's stderr — so rewording them is a consequence of
this change, not a cleanup beside it.

They no longer interpolate UUIDs. The ids stay on .context, which reaches the logs
(where operators are) and which the filter does not serialize. Two of the five are
subject-neutral rather than second-person, because packages/accounts throws them about
a target user as well as the caller.

Also

  • withSpan no longer sets SpanStatusCode.ERROR for a domain error, so an ordinary
    denial stops counting against the service error ratio. The exception is still recorded;
    only the span's aggregate verdict changes.
  • The response body reuses the ServerErrorResponse shape the frontend already declares,
    with reason documented there as the discriminator clients branch on instead of
    matching prose. No client changes.

Deliberately not in this PR

  • No file under apps/cli or apps/frontend.
  • The controller instanceof ladders stay. They throw HttpException, which the
    filter passes through untouched, and some disagree with the class default on purpose.
    Deleting them is a per-route judgement.
  • The remaining ~129 typed error classes stay unannotated. Which to annotate next
    should be driven by what still shows up as a 500 once these six are done.
  • MemberNotFoundError (packages/spaces) leaks a target user's id and wants its own
    issue.

Notes for review

The filter sits on its own subpath (@packmind/node-utils/filters) and is deliberately
not exported from any barrel. @packmind/node-utils resolves to source, so a
barrel-reachable @Catch() would be parsed by the twelve importing projects that do not
enable decorators and fail there with Expression expected. That is also why four
separate resolver configs needed an entry — tsconfig paths, Jest's generated mapping,
node-utils' .swcrc, and apps/api's webpack aliases.

Verification

  • AC-1..AC-6, AC-8, AC-9: an integration spec boots a real Nest app on port 0 and drives
    it with fetch. A filter can map every kind correctly and still never be selected by
    Nest — a unit test passes in that case while every endpoint returns 500 — so the spec
    also asserts AppModule's provider metadata directly.
  • Full suite green across all 27 projects, including cli-e2e-tests against a live stack.

🤖 Generated with Claude Code

MaloPromyze and others added 24 commits September 11, 2026 14:59
Adds scripts/agent-gate.mjs, the verification gate for the orchestrator /
subagent pipeline: scope check, autofix, scoped and repo-wide Nx checks, then
the unit's named test. Prints OK, or the failing step alone with the command
that reproduces it.

Writes .claude/features/<slug>/metrics.jsonl per run, so the gate-stage failure
distribution is collected without the caller having to remember.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the charter, decision-log and unit-spec templates, the return-record JSON
schema, and the tier→model config. scripts/agent-record.mjs validates a unit
executor's record and appends it to the feature's records.jsonl.

The record shape is strict on purpose: unknown fields are rejected and prose is
capped, so a subagent cannot grow the orchestrator's context by reporting at
length. Cross-field rules catch a record that claims done while its own check
failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds context-scout, unit-executor and reconcile as subagent definitions, and
the two phase-1 skills that produce a feature's charter and decision log.

unit-executor is deliberately minimal: everything unit-specific arrives in the
inline spec, and a long standing prompt is paid on every unit against the same
capacity that produces valid edits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dev loop

The orchestrator skill carries the per-unit loop: baseline, route, retrieve,
specify inline, dispatch one subagent, gate, route on which stage failed,
record, commit. Typecheck failures route as capability and escalate the tier;
test failures route as specification and re-spec at the same tier.

Blocked units walk a four-rung ladder — acceptance criterion, decision entry,
new decision appended by the orchestrator — and only reach a human when scope
or a criterion would change.

agentic-doc-ingest reads the charter, decision log and records, and never the
code: the records carry public_surface_changed and behaviour_delta, filled in
while the change was still in front of the executor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The orchestrator had ADaPT's planning half — never pre-decompose — but not its
decomposition half: nothing ever split a unit, so the escalation ladder ended at
the top tier and then at a human.

A unit the strongest model cannot do from a complete spec is evidence about the
unit, not about the executor. Same for a second scope violation: an executor that
keeps reaching outside the declared files is usually right that the work does not
fit inside them. Both now route to a split.

- Two routing rows: `scope` failing twice on one unit, and any stage still
  failing at the top tier.
- §7b — split into two, both halves re-enter at the bottom tier with attempt
  counts reset, numbered U-014a / U-014b after the parent so the metrics can tell
  an over-sized unit from a weak tier.
- models.json: splitAfterTopTierFailures 1, splitAfterScopeFailures 2.
- return-record.schema.json: unit_id widened to ^U-[0-9]{3}[a-z]?$ — without it
  every split half is rejected by the validator. Verified both ways.
- A split-rate metric, and the measured gate timings in the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1a and 1b existed to settle what and how. The other reason to hold both
conversations before any code — getting an overall view of the size of the work,
and deciding whether the orchestrator runs it in one pass or several — was
nowhere in the skills or the templates, so it lived only in someone's head at the
moment they invoked phase 2.

Size is knowable only once the decisions exist: a fork settled one way is two
units, settled the other way is seven. So the verdict belongs at the close of 1b.

- charter.template.md: a `Size and sessions` section — rough unit-count range,
  `one session` | `split`, and for a split the ACs per session with dependencies
  and the reason the cut falls there. Empty at framing.
- agentic-feature-framing: a coarse read only — is this one feature at all —
  explicitly not a unit count, since the decisions that set it do not exist yet.
- agentic-design-session: a closing sizing step, with the signals that justify a
  split and the ones that do not. Unit size stays phase 2's problem. A split is
  not a re-framing: same charter, same log, the run is cut, not the feature.
- agentic-orchestrator: honours the verdict. On `split` it owns one session's
  ACs and halts on the others like any out-of-scope item; on an empty section it
  asks rather than sizing the feature itself.

The unit count stays a rough read throughout. Nothing upfront is a spec — the
only specs are the inline ones written for the unit about to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mething

The gate checked only the exit code of the exit criterion, which meant the one
thing it exists to enforce was not enforced. Three ways a unit went green having
proved nothing, all reproduced against this repo:

- A unit that changed no files. An empty diff is inside any declared scope,
  `nx affected` with no files is a no-op, and the criterion passes off the
  existing suite. The gate printed OK.
- A criterion naming a test that does not exist. A `--testNamePattern` matching
  nothing skips every test and jest still exits 0: `45 skipped, 0 of 4 total`,
  exit 0, gate OK.
- Both together, which is what a unit-executor that silently did nothing
  actually produces.

So `scope` now fails an empty diff, and `tests` reads jest's `Tests:` line and
fails a criterion that exited 0 having run no assertion.

Refactors are the case with no new assertion to make, and the orchestrator skill
already describes gating them on the existing tests instead. That is now
declared rather than implied: `exit_criterion.kind` is `behavioural` by default
and requires a passing assertion; `characterization` requires the existing tests
green and no test file modified, since a refactor that edits its own tests
proves nothing. An unknown kind halts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nx reads `-t` as `--targets` and never forwards it to jest, so the criterion
form seeded by the unit-spec template, `nx test spaces -t 'removeMember'`, ran
the whole project suite. Every unit was judged by tests that already passed
before it started. Measured on this repo: `nx test linter-ast` and
`nx test linter-ast -t 'aTestNameThatDoesNotExist'` print byte-identical
summaries.

Also documents `exit_criterion.kind`, which the gate now enforces, and the two
checks that an exit code alone does not give: no-op units and criteria that
assert nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README had the mechanics and the reasoning but not the thing someone opening
it actually wants first: what you say, in what order, and where each phase
stops. The closing slide of the talk points people here, so it is the file that
has to answer that.

Covers the three phases as conversations rather than commands, what to act on
during the loop (OK, FAIL, HALT, and that only HALT is yours), why restarting
the orchestrator is cheap and often correct, and the three things to get right
before the first run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md asks that PACKMIND_EDITION be oss, which meant remembering to export
it in every shell and prefixing it onto commands. Project settings set it once
for the whole session instead, team-wide.

Node cannot be handled the same way — the version comes from the shell, and any
PATH we could write here would be machine-specific. The README now says to set
it once per machine with `nvm alias default 24` rather than listing `nvm use` as
something to remember; the gate compares only the major version, so a patch bump
in .nvmrc does not invalidate that alias.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esign

Phases 1a and 1b of the agentic pipeline for the redo of PR #462. No code.

The charter drops the CLI from scope entirely: `packmindEdition.ts` now decides
feature-absence from the `PACKMIND_EDITION` header rather than from a 404, so the
sentinel collision the original PR worked around no longer exists.

Three decisions depart from that PR, each because measuring contradicted it:

- D-003 carries `kind` through the base constructor instead of a subclass class
  field. swc at es2022 does not downlevel class fields, so a bare declaration
  yields `undefined` under `swcTransform` (spaces, standards, skills, ...) and
  `'not_found'` under `swcTransformWithDefineFields` (accounts). The PR's field
  initializer is correct, but correct by a convention whose failure is invisible
  in the author's own package.

- D-004 keeps the filter out of the `@packmind/node-utils` barrel rather than
  writing `@Catch()` as a call. `@packmind/node-utils` resolves to src, so all 17
  consumers' Jest parse node-utils source and only 5 enable decorators. A file no
  consumer imports is a file no consumer parses. Also flips
  `packages/node-utils/.swcrc` to `decorators: true`, which the build needs and
  the PR did not mention.

- D-013 records that `SkillsGatewayApi.getSkillBySlug` maps any 404 to `null`, so
  a lost space membership renders as skill-not-found. That is what D-006's
  anti-enumeration choice asks for; written down so it is not refiled as a bug.

D-002 was found rather than designed: `ServerErrorResponse` already declares an
optional `reason` for exactly this purpose and has never been given one.

All six known unknowns are decided, none deferred. Sizing verdict: one
orchestrator session, 6-10 units — no subset of this is releasable alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An error's HTTP status is a property of the error, but a status number is
false at half its throw sites — the same errors are raised inside BullMQ
workers. So the contract is semantic: a kind, and a stable snake_case reason
for the wire. No status number appears here; the adapter that has a response
to write is the only layer entitled to turn kind into a number.

It lives in `packages/types` rather than beside its only current consumer
because `packages/types` is `env:shared` and `node-utils` is `env:node`, and
the module-boundary rule would put the union permanently out of the
frontend's reach — and the frontend is exactly the client that wants to
branch on it.

U-001, first attempt, cheap tier. D-001, D-005.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status was always a property of these errors, stored everywhere except
on the error — twenty controllers grew their own instanceof ladders, and the
HTTP intent leaked into the domain as prose. Each class now states its kind
once, at the super() call, and the adapter layer reads it.

kind rides the base constructor rather than a subclass class-field because
swc does not downlevel class fields: under the transform most packages use,
`class Sub extends Base { kind; }` yields undefined, and under the one
`accounts` uses it yields the right value. The same source, opposite results,
in two suites that both have to be green — and the failure mode is a silent
undefined that turns a 403 back into a 500 in production while the author's
own package's tests pass. A constructor parameter cannot be written wrong.

Messages are untouched: they interpolate UUIDs and rewriting them turns the
skills and standards suites red, so that lands with its spec fallout.

U-002, first attempt, top tier. D-003, D-006, D-014.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They were access errors that happened to be declared next to the use cases
throwing them rather than next to their siblings, and they carried no reason
at all — a client could have seen the status and nothing else. Extending the
base gives them reason, context and the constructor-assigned kind from one
mechanism instead of two.

Membership answers not_found, not forbidden. Dozens of space-scoped use cases
throw it with no controller handling, so a blanket 403 would make every one of
them an existence oracle: guess a space id, read the status, learn whether it
exists. 404 makes "no such space" and "not your space" indistinguishable, and
the message is worded to confirm neither. Space-admin stays forbidden, because
reaching it already proves membership — the caller is inside the space, so
saying it exists discloses nothing they do not have.

spaceId joins the context type as optional rather than being redeclared on the
subclasses: a redeclared field compiles to undefined under the swc transform
most suites here use, which would erase the whole context, not just the id.

The 26 specs asserting these classes assert by class reference and are
untouched. The one that pinned message text now asserts the class.

U-003, first attempt, top tier. D-006, D-007, D-008, D-015.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nobody has ever read these messages: the response body was always "Internal
server error", so a UUID in the text cost nothing. The filter later in this
feature makes them reachable — they land in the frontend surfaces that render
error.message and in the CLI's stderr — so rewording them is a consequence of
the feature, not a cleanup beside it. Deferring it would ship the regression
first and fix it after it had been seen.

Second person, because the reader is the person who was denied, not an
operator reading a log. Identifier-free, because a UUID tells that reader
nothing they can act on. The ids stay on .context, which reaches the logs,
which is where operators are.

The sixteen specs that pinned the old strings now assert the error class,
matching what the abstract use-case specs already did. The AC-7 test checks
the property — that no id passed in appears in the message — rather than an
expected string, so a future rewording cannot quietly retire it.

Verified beyond the gate, which runs lint and build but not these suites:
skills, standards and packmind-cli all green.

U-004, first attempt, top tier. D-008.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The span status is the service's own verdict on whether it worked, and a user
being told they may not do something is the service working. Leaving it ERROR
means the error ratio climbs with ordinary permission denials — which makes it
useless as an alerting signal exactly when adoption grows.

Without this, the feature would convert 500s into clean 403s at the HTTP layer
while still reporting them as service errors in telemetry. Whoever watches the
dashboard would read that as a regression.

recordException stays unconditional, so a specific denial is still diagnosable
in its own trace. Only the aggregate verdict changes.

The criterion runs the whole withSpan suite rather than just the new tests, so
it proves both halves: a domain error no longer marks the span failed, and a
plain Error still does.

U-005, first attempt, cheap tier. D-010.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep that found the assertions pinning the old UserNotFoundError text
searched for "User not found:" with a colon. These two assert the substring
"User not found" without one, so they were missed and the commands suite has
been red since the rewording landed.

The gate runs lint and build, not the suites of projects a unit touches, so
nothing caught this — the drift check did. Worth remembering that a substring
assertion is invisible to a sweep written around the fuller string.

U-006, first attempt, cheap tier. D-008.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The charter's premise was that every throw site funnels through
AbstractMemberUseCase, so the subject is always the caller. It is false for
two of the six classes: packages/accounts re-exports both and throws them
about a target user at five sites. An admin changing the role of a user who
does not exist would have been told "Your user account could not be found."

That is worse than the UUID the rewording removed — the old message was at
least about the right person. Subject-neutral wording is true in both
readings, and costs two strings. No new class, no accounts call site touched:
distinct target-user errors would be correct rather than merely true, but the
charter budgeted six annotations, not two more classes and their fallout.

not_found stands. It was justified by the record being the caller's own, but
its other argument holds for a target too — the request is unresolvable, and
a missing user is what 404 means.

Found by the drift check, not by a test. No unit deviated; it is invisible
from inside any single one.

U-007, first attempt, cheap tier. D-016.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reconcile pass found two things no unit could see from the inside, and
both wanted deciding before the filter lands.

D-016 is committed separately with its code. D-017 settles how the booted-app
spec proves registration: the literal reading of D-012 — boot the real
AppModule — has no precedent here and pulls TypeORM, JWT and every hexa into
a unit-test run, so it would fail for reasons unrelated to the filter. Boot a
minimal module for selection and mapping; read AppModule's own provider
metadata for installation. Neither half replaces the other, which is what
D-012 asked for.

Also corrects three unit exit criteria that claimed to describe AC-1, AC-4
and AC-6. D-012 reserves those for the booted-app spec, so the labels now say
"prepares" — otherwise transcribing them would report three ACs as verified
by tests that cannot verify them.

AC-7 and AC-10 are filled in: they are genuinely met and independently
confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@packmind/node-utils maps to source, not to built output, so every consuming
package's Jest compiles node-utils' TypeScript with its own swc transform.
Seventeen projects import it and five parse decorators. src/index.ts does
`export * from './nest'`, so a filter placed in that barrel would be parsed by
the twelve that do not — and swc with decorators: false fails on @catch() at
parse time, not lazily, breaking suites that never use the filter.

Hiding the file from the barrel removes the problem instead of guarding the
symptom, but then apps/api cannot reach it. Hence a subpath, the way
@packmind/git/schemas already works. apps/api uses swcTransformWithDecorators
and is the only project that will import it.

The target does not exist yet; U-008 creates it. Dangling entries are already
tolerated here.

Made by hand rather than by a unit: tsconfig.base.json is a gate guardrail, so
no executor can edit it. Approved by Malo before the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first real consumer of the contract. Three branches, in order:
HttpException passes through untouched — which is what makes leaving twenty
controller ladders alone free, since some of them disagree with the class
default on purpose; a domain error maps kind to 403/404 with a
{statusCode, message, reason} body; anything else keeps today's 500.

The body reuses a shape the frontend already declares, with reason documented
there as the discriminator clients branch on instead of matching prose. So no
client changes, and the unannotated-error path stays byte-identical.

Domain errors log at warn without a stack, which ExpectedAuthError's docstring
has asked for since it was written and nothing was ever in a position to
honour. A denial is an expected outcome of a working system; paging it at
ERROR with a stack is what trains people to ignore the channel.

The file sits on its own subpath, out of every barrel: node-utils resolves to
source, so a barrel-reachable @catch() is parsed by the twelve importing
projects that do not enable decorators, and fails there with Expression
expected.

node-utils' own Jest was the case D-004 missed — @swc/jest ignores .swcrc when
given explicit options, so the package owning the filter was the one that
could not parse it. It moves to swcTransformWithDecoratorsOnly, not
swcTransformWithDecorators: the latter sets useDefineForClassFields false
across all 31 suites, which is the exact axis that turns a class field into a
silent undefined. It passes green, but green is not the property at issue.

U-008, second attempt after a correct block, top tier.
D-002, D-004, D-005, D-009, D-011, D-018.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lled

APP_FILTER rather than useGlobalFilters in main.ts: it is DI-aware, so the
filter can take a logger, and it sits inside the module graph the test boots.
Per-controller binding would be twenty registrations and a silent gap on the
twenty-first.

The spec is the point of this unit. A filter can map every kind correctly and
never be selected by Nest — wrong metadata, wrong registration, a barrel that
did not re-export it — and a unit test passes in all of those cases while
every endpoint still returns 500. So the claim is split: booting a minimal
module on port 0 and driving it with fetch proves Nest selects the filter and
maps correctly; reading AppModule's own provider metadata proves the real
graph installs it. Booting AppModule itself would need TypeORM, JWT and every
hexa to stand up, and would fail for reasons unrelated to the filter.

The plain-Error and HttpException cases are asserted but prove passthrough,
not registration — Nest's default handler produces the same bodies.

AC-4 is asserted as byte-identity: the same class answers both "not your
space" and "no such space", so the two responses cannot be told apart. That
is the anti-enumeration property, checked on the wire rather than argued.

Webpack keeps an alias map independent of tsconfig and aliased the package to
a directory, so the subpath resolved to src/filters and only nx build api
failed. Fourth and last resolver; D-019 writes the set down.

U-009, second attempt after a correct block, top tier.
D-002, D-006, D-011, D-012, D-017, D-019.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g it

A global @catch() intercepts everything, including the http-errors that
body-parser throws. main.ts sets a 15mb body limit for bulk imports, so an
oversized upload answers 413 today — and the filter, which reproduced only one
of Nest's two default branches, turned that into a 500 with an ERROR-level
page. The feature that exists to stop ordinary client errors looking like
crashes had quietly created a new one.

Every test passed, because the integration spec only threw new Error('boom'),
which takes the same branch under both implementations. The drift check found
it by reading the call graph, not the tests.

The fix is not the missing branch. It is to stop copying a dependency's
private behaviour: BaseExceptionFilter now handles everything that is not a
domain error. That also restores the isHeadersSent guard — sse.controller
streams through @res(), where an unguarded write throws ERR_HTTP_HEADERS_SENT
— the IntrinsicException suppression that was already missing, and the
unhandled-error log's original ExceptionsHandler origin, which had become
AppController via the injected logger.

AC-8 and AC-9 are now true by construction rather than by a test kept in sync
with someone else's internals. The specs assert the absence of a stack on the
warn payload, which objectContaining never did.

U-010, second attempt: the first failed typecheck alone — swc strips types
without checking them, so tests and build both passed a missing override.

D-002, D-009, D-011, D-020.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every AC now names the command that proves it. Only the fills the drift check
judged honest: AC-8 cites both specs, because the booted app proves the status
and body while the unit spec proves the ERROR-level log; AC-11 cites the unit
spec alone, since log level is not an HTTP-observable property.

D-020 records why the non-domain path delegates rather than reproduces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the remaining issue is a non-blocking accuracy problem in agent-pipeline metrics.

Findings

  1. P2 Skipped Check Recorded Successful

Summary

  • Maps forbidden and not-found domain errors to 403 and 404 responses with stable reason codes.
  • Registers the filter globally and preserves existing HttpException and unknown-error behavior.
  • Prevents expected domain denials from marking OpenTelemetry spans as failed.
  • Adds integration and unit coverage for mapping, anti-enumeration, response redaction, and passthrough behavior.
  • Adds agent pipeline tooling and a Packmind standards-check stage.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Controller or use case throws] --> B{isDomainError?}
  B -->|Yes| C[Map semantic kind to HTTP status]
  C --> D[Warn with reason and internal context]
  D --> E[Return statusCode, message, reason]
  B -->|No| F[Delegate to Nest BaseExceptionFilter]
  F --> G[Preserve HttpException and unknown-error behavior]
  A --> H[withSpan catch path]
  H --> I[Record exception]
  I --> J{Domain error?}
  J -->|Yes| K[Do not mark span ERROR]
  J -->|No| L[Set span status ERROR]
Loading

Reviews (3) · Last reviewed commit: "🐛 fix(node-utils): stop asserting on th..."

Comment thread scripts/agent-gate.mjs Outdated
MaloPromyze and others added 4 commits September 14, 2026 10:15
…hell

`stepScoped` appended the changed-file list straight onto
`commands.scoped` and ran it with `shell: true`. The paths are whatever
the executor created, and scope validation is glob-only, so a filename
like `packages/x/$(...).ts` satisfies a declared `packages/x/**/*.ts`
and then gets evaluated by the shell with the developer's credentials.

Quote the joined list the way `stepAutofix` already quotes its
arguments. It is one `--files=` argument, so the list is quoted whole
rather than file by file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other barrel here uses `export * from`; this one imported the symbol
only to name it again on the next line. The filters barrel was the exception,
so this removes an inconsistency rather than introducing a rule.

Gated as a characterization unit: no test file changed, and the existing
integration spec reaches the filter through app.module.ts, which imports it
through this barrel — so the spec passing is what proves the re-export still
resolves, in webpack as well as in Jest.

U-011, first attempt, cheap tier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate ran ESLint and never the Packmind CLI, so a unit could be green under
scoped and wide and still break CI on a rule neither of them knows about. That
is exactly what happened: twelve units passed, and the standards check failed
after the PR was opened.

Changed files only, so a violation names the unit that introduced it instead of
inheriting one from a file nobody touched.

The awkward part is that the CLI exits 1 for two unrelated reasons — it found
violations, and it could not run at all. An expired key, a missing build, no
network and a real violation are indistinguishable by exit code, and treating
the environment as a unit failure would blame the executor for it and turn the
metrics into noise. That is the failure baseline already exists to prevent.

So an unavailable checker is recorded as skipped and the unit proceeds, while
violations fail the gate. The skip carries its reason into metrics and is never
silent, and CI runs the identical lint with a valid key, so it remains the
backstop. Verified both paths: an expired key skips, a violation fails.

A standards failure routes like `tests`, not like `typecheck` — the executor
wrote valid code against a house rule nobody told it about, so it re-specs at
the same tier with the rule quoted rather than escalating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repo's @backend-tests-redaction standard forbids asserting on stubbed
logger output, and the Packmind CLI blocks CI on it. No other spec in the
monorepo does it; there is no ignore file and no disable comment anywhere.

The conflict was genuine rather than technical: AC-11 is itself a statement
about logging — an access denial is logged at warn without a stack — and for a
log level there is no observable behaviour other than the log. The standard
assumes logging is never the requirement under test, which is true everywhere
else and false here.

It yields anyway. A standard the whole codebase observes should not acquire its
first exception inside a feature PR, decided by that feature's author, and the
cost is bounded: the behaviour ships correct and described, only its protection
is lost. D-021 records the accepted cost so it is not rediscovered as an
oversight — nothing now catches a change from warn to error, or a stack
reappearing in the payload.

The adjacent assertion reading a warnPayload() helper was not flagged, because
the rule matches expect(logger.…) directly. It is removed too: keeping it would
be the same assertion hidden from the matcher, which evades the rule instead of
satisfying it.

Sixteen tests remain, covering status, body, the exact key set, HttpException
passthrough, http-errors status preservation and the headers-sent path.

U-012, first attempt, cheap tier. D-021.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Comment thread scripts/agent-gate.mjs
Comment thread scripts/agent-record.mjs
@@ -0,0 +1,179 @@
#!/usr/bin/env node
// scripts/agent-record.mjs

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.

Does it make sense to keep this script here ?
If yes, could we move it in a dedicated or different folder maybe ?

@@ -0,0 +1,15 @@
{

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.

does it make sens to keep these files ?

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.

2 participants