---
title: "Replace a score definition's config"
method: PUT
path: "/api/v1/ackdb/scores/{scoreKey}"
tags: ["ackDB"]
---

# Replace a score definition's config

`PUT /api/v1/ackdb/scores/{scoreKey}`

## Request

- Base URL: `http://localhost:3009`
- URL: `http://localhost:3009/api/v1/ackdb/scores/{scoreKey}`
- Auth: HTTP bearer

Full replace of the mutable fields (name, description, groups, criteria, normalization, labels, isPrimary, enabled). `scoreKey` and `grain` are immutable. Disabling a previously-enabled definition removes its columns and transactionally scrubs referencing saved segments (the response's `scrubbedSegments` reports how many). Any successful edit clears `degraded`.

## Path parameters

- `scoreKey` string, required

## Query parameters

- `grain` 'company' | 'person'

## Request body

- AckdbScoreDefinitionInput: Create/replace body for a score definition.
  - `scoreKey` string, required: Immutable slug → the sc__<scoreKey> column. Single underscores only.
  - `name` string, required
  - `description` string, nullable
  - `grain` 'company' | 'person', required: Immutable after create.
  - `groups` object[], required: Weighted criterion groups; weights must sum to 1 (±0.01).
    - `key` string, required
    - `weight` number, required
  - `criteria` AckdbScoreCriterion[], required
    - `group` string, required: Key of the group this criterion belongs to.
    - `field` string, required
    - `label` string: Optional display label for explain/UI.
    - `match` object[]: Value → points entries (first match wins). op: eq | in | present. Legal on value-shaped refs (trait/attribute/identity_link/company:) and, since #438, on `agg: "latest"` event refs: the one aggregate that reduces to a VALUE rather than a volume, so it compares the `valueField` as text where bands/perUnitPoints cast it to numeric. Counting aggregates (count/sum/distinct_contacts) and `contacts` reject match (400), since comparing a count against numbers is what bands is for. Exactly one of match | bands | perUnitPoints(+cap) per criterion. On event metadata the comparison is EXACT and case-sensitive (see /scores/capabilities, `eventValueMatching`): trait values canonicalize case-insensitively (#380), event fields deliberately do not, so seed value pickers from /fields/{key}/values instead of accepting free text.
      - `op` 'eq' | 'in' | 'present', required
      - `values` unknown[]
        - unknown
      - `points` number, required
    - `bands` object[]: Numeric threshold bands (first match wins). Exactly one of gte/lt per band. Points are ±100, the same bound match entries and perUnitPoints carry.
      - `gte` number
      - `lt` number
      - `points` number, required
    - `agg` 'count' | 'sum' | 'distinct_contacts' | 'latest': Aggregate reducer (event: refs; default count). distinct_contacts is company-grain only. latest (#424) scores the CURRENT value carried in event metadata — the `valueField` of the most recent matching event — instead of a volume.
    - `sumField` string: Promoted NUMERIC metadata field — required when agg=sum.
    - `valueField` string: Promoted metadata field read off the most recent matching event — REQUIRED when agg=latest, rejected otherwise (#424). Must be promoted on the criterion's event type (entity_events.metadata holds promoted fields only). Its value is cast to numeric; a non-numeric value scores 0 points.
    - `windowDays` integer: Trailing window; absent = all-time. REQUIRED on convergence, with a tighter 1–365 bound.
    - `halfLifeDays` integer: Exponential decay half-life (weight = 2^(−age/halfLife)). Not with distinct_contacts or latest.
    - `perUnitPoints` number: Linear points per unit (with cap).
    - `cap` number: Symmetric clamp for perUnitPoints (0 < cap ≤ 100). Required with it.
    - `filter` object: contacts criterion only: persona filter.
      - `titleContains` string[]
      - `activeWithinDays` integer
    - `sources` string[]: convergence criterion only: the distinct sources counted (≥2, each must exist in the schema registry). Double counting with per-source `event:` criteria is intended — convergence is the multiplier on top of per-source activity. Exactly one of `sources` | `signals`.
    - `signals` string[]: convergence criterion only (#421): user-composed signal list — `event:<source>.<bareType>` refs (2–20, deduped, each must resolve in the schema registry; person grain needs person-scope types). The criterion value is how many listed signals had ≥1 event in the window — a signal firing 40× counts once. Exactly one of `sources` | `signals`.
  - `normalization` 'absolute' | 'percentile': absolute (default): 100 × points / max possible, clamped. percentile: rank within THIS grain's population (strictly-below semantics — a no-data row is the 0th percentile).
  - `percentilePopulation` 'all' | 'active': Percentile definitions only (#432) — 400 with absolute normalization. all (default): rank against the whole grain population (today's behavior). active: rank only against rows where at least one non-trait criterion fired (an event:/convergence/contacts/company: criterion contributed nonzero points) — on sparse books the empty rows stop being the yardstick. Excluded rows still serve a score of 0 (never NULL) and take whatever lt label band covers 0.
  - `labels` object[]: Label bands over the normalized 0–100 score (first match wins).
    - `gte` number
    - `lt` number
    - `label` string, required
  - `scopeSegmentId` string, nullable: SCOPE (#443): score ONLY the members of this saved segment. Absent/null = the whole grain (every definition before this feature). Rows outside the segment read NULL in sc__<key> and sc__<key>__label — not 0 — and are excluded from the percentile population, the preview histogram and the history sweep. ANY segment grain is legal for either score grain: a contact/person segment scoping a company score means 'the companies of those people', a company segment scoping a person score means 'the people with an active edge at those companies'. Derived segments are legal. 400s when the segment does not exist, or when its filter (or its base's) references a SCOPED score — a scored population may only be defined by whole-grain scores, which is what makes a scoring cycle impossible.
  - `isPrimary` boolean: One primary per grain (server-enforced hand-off).
  - `enabled` boolean

## Response `200`

The updated definition (+ scrubbedSegments).

- AckdbScoreDefinition: Create/replace body for a score definition.
  - `scoreKey` string, required: Immutable slug → the sc__<scoreKey> column. Single underscores only.
  - `name` string, required
  - `description` string, nullable
  - `grain` 'company' | 'person', required: Immutable after create.
  - `groups` object[], required: Weighted criterion groups; weights must sum to 1 (±0.01).
    - `key` string, required
    - `weight` number, required
  - `criteria` AckdbScoreCriterion[], required
    - `group` string, required: Key of the group this criterion belongs to.
    - `field` string, required
    - `label` string: Optional display label for explain/UI.
    - `match` object[]: Value → points entries (first match wins). op: eq | in | present. Legal on value-shaped refs (trait/attribute/identity_link/company:) and, since #438, on `agg: "latest"` event refs: the one aggregate that reduces to a VALUE rather than a volume, so it compares the `valueField` as text where bands/perUnitPoints cast it to numeric. Counting aggregates (count/sum/distinct_contacts) and `contacts` reject match (400), since comparing a count against numbers is what bands is for. Exactly one of match | bands | perUnitPoints(+cap) per criterion. On event metadata the comparison is EXACT and case-sensitive (see /scores/capabilities, `eventValueMatching`): trait values canonicalize case-insensitively (#380), event fields deliberately do not, so seed value pickers from /fields/{key}/values instead of accepting free text.
      - `op` 'eq' | 'in' | 'present', required
      - `values` unknown[]
        - unknown
      - `points` number, required
    - `bands` object[]: Numeric threshold bands (first match wins). Exactly one of gte/lt per band. Points are ±100, the same bound match entries and perUnitPoints carry.
      - `gte` number
      - `lt` number
      - `points` number, required
    - `agg` 'count' | 'sum' | 'distinct_contacts' | 'latest': Aggregate reducer (event: refs; default count). distinct_contacts is company-grain only. latest (#424) scores the CURRENT value carried in event metadata — the `valueField` of the most recent matching event — instead of a volume.
    - `sumField` string: Promoted NUMERIC metadata field — required when agg=sum.
    - `valueField` string: Promoted metadata field read off the most recent matching event — REQUIRED when agg=latest, rejected otherwise (#424). Must be promoted on the criterion's event type (entity_events.metadata holds promoted fields only). Its value is cast to numeric; a non-numeric value scores 0 points.
    - `windowDays` integer: Trailing window; absent = all-time. REQUIRED on convergence, with a tighter 1–365 bound.
    - `halfLifeDays` integer: Exponential decay half-life (weight = 2^(−age/halfLife)). Not with distinct_contacts or latest.
    - `perUnitPoints` number: Linear points per unit (with cap).
    - `cap` number: Symmetric clamp for perUnitPoints (0 < cap ≤ 100). Required with it.
    - `filter` object: contacts criterion only: persona filter.
      - `titleContains` string[]
      - `activeWithinDays` integer
    - `sources` string[]: convergence criterion only: the distinct sources counted (≥2, each must exist in the schema registry). Double counting with per-source `event:` criteria is intended — convergence is the multiplier on top of per-source activity. Exactly one of `sources` | `signals`.
    - `signals` string[]: convergence criterion only (#421): user-composed signal list — `event:<source>.<bareType>` refs (2–20, deduped, each must resolve in the schema registry; person grain needs person-scope types). The criterion value is how many listed signals had ≥1 event in the window — a signal firing 40× counts once. Exactly one of `sources` | `signals`.
  - `normalization` 'absolute' | 'percentile': absolute (default): 100 × points / max possible, clamped. percentile: rank within THIS grain's population (strictly-below semantics — a no-data row is the 0th percentile).
  - `percentilePopulation` 'all' | 'active': Percentile definitions only (#432) — 400 with absolute normalization. all (default): rank against the whole grain population (today's behavior). active: rank only against rows where at least one non-trait criterion fired (an event:/convergence/contacts/company: criterion contributed nonzero points) — on sparse books the empty rows stop being the yardstick. Excluded rows still serve a score of 0 (never NULL) and take whatever lt label band covers 0.
  - `labels` object[]: Label bands over the normalized 0–100 score (first match wins).
    - `gte` number
    - `lt` number
    - `label` string, required
  - `scopeSegmentId` string, nullable: SCOPE (#443): score ONLY the members of this saved segment. Absent/null = the whole grain (every definition before this feature). Rows outside the segment read NULL in sc__<key> and sc__<key>__label — not 0 — and are excluded from the percentile population, the preview histogram and the history sweep. ANY segment grain is legal for either score grain: a contact/person segment scoping a company score means 'the companies of those people', a company segment scoping a person score means 'the people with an active edge at those companies'. Derived segments are legal. 400s when the segment does not exist, or when its filter (or its base's) references a SCOPED score — a scored population may only be defined by whole-grain scores, which is what makes a scoring cycle impossible.
  - `isPrimary` boolean: One primary per grain (server-enforced hand-off).
  - `enabled` boolean
  - `degraded` boolean: True when a source deletion scrubbed criteria out of this definition, or (#443) when its scope segment was deleted (the definition is also disabled) or widened by a scrub. Any successful edit clears it.
  - `scopeSegmentName` string, nullable: Scope segment display name, hydrated on reads (#443). Null when the definition is unscoped OR its scope segment has been deleted — a non-null scopeSegmentId with a null name is the dangling-scope state (the definition is disabled + degraded).
  - `scopeSegmentKind` 'company' | 'contact' | 'person', nullable: Scope segment home grain, hydrated on reads (#443). Null when unscoped/dangling.
  - `createdAt` string, date-time
  - `updatedAt` string, date-time

## Other responses

- `400`: Validation failed / immutable field changed.
- `404`: No such definition.

---

[API](https://skmtc.dev/floqer/apis/floqer-public-api.md) - [All operations](https://skmtc.dev/floqer/apis/floqer-public-api/llms.txt) - [OpenAPI document](https://skmtc.dev/floqer/apis/floqer-public-api/revisions/fce50dc07b20?raw)
