Skip to content

feature: Frigg 2.0 features - #394

Draft
d-klotz wants to merge 1144 commits into
mainfrom
next
Draft

feature: Frigg 2.0 features#394
d-klotz wants to merge 1144 commits into
mainfrom
next

Conversation

@d-klotz

@d-klotz d-klotz commented Jun 4, 2025

Copy link
Copy Markdown
Contributor

Version

Published prerelease version: v2.0.0-next.108

Changelog

🐛 Bug Fix

  • fix(deps): clear all production dependency vulnerabilities #642 (@claude @d-klotz)
  • ADR-031: Concurrent OAuth Credential Refresh Across Lambda Invocations #637 (@d-klotz)
  • @friggframework/admin-scripts, @friggframework/core, @friggframework/devtools, @friggframework/eslint-config, @friggframework/prettier-config, @friggframework/schemas, @friggframework/serverless-plugin, @friggframework/test, @friggframework/ui
    • fix(core): write derived process metrics atomically to stop lost counter updates #638 (@d-klotz)
    • fix(core): record why credentials were rejected before flipping to ERROR #634 (@d-klotz)

Authors: 2

Comment thread packages/core/handlers/app-handler-helpers.js Outdated
Comment thread packages/core/handlers/backend-utils.js Outdated
Comment thread packages/core/handlers/backend-utils.js Outdated
Comment thread packages/core/handlers/backend-utils.js Outdated
// Removes "Bearer " and trims
const token = authorizationHeader.split(' ')[1].trim();
// Load user for later middleware/routes to use
req.user = await User.newUser({ token });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

User.newUser is misleading, it does not create a new user, instead it retrieves a user based on the JWT token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

User.fromToken({ token }) or User.getByToken({ token }) would be a better fit.

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.

Yup agreed. I was using existing methods though.

Comment thread packages/core/handlers/app-handler-helpers.js Outdated
const appDefinition = backendJsFile.Definition;

const backend = createFriggBackend(appDefinition);
const loadRouterFromObject = (IntegrationClass, routerObject) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

"Object" is too vague, I have no clue what can be in there or what that means

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think we could call it loadIntegrationRoutes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Also the routerObject is already inside of IntegrationClass isn't it?

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.

The overall method of loadIntegrationRoutes makes sense, or integration defined routes.

The load from object method was intended to load from a... route object? Dunno what to call it. The "method", "path", "event" object. I'm not even sure where I got that concept from except it's a common way to represent an http endpoint. Minus the event thing. That I want to be an easy way for a dev to reference when creating event handlers.


for (const routeDef of IntegrationClass.Definition.routes) {
if (typeof routeDef === 'function') {
router.use(basePath, routeDef(IntegrationClass));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't understand this.

You're looping through IntegrationClass.Definition.routes and from every routeDef you're passing the same integration as parameter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do you have an example of a place where we declare routes as a function and need it's own integration class as parameter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why not stick to one type of route definition? Either function or object

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.

I think the rationale was that in some cases, you need to have some logic that runs outside of express route generation. https://github.com/lefthookhq/frigg-2.0-prototyping/blob/460ad99b85b5a53bac5a859de8b8d3780b85937d/backend/src/testRouter.js#L8

In other cases, relying on the normal instantiation is fine and for those you can reach for either the straight express route or the static object.

I wanted to provide a "quick and easy", "moderate complexity", "high complexity" set of options.

That said I'm not sure what I did was the way to do it.

Comment thread packages/core/handlers/backend-utils.js Outdated
router[method.toLowerCase()](path, async (req, res, next) => {
try {
const integration = new IntegrationClass({});
await integration.loadModules();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We already loadModules in the IntegrationBase constructor, we could registerEventHandlers in the constructor as well.

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.

I think I had an issue of not wanting to load event handlers yet because of some dynamic issues? Or something else. But, happy to not duplicate invoking!

await integration.loadModules();
await integration.registerEventHandlers();
const result = await integration.send(event, {req, res, next});
res.json(result);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do we really have to return the result here?
Developers will by instict call the http response when it's available to them. I was not aware that this existed until now and probably other developers won't either.

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.

Well, the intent was to remove the need to think about express inside the handler function. Just, do what you need, then if you need to return something, return it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

But if someones sees a "res" object, one would not simply return and ignore "res".
BTW, one of the reasons I don't like ruby on rails is because it does too much magic and I don't know why and how 🫠

for (const [entityId, key] of Object.entries(
integrationRecord.entityReference
)) {
const moduleInstance =

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Are the modules not already instantiated in IntegrationBase -> loadModules() when we do:

const instance = new integrationClass({
userId,
integrationId: params.integrationId,
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

OK, looks like loadModules doesn't actualy loads modules but it simply instantiates the module api.

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.

There's a difference between "load module definitions" and "load module entities into the module instance"

integrationRecord.config.type
);

const instance = new integrationClass({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

rename to integrationInstance

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.

I'm for it

// for each entity, get the moduleinstance and load them according to their keys
// If it's the first entity, load the moduleinstance into primary as well
// If it's the second entity, load the moduleinstance into target as well
const moduleTypesAndKeys =

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think this comment also does not make sense anymore, right?

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.

The first line does make sense. The second and third lines are for backwards compatibility, where we enforced a "primary" and "target" concept via naming conventions.

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.

I don't think we need to maintain that though. Let things error and people correct the errors.

moduleClass &&
typeof moduleClass.definition.getName === 'function'
) {
const moduleType = moduleClass.definition.getName();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

moduleType also comes from the name?

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.

Yes, though we can debate that

const integrationClassIndex = this.integrationTypes.indexOf(type);
return this.integrationClasses[integrationClassIndex];
}
getModuleTypesAndKeys(integrationClass) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I dont get what this does

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.

I don't think this implementation is fully what I intended. But anywho, the goal is that we grab an integration definition, and from it we can determine which api modules are part of it, which allows us to get the required module entity instances in order to create a complete integration record (TODO allow for a module to be optional and not required on creation of an integration record, potentially make them required on a per event basis, ie we throw an error/force a user to assign a connection or create a new connection if they go to use a feature that is only available if they have a specific module instance added).

The direct use case is during the management inside the frontend experience. The ui should see "user wants to create a HubSpot integration. The HubSpot integration requires two modules. The user has one module connection (entity) available to use, but needs the other one. I'll run the auth flow for the other one and then ask them to confirm the use of that new connection."

The reason I say this implementation may not be what I intended is that we should allow multiple modules of the same type to be assigned different module names so you have something like "slackUser" and "slackApp" both pointing to the slack api module.

@seanspeaks seanspeaks 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.

Did my comments to your comments and a few added comments in there

Comment thread lerna.json
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "1.2.2",
"version": "2.0.0-next.0",

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.

I have no idea if this should be committed 😅

Comment thread packages/core/handlers/backend-utils.js Outdated
const appDefinition = backendJsFile.Definition;

const backend = createFriggBackend(appDefinition);
const loadRouterFromObject = (IntegrationClass, routerObject) => {

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.

The overall method of loadIntegrationRoutes makes sense, or integration defined routes.

The load from object method was intended to load from a... route object? Dunno what to call it. The "method", "path", "event" object. I'm not even sure where I got that concept from except it's a common way to represent an http endpoint. Minus the event thing. That I want to be an easy way for a dev to reference when creating event handlers.

Comment thread packages/core/handlers/backend-utils.js Outdated
Comment thread packages/core/handlers/backend-utils.js Outdated
router[method.toLowerCase()](path, async (req, res, next) => {
try {
const integration = new IntegrationClass({});
await integration.loadModules();

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.

I think I had an issue of not wanting to load event handlers yet because of some dynamic issues? Or something else. But, happy to not duplicate invoking!


getIntegrationById: async function(id) {
return IntegrationModel.findById(id);
getIntegrationById: async function (id) {

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.

I really need to lock this concept into my brain. Git repo takes the entire definition of "repository" in my head.

const integration =
await integrationFactory.getInstanceFromIntegrationId({
integrationId: integrationRecord.id,
userId: getUserId(req),

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.

I think we discussed this on our call. Likely just was throwing things that stuck, for context on why this is here.

@@ -349,9 +375,7 @@ function setEntityRoutes(router, factory, getUserId) {
throw Boom.forbidden('Credential does not belong to user');

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.

I think there were moments where this failed? Dunno what those moments were/are though.

Comment thread packages/core/module-plugin/auther.js Outdated
const {ModuleConstants} = require("./ModuleConstants");
const { ModuleConstants } = require('./ModuleConstants');

class Auther extends Delegate {

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.

All for it. At one point @MichaelRyanWebber and I were debating both naming and intent of the class (hi Michael! Not to rope you in but, you likely can either find the note somewhere or comment on the future improvements you/we had in mind).

Comment thread packages/core/module-plugin/auther.js Outdated
this.EntityModel = definition.Entity || this.getEntityModel();
}

static async getInstance(params) {

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.

Async construction.

It's a debate in nodejs world. Might be a resolved debate in node 20? But basically "if you need to await/promise something in order to instantiate a class, do you make it an async constructor, or do you create a static async instantiation method?" Aka the get

That's the root of this decision at any rate.

Comment thread packages/devtools/infrastructure/serverless-template.js Fixed
Comment thread packages/core/handlers/routers/health.js Fixed
Comment thread packages/devtools/management-ui/server/api/cli.js Fixed

@github-advanced-security github-advanced-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Comment thread packages/devtools/management-ui/server/services/npm-registry.js Fixed
Comment thread packages/devtools/management-ui/server/services/npm-registry.js Fixed
Comment thread packages/devtools/frigg-cli/build-command/index.js Fixed
Comment thread packages/devtools/frigg-cli/utils/process-manager.js Fixed
@gitguardian

gitguardian Bot commented Nov 20, 2025

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 3 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
23374074 Triggered Generic Password 5c1d197 .claude/skills/frigg-canary-test/assets/harness/docker-compose.yml View secret
23374074 Triggered Generic Password 8eb6309 .claude/skills/frigg-canary-test/assets/harness/docker-compose.yml View secret
23374074 Triggered Generic Password 9b7e917 .claude/skills/frigg-canary-test/assets/harness/docker-compose.yml View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Comment thread packages/devtools/management-ui/server/services/template-engine.js Fixed
Comment thread packages/core/handlers/app-handler-helpers.js Fixed
Comment thread packages/devtools/management-ui/server/services/npm-registry.js Fixed
Comment thread packages/devtools/management-ui/server/services/npm-registry.js Fixed
Comment thread packages/devtools/management-ui/src/pages/Environment.jsx Fixed
@sonarqubecloud

sonarqubecloud Bot commented May 5, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
281 Security Hotspots
6.6% Duplication on New Code (required ≤ 3%)
E Security Rating on New Code (required ≥ A)
E Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@github-advanced-security github-advanced-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SonarCloud found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

seanspeaks and others added 15 commits May 23, 2026 08:39
References Daniel Klotz's PR #590 (Tier 3 framework implementation) as the
in-flight realization of Phase 2. Closes gaps the prior revision flagged:

- **Framework load mechanism** subsection under Tier 3 documents the
  actual two-seam split #590 implemented — boot-time route claiming in
  integration-defined-routers.js, per-instance event merging via
  IntegrationBase._mergeExtensions() called from initialize(). Quotes
  the resolution-priority cascade, fail-loud conflict policy, and the
  two newly-added instantiation hooks (loadRouterFromObject's
  per-request closure and createQueueWorker's dry-instance branch).

- **Deferred from the initial implementation** subsection captures the
  three Phase-2 items #590 explicitly defers (per-class merge cache,
  worker-side getExtensionWorkers consumption, declarative route
  middleware seam) plus the pre-existing IntegrationBase DI gap that
  extensions now inherit.

- **Beyond { routes, events, queues, workers } — extending the contract**
  subsection enumerates primitives not yet in the extension contract:
  crons/schedules, user actions, config options, dynamic options. Calls
  out crons and user actions as highest-leverage v1.1 adds.

- **Core / API module boundary — worked example** subsection uses #590's
  findIntegrationByPortalId as a concrete teaching case for the
  platform-neutral-primitive-in-core vs platform-vocabulary-wrapper-in-
  extension rule. Cross-references the comment posted on #590 proposing
  the rename to findIntegrationByEntityExternalId.

Also: extends Phase 2 to reference #590's actual scope, adds Phase 7
for v1.1 contract extensions, adds Open Questions 9-14 (merge cache,
worker consumption, route middleware, IntegrationBase DI, contract
extensions, boundary-as-guideline), updates References with a new
"Framework implementation (in flight)" section pointing at #590.

No code changes; documentation only.
Resolves two review threads on the Tier 3 Integration Extensions PR.

1. Entity-level ambiguity (Codex P1)
   `findIntegrationByPortalId` called `moduleRepository.findEntity` which
   returns only the first match. If two Entity rows share the same
   `externalId`, the duplicate was silently dropped and the downstream
   integration-level ambiguity check never saw it — a cross-tenant
   routing risk.

   - Adds `findEntities(filter)` to ModuleRepository (interface + mongo,
     postgres, documentdb, legacy implementations) symmetric with the
     existing `findEntity(filter)`.
   - Helper now throws on entity-level multi-match in addition to the
     existing integration-level multi-match.

2. Helper rename + boundary clarification (Sean)
   "Portal ID" is HubSpot vocabulary. The generic primitive belongs in
   core; platform-named wrappers belong inside each api-module's own
   extension (HubSpot `portalId`, Slack `team_id`, Asana `workspace_id`,
   Microsoft Teams `tenant_id`, etc.).

   - Renames `findIntegrationByPortalId` →
     `findIntegrationByEntityExternalId`.
   - Adds sibling `listIntegrationsByEntityExternalId` for the
     legitimate one-externalId → many-integrations fan-out case
     (returns an array, does not throw on ambiguity).
   - Updates EXTENSIONS.md with the new helper signatures and a worked
     example of the core / api-module-extension boundary rule.

3. SonarCloud version disclosure
   Suppresses Express `X-Powered-By` header on the test harness in
   `integration-defined-routers.test.js`.

Tests: 15 new + reworked unit tests for the renamed helper and its
list sibling — covering entity-level ambiguity, integration-level
ambiguity, orphan entities, deduplication across multiple matched
entities, and moduleName forwarding. All pass. Existing
`integration-defined-routers` suite still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Tier 3 extension flow needs the SQS worker to dispatch on the
extension-declared event name (e.g. HUBSPOT_WEBHOOK) so the bound
integration handler fires. Before this change, queueWebhook hardcoded
event: 'ON_WEBHOOK' in the SQS message body and the dispatcher always
routed to the default onWebhook handler — extension-bound handlers
never executed end-to-end.

Now: callers may pass `event` in the payload; it's stripped from the
data payload and used as the SQS message's dispatch event. Default
remains 'ON_WEBHOOK' so the existing Definition.webhooks: true path
behaves identically.

Caught by the sub-agent building the matching HubSpot extension bundle
in api-module-library#91.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Proposes an integration-level Capability Declaration on
IntegrationBase.Definition.capabilities, extending the API-module-level
pattern from ADR-EXTENSIONS to the integration class. Capabilities are
descriptive (not generative), use the same backedBy + implementedBy
vocabulary as ADR-EXTENSIONS, and resolve via prototype chain with
inherits/override semantics.

Key additions specific to the integration layer:
- Primitive taxonomy (dataSync, workflow, userAction, configOption,
  lifecycleHook, webhookHandler, fenestraComponent, cron, aiInference,
  apiProxy, mcpTool)
- `surface` field on implementedBy (default / vendor-webhook /
  vendor-frontend / admin / internal) with auth model + route shape
  per surface
- Surface-selection rule: prefer default-surface primitives
  (getConfigOptions, USER_ACTION events, /api/v2/integrations) over
  declaring new routes; vendor-namespaced surfaces reserved for cases
  where the external system literally cannot reach default
- Pointer discipline — declarations point at code by name, never claim
  behavior

Includes a worked PipedriveIntegration example showing 8 current routes
collapsing to 2 own capabilities + 11 inherited (5 from BaseCRMIntegration,
6 from IntegrationBase).

Status: PROPOSED. Draft committed to preserve work-in-progress on the
overhaul branch while adversarial review is in flight. Known issues being
addressed in the next revision:
- Static validation of `implementedBy.events` requires either a static
  EventDefinitions field or admission that the lint is dynamic
  (this.events is constructor-wired today)
- `implementedBy.extensions` cross-reference needs to pin to local
  binding keys, not extension class names
- Multi-`via` capabilities need per-field discrimination (artifact
  surface vs Frigg-side backend)
- `surface: vendor-frontend` discriminator should likely reframe around
  credential carrier (Bearer / vendor-JWT / vendor-signature / none)
  rather than "where the UI runs"
- Companion ADRs (ontology-layers, agent-harness, evals) not yet drafted

Not yet ready for merge or PR to next.

https://claude.ai/code/session_01Wbyh477BTZHfMLQMzASnuc
…mmands

Daniel's review on api-module-library#91: integration instances should not
expose findIntegrationByEntityExternalId directly. Cross-cutting lookups
belong in friggCommands — the canonical access pattern, consistent with
how findIntegrationContextByExternalEntityId, loadIntegrationContextById,
etc. are already shaped.

This commit:
- Extracts the reverse-lookup logic from IntegrationBase instance methods
  into two use cases:
    FindIntegrationByEntityExternalIdUseCase
    ListIntegrationsByEntityExternalIdUseCase
- Exposes both via createIntegrationCommands so they're available as
  integration.commands.findIntegrationByEntityExternalId(...)
- Removes the instance methods from IntegrationBase (single source of
  truth, no duplicate access surface)
- Moves the test file alongside the use cases and rewrites the tests to
  target the use case classes directly (mock the two repos, no
  IntegrationBase scaffolding required)
- Updates EXTENSIONS.md to show the commands-based access pattern and
  the api-module-side wrapper signature

Behavior is unchanged — same throws on entity-level / integration-level
ambiguity, same return shapes. Only the access path moved.

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

Four parallel reviewers (architectural coherence, Frigg API alignment,
agent-eval value, migration & drift) surfaced enough factual and
design errors that the draft needed a substantial rewrite.

Major corrections:

- Replace single `consumedBy` with `surface` + `auth` split.
  Surface describes where the endpoint is mounted (default,
  vendor-webhook, vendor-namespaced, admin, internal, mcp); auth
  describes which credential carriers it accepts (bearer, adopter-jwt,
  shared-secret, admin-key, vendor-signature, vendor-jwt, none). The
  earlier single-dimension framing collapsed surface and auth and led
  to the hand-wavy "vendor-frontend" enum value (now dropped).

- Fix all `/api/v2/...` references to actual current v1 paths.
  ADR-006 marks Phase 1-4 as accepted but no v2 router exists in the
  current checkout. Worked examples now cite the real v1 endpoints
  (`PATCH /api/integrations/:integrationId`, `/api/integrations/:id
  /actions/:actionId`, etc.).

- Replace references to `loadUser` / `requireLoggedInUser` /
  `requireAdmin` middleware. Those files live on
  `feature/integration-router-v2-drop-modules-router`, not on this
  branch. Actual current code uses inline `authenticateUser.execute()`
  inside handlers (packages/core/user/use-cases/authenticate-user.js
  :49-99), supporting four user-context auth methods, not one.

- Rebuild the Pipedrive worked example from the actual current
  four-route state (not the eight-route fiction from stale search
  results). Only `/uninstall` survives as a route after the cleanup
  per the May 25 #dev-feed thread; `callActivityDestination` and
  the admin script ride default-surface primitives with no new
  routes. Three own capabilities total; one route remaining.

- Split Phase 2 into Phase 2 (framework-only, IntegrationBase
  declaration) and Phase 2.5 (adopter base classes such as
  BaseCRMIntegration and BaseOutboundIntegration, which live in
  adopter repos and are not framework deliverables).

- Address the static-check viability gap. `this.events` is wired in
  constructors, so `validateCapabilityImplementation` cannot be
  purely static today. Propose a `static EventDefinitions`
  convention as a paired framework change; mark as Open Question 1.

- Pin `implementedBy.extensions` cross-references to binding keys
  (the locally-unique identifier in Definition.extensions),
  resolving the ambiguity flagged in architectural review.

- Drop multi-`via` as the default pattern. A capability with both
  Artifact and Frigg-side handler splits into sibling capabilities.
  Multi-via is now Open Question 4.

- Constrain `description` to purpose-not-behavior. Every worked-
  example description rewritten. Behavior claims are forbidden in
  the Definition — that's the pointer discipline.

- Add `extend` (array append) and `disinherit` to inheritance
  semantics. The earlier shape only supported `override` (shallow
  replace), which the architectural reviewer flagged as forcing
  authors to redeclare unchanged sibling fields when they only
  meant to add.

- Gate Phase 2 on PR #590 merged + one prerelease cycle stable.
  Gate Phase 5 on eval falsification criteria from ADR-EVALS
  (capability-only condition <8pp lift at Sonnet OR Haiku <70% at
  all-three-enabled triggers reconsideration).

- Add Revision history section documenting what changed and why,
  with provenance back to the four review findings.

Open Questions 5, 6, 9, 10 added; 1-4 and 7-8 updated to reflect
the corrected design.

The shape of the design (capability array, primitive + backedBy +
implementedBy, pointer discipline, inheritance via prototype chain)
survives. The factual layer that referenced auth middleware, v2
paths, and an eight-route Pipedrive was wrong; this revision
corrects it.

Not yet PR-ready. Three companion ADRs (ontology, harness, evals)
remain to draft.
Companion to ADR-INTEGRATION-CAPABILITIES. Defines the four-layer
ontology model (L1 universal non-obvious / L2 Frigg-context reading
discipline / L3 vendor and domain / L4 live-read instance) and the
in-house Node compiler that renders task-scoped subsets into
XML-tagged context blocks.

Key design decisions captured:

- Layer model stratifies by scope-of-applicability, not subject
  matter. More-specific layers override less-specific; locked
  constraints accumulate and survive overrides.

- Authoring discipline: imperative-verb constraints; pointer-only
  references to live code via retrieve_from; no behavioral facts
  about code in the ontology (behavior is the property of code,
  surfaced live).

- Node-based compiler shipped as @friggframework/ontology from the
  same monorepo. ~300 LOC core. CLI plus SDK. git+https URLs for
  remote ontology roots with version pinning at task start.

- Session protocol: pin ontology version at task start, never
  refresh mid-task. Refresh happens at task boundaries.
  Patch/minor bumps auto-adopt; major bumps (locked-rule changes)
  surface for review via ontology diff.

- Composition with ADR-INTEGRATION-CAPABILITIES is explicit and
  non-overlapping: capabilities answer what an integration DOES;
  ontology layers answer what an agent NEEDS TO KNOW to work on
  it. L4 entries are mostly pointers to capability declarations;
  the ontology teaches the agent how to find them, not what they
  say.

- L2 captures the surface-selection rule that surfaced in the
  May 24-25 dev-feed thread on Pipedrive routes. That rule was
  previously implicit institutional knowledge with no home.

- Adopter-portable: framework ships L1 + L2; adopter repos extend
  with L3 (vendor backstory) and L4 (instance pointers).

Phases land sequentially: schema + validator -> compiler -> drift
tooling -> seed framework L1+L2 -> adopter L3 -> harness wiring
(ADR-AGENT-HARNESS) -> eval measurement (ADR-EVALS).

Eight open questions captured; falsification criteria deferred to
ADR-EVALS.

Not yet PR-ready. ADR-AGENT-HARNESS and ADR-EVALS remain to draft;
fourth adversarial reviewer (agent-eval value) still in flight.
Wires ADR-INTEGRATION-CAPABILITIES and ADR-ONTOLOGY-LAYERS into a
Claude Code SessionStart hook plus an MCP server fallback.

The harness has three responsibilities:

1. Session-start injection. Reads the repo's harness config, runs
   resolveCapabilities(IntegrationClass) and compileContext(), and
   emits a single <FRIGG-HARNESS-CONTEXT> block with <ONTOLOGY> and
   <CAPABILITIES> sub-blocks for Claude Code to prepend as a system-
   prompt overlay.

2. Task pinning. First run for a new task writes .frigg-harness/
   state.json with the resolved ontology version + capability class
   hash + compiled block hash. Subsequent runs (including subagent
   invocations) reuse the pinned state. Mid-task drift is impossible
   by construction; default hash-mismatch policy is warn-and-recompile
   with stricter / laxer modes available.

3. Subagent propagation. spawnSubagentPrompt() helper templates the
   parent's pinned context into child prompts. A phase-executor
   subagent inherits the orchestrator's pinned ontology and
   capability versions; it never re-pins.

Optional fourth responsibility: MCP server fallback exposing the
same content via mcp__frigg-harness__get_capabilities(),
get_ontology(), pin_task(), resolve_pointer() tool calls. Same
content, pull-based instead of push-based, for agents outside Claude
Code's SessionStart-hook contract.

Active-integration detection is heuristic (auto-from-branch, auto-
from-cwd, explicit override). Failure modes default to graceful
degradation: missing config, unreachable ontology root, broken
capability declaration all warn-and-proceed rather than block. The
exception is locked-content budget overflow, which is a hard error
because locked constraints cannot be elided.

Two transports (SessionStart push vs MCP pull) ship together. The
eval (ADR-EVALS) measures which produces better task accuracy at
each model capability tier. The harness on/off is one of three
independent variables in the eight-condition matrix.

Phases land sequentially: package skeleton -> SessionStart hook ->
task pinning -> subagent propagation -> MCP fallback -> failure-mode
tests -> eval integration. Gates: Phase 2 of this ADR depends on
the resolver from ADR-INTEGRATION-CAPABILITIES and the compiler
from ADR-ONTOLOGY-LAYERS both shipping first.

Eight open questions captured; relationship to the eval ADR's
falsification criteria is forward-referenced.

Not yet PR-ready. ADR-EVALS remains to draft; fourth adversarial
reviewer (agent-eval value) still in flight.
Completes the four-ADR set. Defines the eight-condition factorial
matrix that tests the three independent variables (capability
declaration, ontology layers, agent harness) against a five-model
ladder spanning Haiku-class through Opus-class plus cross-vendor
and open-weights.

Falsifiable hypothesis with six explicit criteria gating Phase 5
of ADR-INTEGRATION-CAPABILITIES (rolling migration of remaining
integrations across adopter repos):

  1. All-three at Haiku >=80% composite (weak-model promise)
  2. Capability-only at Sonnet >=15pp lift over baseline
  3. Ontology-only at Sonnet >=10pp lift over baseline
  4. Harness-only at Sonnet >=10pp lift over baseline
  5. Combined-effects monotonicity (no variable degrades score)
  6. Locked-constraint adherence >=95% under prompt pressure

Missed criteria 1-4 by >5pp = pause rolling migration; missed
criteria 5-6 at all = stop and reconsider design. The shape of
the design survives single missed thresholds (tuning available);
deeper failures invalidate the value claim.

Ten-task seed mixing three categories:
- 5 generic tasks against synthetic fixture integrations
- 3 real-bug-derived tasks (incl. the Pipedrive /settings smell
  from the May 24-25 dev-feed thread)
- 2 adversarial locked-constraint pressure tests

Tasks are scored on four independent dimensions (correctness,
locality, constraint adherence, cost) and combined via weighted
composite. Per-task weight override available for adversarial
tasks where constraint adherence should dominate.

Build on promptfoo (Node-native, MIT, mature) with a custom
provider for the Frigg-agent-in-sandbox loop. ~1500 LOC total
including tests, scorers, fixtures, custom provider. Three-layer
ownership:
- @friggframework/evals: scorers + fixture integrations + generic
  tasks; published from the Frigg monorepo
- lefthookhq/quo--frigg/evals/ (and other adopters): real-
  integration tasks, importing scorers from the framework package
- Framework eval numbers are load-bearing for generality claims;
  adopter numbers are illustrative real-world demonstration

Cadence:
- Every PR: smoke test only (parse, validate, compile - no model
  calls; ~free, fast)
- On-demand: workflow_dispatch with --models, --tasks, --conditions
  knobs; ~$10-50 typical iteration
- Canonical at milestones: full 8x5x10 = 400 runs; $50-150 per
  canonical; committed to evals/results/ and inserted into this
  ADR's Canonical Results section
- Local dev: single-cell runs <$1

Rejected alternatives:
- Python-based Inspect AI: cross-language toolchain tax
- Adopter-only evals: can't make generality claims from one
  adopter's biased data
- Skip eval, ship on intuition: how the original
  integration-definition.schema.json shipped with zero adopters
- Eval on every PR: cost and latency untenable

Ten open questions captured, including task-set evolution,
model-version drift, scorer weight tunability, agent-loop
architecture (single-loop vs multi-agent simulation), per-condition
baseline (with vs without existing CLAUDE.md), and statistical
methodology (effect size + significance, not just p-values).

Canonical results section is templated; populated after first run
following ADR-INTEGRATION-CAPABILITIES Phase 4. None of the four
ADRs land on `next` until the canonical run exists.

Not yet PR-ready. Fourth adversarial reviewer (agent-eval value)
remains in flight; if/when it returns its findings inform a
follow-up revision to this ADR specifically.
- Scrub client-specific (Quo) and private-repo references; generalize
  vendor examples to HubSpot / Pipedrive / Salesforce / Slack.
- Add Module-exported ontology: api modules ship their own vendor L3
  fragments; integration projects compose from installed modules.
- Add Source adapters: compiler converts md / gdoc / notion / github /
  live sources to canonical records on the fly; distinguish source
  (authoring-time) from retrieve_from (runtime).
- Add Validation subagent pattern: recommended pass that spawns a
  subagent with the same compiled block to review parent agent output.
- Add Friction capture and ontology evolution: feedback loop that
  captures ambiguity / gaps / conflicts surfaced by agents and the
  validation subagent; backlog becomes the maintenance trigger.
- Reframe Phase 5 around module-exported ontology; add Phase 6 (source
  adapters), Phase 8 (friction tooling), renumber accordingly.
- Update Negative consequences to reference friction loop as the named
  defense against the "seed commits, ongoing edits stall" pattern.
- Add open questions on friction-tool naming and module-ontology rollout.
ADR-AGENT-HARNESS:
- Add Lifecycle taxonomy subsection mapping the 9-phase agent lifecycle
  (Freya ADR-008) onto Frigg touchpoints; clarify Frigg implements only
  the two phases Claude Code's hook surface exposes (SessionStart for
  parent pre_turn, SubagentStart for subagent pre_turn) and uses port
  decorators or skill conventions for the rest.
- Add decorator-vs-hook framing (decorators wrap ports for structural
  concerns; hooks punctuate the lifecycle for cross-cutting concerns).
- Add Shared friction pipeline subsection noting the @freyaframework/
  friction package as a co-owned dependency.

ADR-ONTOLOGY-LAYERS:
- Revise Validation subagent pattern to use Freya's discriminated-union
  return shape: {valid | fixable, correctionPrompt | friction, events}.
  FRICTION classification emits events into the shared pipeline.
- Replace abstract friction-tooling section with explicit borrow from
  Freya ADR-009 as @freyaframework/friction shared package. Itemize the
  v1 consumed surface (typed FrictionEvent with 6 starting signals,
  OntologyVcsPort + GitHubOntologyVcsAdapter, discriminated-union
  validation, threshold+corroboration+decay) and the v1 deferred surface
  (MigrationPlan, persisted FrictionCluster, confidence float, scheduled
  aggregator, multiple separate detection hooks, eight domain events).
- Lift the five non-negotiable guardrails verbatim (harness-side-only
  detection, human-approve-only, decay, rejection cooldown, cross-tenant
  isolation).
- Add positioning: bottom-up schema synthesis as the structural inverse
  of Hermes-style procedure synthesis.
- Update Open Q9 from "tool TBD" to "shared package decided; confirm
  trimmed v1 surface with Freya ADR-009 roadmap."
…ion package

- Generalize lefthookhq/quo--frigg and other client-repo references in
  ADR-INTEGRATION-CAPABILITIES, ADR-AGENT-HARNESS, and ADR-EVALS to
  "adopter repo / canary adopter project / participating adopter
  projects". The Frigg framework's ADRs are public; private client work
  doesn't belong inline. Vendor names (HubSpot, Pipedrive, Salesforce,
  Slack) preserved as examples since they're shipping api modules.
- Rename transformPersonToQuo example to transformPersonToDestination.
- Rename the second module in the worked example from "quo" to
  "destination" to preserve the renaming idiom without naming a client.
- Add cross-link from ADR-EVALS to ADR-ONTOLOGY-LAYERS friction
  subsection, noting eval miss-cases feed the shared
  @freyaframework/friction package as FrictionEvents.
…tByExternalEntityId

The use case was incorrectly finding integrations by userId, which would
return wrong results when a user has multiple integrations. Now uses
findIntegrationsByEntityId to correctly find the integration that
contains the specific entity.

Fixes FRI-498

https://claude.ai/code/session_01EQCPbqP3QHZbvqPy3fW5yE
When an entity belongs to multiple integrations of different types,
we need to filter by config.type to return the correct one.

https://claude.ai/code/session_01EQCPbqP3QHZbvqPy3fW5yE
d-klotz and others added 11 commits July 14, 2026 09:56
…olve-route

fix(devtools): expose POST /admin/db-migrate/resolve route (fixes #626)
Treat reporting as a sibling of the Admin Script Runner (ADR-005) on shared
primitives, replacing PR #607's standalone reporting silo. Reports register via
`reports: []` in the app definition (plus `admin.includeBuiltinReports` for
core built-ins), extend `ReportBase`, and run through the same runner, admin API
key, async/SQS execution, and EventBridge scheduling as admin scripts.

Run modes: live (compute inline, persist nothing), recorded (persist an
execution record), snapshot (recorded run tagged into a named series). All three
use the isolated `AdminScriptExecution` store (type: 'REPORT', no user/
integration FK), so a user-scoped query can never return a report record.

Highlights:
- ReportBase + built-in `integrations` report (PR #607 aggregation preserved,
  schemaVersion 1) reading via the admin command bundle, not a repository.
- Fold the retired reporting repository triad into the canonical integration /
  integration-mapping repositories (findAllForReport, countByIntegrationIds);
  hoist the DocumentDB cursor-drain into documentdb-utils (findManyDrained /
  aggregateDrained) so deployment-wide scans never truncate at the first batch.
- report-commands (type:'REPORT', guarded findExecutionById, findSnapshotSeries),
  report-runner, report-router (live + async + snapshots + executions + schedule),
  report-executor-handler, bootstrap wiring with a script/report name-collision
  guard.
- devtools AdminScriptBuilder: dedicated ReportQueue + report executor Lambda,
  report router, artifact bucket + scheduler resources gated on reports; remove
  the always-on standalone reporting function.
- Artifact storage (S3 + signed URL, private + SSE) for non-JSON report output.
- Credential-refresh generic (countActiveByType) reading only a non-secret
  projection — never decrypts credential secrets.

Retire the silo: delete reporting-router, reporting/repositories/*,
use-cases/list-integrations-report, handlers/routers/reporting; collapse the
dedicated reporting API key into the admin API key.

Reviewed across three adversarial passes; confirmed findings fixed (artifact
dev-stage selection, DocumentDB truncation, execution-record compensation on
enqueue/requeue failure).

NOTE: adds @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner to
packages/core/package.json — package-lock.json must be regenerated with the
repo's canonical npm 10 before merge (do not commit an npm 11 lockfile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Reports (ADR-010) section to the frigg skill covering the reports registry,
ReportBase, run modes (live/recorded/snapshot), endpoints, artifact output, and
scheduling, with two examples: a snapshot+scheduled JSON report reading via the
frigg command bundle, and a recorded CSV report writing to artifact storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…isolation)

Follow-up to ADR-010 reporting, resolving verified code-review findings:

- Artifact retrieval: report execution + snapshot reads now mint a signed
  download URL (report-commands) so stored non-JSON artifacts are reachable
  via the API; snapshot artifactUrl is a real URL, not the raw storage ref.
- Async run validation: recorded/snapshot runs validate mode + inputSchema
  before persisting/enqueueing, returning 400 up front instead of a 202 that
  fails later in the worker.
- Isolation: admin-script findExecutionById now rejects REPORT rows,
  symmetric with report findExecutionById rejecting non-REPORT rows.
- Report stays protocol-agnostic: integrations-report throws a coded
  INVALID_INPUT error instead of Boom (no HTTP in the application layer).
- Add zip artifact content type; remove orphan findMany import; correct the
  local artifact adapter comment.
- Docs: clarify that a Definition-declared schedule only supplies the default
  mode; the recurring trigger is activated via PUT /:name/schedule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unwrap helper

Reduce PR-added code comments across the reporting stack so the code speaks
for itself (~700 -> ~290 comment lines), keeping only non-obvious "why":
invariants, ordering/consistency reasons, and gotchas. Removed route-path
JSDoc banners, architecture-tag headers, and param/flow restatements.
Comment-only changes; no runtime behavior altered.

Also drop the unwrap() helper in integrations-report: read commands directly
so the report body reads plainly. A failed read still fails the report (the
runner records FAILED), just without unwrap's synthesized message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ns-plan-5b607f

feat(core): implement ADR-010 reporting as an admin operation
PR #628 added @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner to
packages/core for report artifact storage, but the root package-lock.json
was never regenerated. The Release workflow's `npm ci` fails with EUSAGE
("Missing: @aws-sdk/s3-request-presigner ... from lock file"), so no
2.0.0-next.105 has published since #628 merged.

Regenerated with npm 10 (npm 11 lockfiles break `npm ci` in this repo's CI).
Verified: `npm ci --dry-run` now resolves cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ws-sdk

fix: sync package-lock.json with @aws-sdk artifact-storage deps
Migrate and redesign the friggframework.org marketing site under website/, add the on-site "Ask Freya" assistant running on a vendored Freya runtime with a Frigg-domain ontology and roadmap-retrieval tools, apply Frigg brand green, and exclude the vendored bundle from SonarCloud.
@netlify

netlify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy Preview for friggframework-org canceled.

Name Link
🔨 Latest commit 4686473
🔍 Latest deploy log https://app.netlify.com/projects/friggframework-org/deploys/6a8844102e242200090a61e9

d-klotz and others added 17 commits August 5, 2026 14:08
When a module reports CREDENTIAL_INVALIDATED, receiveNotification flipped the
integration to ERROR via persistStatus, which writes only the status column.
The reason reached the log line and nothing else, so the integration surfaced
to end users and support as broken with an empty errors array, diagnosable
only by a log query pinned to the flip timestamp.

In one adopter's production database this accounted for 74 of 333 ERROR
integrations (22%), accruing 1-4 per day across five different integration
types, each of them a genuine expired or revoked authorization.

receiveNotification now calls recordCredentialRejection first, reusing the
'Authentication Error' title and voice testAuth already writes so both the
passive check and the delegate produce a consistent diagnostic.

Two deliberate constraints. The delegate's `reason` is not persisted: it is
the FetchError message, which embeds the serialized request including the
Authorization header, since FetchError blanks that only when STAGE is not
dev — and these messages are shown to end users. Only the status code
crosses over. And the write is best-effort, so a failed diagnostic can never
prevent the status flip that stops processing on dead credentials, with
recording ordered first so a failed flip still leaves a cause.

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

Follow-ups from the two-axis review of this branch.

Standards axis:
- Rename recordCredentialRejection to _recordCredentialRejection. Every
  internal helper in this file is underscore-prefixed; an unprefixed name on a
  widely-subclassed base class claims subclass API surface it does not want.
- Extract _authErrorMessage, now the single source of the "credentials no
  longer work" wording. testAuth and the delegate had two near-identical
  copies of the same user-facing sentence in one file; they can no longer
  drift. This also normalizes the embedded newline testAuth's copy carried.
- Resolve the module name once in the delegate branch and pass it down, so the
  log line and the persisted message can never name the module differently.
  Drops the getName()/name walk over the notifier, and falls back to the
  payload's moduleName.
- Read naturally when no module name is resolvable ("your Entity") instead of
  rendering "your unknown Entity" to a user.

Spec axis found that two token-grant paths dropped the failure entirely:
getTokenFromUsernamePassword and getTokenFromClientCredentials called
notify(DLGT_INVALID_AUTH) with no payload from a bare `catch {}`, so the
delegate had no status code to persist. Both now forward the caught error.

Note the review characterized those as the token-refresh path; they are not.
refreshAccessToken has no notify site at all — its error propagates to
refreshAuth, which only logs and returns false. The refresh case reaches the
delegate via the next request's 401 in Requester._invalidateAuth, which
already passes a FetchError, which is why observed production flips do carry
a status code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Module.validateDefinition throws when moduleName is missing, and it runs in the
constructor before this.name is assigned, so no Module instance can carry a
falsy name. Delegate.notify always passes that instance as the notifier, and
testAuth's caller passes a constructed module too. Both fallbacks — the
'your Entity' branch and the payload's moduleName — were therefore dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Passing the caught error to notify(DLGT_INVALID_AUTH) leaked credentials.
The bodies of these two grant requests hold the raw password and the raw
client secret, FetchError embeds the request body in its message whenever
STAGE is dev, and both markCredentialsInvalid and receiveNotification log
that message. Before this branch these sites notified with no payload, so
the logs stayed empty; forwarding the error turned dev deployments into a
place where end-user passwords and client secrets reach CloudWatch.

Forward { statusCode } instead. The delegate only ever needed the status to
build its message, and the tests now assert the secret cannot reach the
payload rather than just asserting the shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
refreshAuth's docstring has always claimed "On failure, notifies delegates
via DLGT_INVALID_AUTH", but its catch only logged and returned false. Three
tests asserted the documented behaviour and had been red on next for as long
as they have existed.

It now notifies, forwarding { statusCode } rather than the caught error:
refreshAccessToken's body carries client_secret in a URLSearchParams that
FetchError explicitly stringifies into its message outside prod, so passing
the error would log the secret. Added a test that pins that.

The three stale assertions expected a bare notify(DLGT_INVALID_AUTH) with no
payload. That expectation was already wrong for the 401 retry test, which
reaches the delegate through Requester._invalidateAuth and has been passing a
FetchError since that method was introduced. All three now assert the payload.

Note this makes the delegate fire twice on the only path that calls
refreshAuth: Requester also calls _invalidateAuth when refreshAuth returns
false. The end state is identical since markCredentialsInvalid and the ERROR
flip are both idempotent, but it doubles the writes. Guarding
markCredentialsInvalid on credential.authIsValid === false would collapse
that, and would also damp the observed production case of one integration
firing this delegate six times in eight seconds — left for a separate change.

Core package: 1590 passing, up from 1586, with the 12 remaining suite
failures byte-identical to next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Making refreshAuth notify means the delegate now fires twice per refresh
failure, because Requester also calls _invalidateAuth when refreshAuth
returns false. Each firing wrote a message and a status, so a single failure
cost four writes for a state it had already reached.

Guard the CREDENTIAL_INVALIDATED branch on the integration already being in
ERROR, mirroring the CREDENTIAL_VALIDATED branch's existing
`if (this.status !== 'ERROR') return`.

Deliberately guarded here and not in Module.markCredentialsInvalid on
`credential.authIsValid === false`, which was the first suggestion. Two
integrations can share one Credential row — observed in production, where two
Zoho integrations shared credential 12369. A credential-level guard would let
the first integration's 401 mark the row invalid, then return early for the
second, leaving it ENABLED with a dead credential: the same silent-broken
state this branch exists to fix. Integration status is per-integration, so
the guard belongs here. A test pins that a second integration sharing the
credential still flips.

This does not deduplicate concurrent invocations, which each hydrate their own
instance and read status ENABLED before any write lands. It removes the
sequential duplicate, which is the guaranteed one.

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

fix(core): record why credentials were rejected before flipping to ERROR
A production Frigg app silently orphaned 95 records over 4 months. The
trigger was a cross-invocation credential rotation race: separate Lambda
invocations each hydrate the credential into memory, refresh without
re-reading it, and persist with a blind last-writer-wins upsert. The
loser presents an already-rotated refresh token, gets invalid_grant,
flags the shared credential invalid, and the queue worker then silently
acks every subsequent message.

PR #636 shipped a per-instance single-flight refresh; it cannot reach a
race between invocations. No existing ADR covers refresh concurrency
(nearest: ADR-005 names token refresh as an admin utility, ADR-006
defines credential endpoints, ADR-009 flags refresh-on-401 as a test
gap).

The proposed decision is a layered stack: failure containment in the
invalidation and worker paths, loser recovery by re-read-and-adopt,
compare-and-swap on the credential write, core-owned token expiry with
a narrow pre-flight gate, declared per-module rotation semantics, and
optional jitter. Provable serialization (SQS FIFO per credential) is
deferred behind a named metric. The maintainer's three candidate
designs (central token service, DB-event subscription, per-API re-read
flag with a dual-write cache) are evaluated in Alternatives Considered;
the re-read half of the third is adopted, the rest rejected on
mechanism.

Status is Proposed. One open question is named for ratification, plus
the default-on exception to ADR-027's opt-in bar. Numbered 031 because
028-030 are claimed by drafts on unmerged branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same decision, same facts, same code citations, same numbers. Only the
language changes: short sentences, active voice, one idea per sentence,
and a terms list at the top. A checklist pass verified that every code
reference, measurement, and count from the previous revision is present
in the new text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ter updates

UpdateProcessMetrics wrote twice per call: phase 1 applied counter
increments atomically via applyProcessUpdate (jsonb_set), then phase 2
persisted derived fields (duration, recordsPerSecond,
estimatedCompletion) by writing the WHOLE context/results blobs back
through the legacy update(). Under concurrent callers a stale phase-2
write replayed its phase-1 snapshot over the row and permanently
discarded increments other callers had landed in between.

Observed in production (HubSpot<->Clockwork initial sync, 88 concurrent
batch handlers): processedRecords ended exactly one 25-record chunk
short of totalFetched, so the completion gate (processed >= totalFetched)
never fired and the sync sat in PROCESSING_BATCHES forever. A simulation
against real Postgres lost 1025 of 2200 increments under an 88-way burst
with the old code, and 0 with this fix.

Phase 2 now writes only the three derived paths it owns through the same
atomic primitive. The derived values stay best-effort under concurrency,
same as before; they just can no longer destroy counters. All three
repository backends (postgres, mongo, documentdb) already support the
set op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s-lost-update

fix(core): write derived process metrics atomically to stop lost counter updates
Responds to the review on PR #637 (changes requested):

- Cut from 1027 to 337 lines (~67%).
- Restructured around the review's four options. The decision is
  options 4 and 2 together: a reactive database check before refresh
  and on 401 as the mechanism, and proactive scheduled refresh via the
  admin scripts as the companion.
- Folded in both inline review findings: the adoption test keys on the
  refresh token (not the access token), and only definitive
  authorization rejections may invalidate a credential — transport
  failures (timeout, 429, 5xx) stay retryable.
- Addressed the database-propagation caveat with the measured 716ms
  window, bounded re-read backoff, and the readPreference=primary
  requirement.
- Compare-and-swap moved from load-bearing to deferred hardening. The
  serialization family stays deferred behind the named metric.
- The full prior analysis remains available at commit 42d1e6f.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scheduled-refresh companion no longer requires core changes. The
script hydrates each module and calls refreshAuth() serially; the
existing setTokens -> DLGT_TOKEN_UPDATE -> onTokenUpdate ->
upsertCredential chain persists the result with no new code. Expiry
tracking (the persist/hydrate work and the null-expires_in computation
fix) moves from prerequisite to optional later optimization, taken only
if the bounded waste of blind refreshes ever matters.

Option 4 is unchanged: the database lookup before any refresh, and the
re-read-and-adopt on 401/invalid_grant, remain the mechanism and ship
first. The sequence shrinks from three PRs to two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ential-refresh

ADR-031: Concurrent OAuth Credential Refresh Across Lambda Invocations
npm audit on `next` reported 68 advisories (3 critical / 34 high /
27 moderate / 4 low). Splitting dev from production showed the
published surface accounted for only 10 of them, and every one had a
patch available inside its existing major version.

Several entries in the root `overrides` block had gone stale: the
pinned version was correct when it was added, but later advisories
widened the vulnerable range past it. Most notably velocityjs was
pinned at 2.1.6 while the critical RCE advisory covers <=2.1.6, so
the override was holding the tree on a vulnerable version.

Stale pins bumped:
  fast-uri    3.1.2  -> 3.1.5   (<=3.1.4)
  ip-address  10.2.0 -> 10.5.0  (<=10.3.0)
  velocityjs  2.1.6  -> 2.1.7   (<=2.1.6, critical RCE)
  js-yaml     4.2.0  -> 4.3.1   (4.0.0-4.3.0, scoped overrides)

New pins:
  nanoid           3.3.18  (<=3.3.17)
  postcss          8.5.26  (<=8.5.22)
  body-parser      1.20.6  (<1.20.6)
  js-yaml@4        4.3.1
  js-yaml@3        3.15.1  (<=3.15.0)
  brace-expansion@1 1.1.18 (<=1.1.17)
  brace-expansion@2 2.1.4  (2.0.0-2.1.3)
  undici@6         6.28.0  (<=6.27.0)
  undici@7         7.29.0  (7.0.0-7.28.0)

js-yaml and brace-expansion are pinned per major line rather than
globally: both have multiple majors in the tree, and collapsing them
would force js-yaml 3.x consumers onto v4 (which dropped safeLoad)
and downgrade brace-expansion 5.x consumers.

ajv, socks and @istanbuljs/load-nyc-config were flagged only for
depending on vulnerable fast-uri / ip-address / js-yaml, so they
clear without a direct change.

Result: `npm audit --omit=dev` goes from 10 to 0. Full tree drops
68 -> 59; everything remaining is build/release tooling behind a
major upgrade (lerna 8->10, nx 20->23, serverless-offline, prisma,
vite) and is tracked separately.

website/ has its own lockfile and already audits clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdchVmNyYEzYD9rBfRugko
…ss-ywp0of

fix(deps): clear all production dependency vulnerabilities
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
E Reliability Rating on New Code (required ≥ A)
E Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

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.

5 participants