@sonordev/contracts 2.7.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.8.0
4
+
5
+ A minor release: two new entries, `@sonordev/contracts/business-claims` and `@sonordev/contracts/outreach`. Every other entry is unchanged.
6
+
7
+ - **New entry `@sonordev/contracts/business-claims`**: what an outbound email or text may say about a business, checked the same way where it's written and where it's sent.
8
+ - `checkOutboundCopy(copy, ctx)` sorts what it finds into `blocking` (refused outright), `flags` (a person looks before it sends) and `style` (never blocks), and returns the body as the reader sees it in `plain`. It reads the copy sentence by sentence after rendering HTML to text and decoding entities once.
9
+ - `PLATFORM_BANNED_CLAIMS` lists the claims no business's outreach may make. A business adds its own through `ctx.rules`.
10
+ - `OutboundClaimReview`, `readOutboundClaimReview` and `unsettledFlags` are the stored record of a check and the rule for when it's settled. `outboundRefusalMessage` is the one wording of a refusal.
11
+ - `readBackVerdict(intended, read)` says whether a sent email arrived intact.
12
+ - The quote, price and number helpers (`normalizeForQuote`, `quoteFoundIn`, `checkQuotedPrice`, `numbersGrounded`), `decodeHtmlEntities`, `removeDashes`, `outboundPlainText` and `isWriterNote` are exported so nothing re-implements them.
13
+ - **New entry `@sonordev/contracts/outreach`**: what a segment of outreach is and who may turn one on.
14
+ - `outreachSegment`, `parseOutreachSegmentKey`, `isGrantableSegmentKey` and `outreachSegmentLabel` build, read and label segment keys. `prospectIndustry` and `normalizeIndustry` decide the industry, and `narrativeFingerprint` the pitch.
15
+ - `SegmentGrant`, `readSegmentGrants`, `activeSegmentGrant`, `segmentGrantCovers` and `grantEvidenceRefusal` read a person's standing approval of a segment and check it against the approvals it stands on. `GRADUATION_BAR` is the one bar a segment earns its own sending by.
16
+ - `segmentGranterEligible`, `PUBLIC_MAILBOX_DOMAINS`, `isPublicMailboxDomain` and `dripRowDisposition` round it out.
17
+
3
18
  ## 2.7.1
4
19
 
5
20
  - Preserve article photo source identity during duplicate detection when a responsive image optimizer rewrites the displayed URL.
package/README.md CHANGED
@@ -32,6 +32,8 @@ pnpm add @sonordev/contracts
32
32
  | `@sonordev/contracts/schema-placeholders` | Whether stored JSON-LD carries a template's unfilled slots (`example.com` URLs, an `Organization` named "Example", a placeholder phone, `[Resident Name]`, `{plan.name}`), and the value without them | site-kit's schema output, the Sonor API's schema reads and writes |
33
33
  | `@sonordev/contracts/sentences` | Where prose splits into sentences, without dropping a word or ending one at "p.m." (`splitSentences`) | anything that cuts an excerpt, a social slide or a summary out of an article: the Sonor API's carousels, Signal's article pipeline |
34
34
  | `@sonordev/contracts/knowledge` | Which Sonor records Signal reads into a project's knowledge, the address each one's knowledge is stored under (`portfolio://<id>`), and the limits of the call that tells Signal a site's pages changed | the Sonor API sends those calls and Signal answers them |
35
+ | `@sonordev/contracts/business-claims` | What an outbound email or text may say about a business (`checkOutboundCopy`), the stored review of what it claimed and when that review is settled, and whether a sent email arrived intact (`readBackVerdict`) | the Sonor API checks again on the bytes it sends, Signal checks what its writers and agents file |
36
+ | `@sonordev/contracts/outreach` | What a segment of outreach is (pitch, sending mailbox, prospect industry), who may turn one on, what a turned-on segment covers, and what a drip's ledger row means for its lead | Signal writes and reads segment grants, the Sonor API checks the same grant before it sends |
35
37
 
36
38
  ## Schema placeholders
37
39
 
@@ -418,6 +420,127 @@ const batch = [...removed, ...changed].slice(0, KNOWLEDGE_SYNC_MAX_PAGES)
418
420
  // removals first, so the cut drops changed pages before any removal
419
421
  ```
420
422
 
423
+ ## The business-claims contract
424
+
425
+ An email that goes out under a business's name can't promise what the
426
+ business never said. Every writer (a model, an agent, a person's own
427
+ template) and every sender checks copy with the same function, so a
428
+ sentence refused where it's written is refused again where it's sent.
429
+
430
+ ```ts
431
+ import { checkOutboundCopy } from '@sonordev/contracts/business-claims'
432
+
433
+ const check = checkOutboundCopy(
434
+ { subject: 'A quick idea for your site', body, format: 'text', channel: 'email' },
435
+ { author: 'signal', rules, claims },
436
+ )
437
+ check.blocking // refused outright
438
+ check.flags // needs a person's look before it sends
439
+ check.style // house style, never blocks
440
+ check.plain // the body as the reader sees it
441
+ ```
442
+
443
+ - **Who wrote it.** `author` is `'signal'` (copy a model wrote), `'agent'`
444
+ (an agent's own copy, the strictest set) or `'operator'` (a person's fixed
445
+ template). Operator copy gets the blocking set only. An author it doesn't
446
+ know is checked as an agent.
447
+ - **Blocking.** An empty subject or body, a merge tag that never rendered
448
+ (`{{first_name}}`, `[Company]`), a claim no business may make
449
+ (`PLATFORM_BANNED_CLAIMS`: guaranteed rankings, top-3 or map pack spots
450
+ or results, "#1 on Google",
451
+ "risk free", fake scarcity, implied Google endorsement, and any promise
452
+ that they'll pay nothing or get their money back), the business's own
453
+ banned claims and the pitch's forbidden phrases, a price that matches none
454
+ the business publishes, and a word that mixes alphabets. An agent's own copy
455
+ is also refused for dashes and for unsafe HTML.
456
+ - **Flags.** A price it can't verify, a number nothing backs, a citation that
457
+ doesn't match what it cites, a banned claim that a negation takes back, a
458
+ note the writer left to itself, and a number an agent took from its
459
+ research. Research about the recipient backs a number only in a sentence
460
+ about the recipient (`claimSubjectOf`). It never backs one about the sender.
461
+ A clause that names nobody ("the measured performance score is 61") counts
462
+ as the recipient's only when their research carries that number, and a
463
+ subject line may repeat a number the body backs that way.
464
+ - **Rules come from the caller.** `rules` carries the business's published
465
+ prices, banned claims, forbidden phrases, verified facts and its own names,
466
+ resolved before the call, so no business data lives in the package. Price
467
+ and banned-claim rules run only when `rules` is set.
468
+ - **Findings are stable.** Each finding's `id` is a hash of its code, its
469
+ sentence and the match (`outboundFindingId`), so a person's acknowledgement
470
+ of one sentence never carries over to a rewritten one.
471
+ - **The stored review.** `OutboundClaimReview` is what was checked, against
472
+ which rules, and which flags a person acknowledged.
473
+ `readOutboundClaimReview(value)` reads a stored one defensively, and
474
+ `unsettledFlags(review)` lists the flags nobody has settled yet. A sender
475
+ refuses while that list isn't empty.
476
+ - **The refusal.** `outboundRefusalMessage(findings)` is the one wording for
477
+ what's wrong and how to fix it, numbered and plain.
478
+ - **Read-back.** `readBackVerdict(intended, read)` compares the email a
479
+ mailbox says it sent with the one that was meant: `verified` only when the
480
+ whole intended text arrived (a footer added after it is fine), otherwise
481
+ `empty`, `mismatch` or `unreadable`.
482
+ - **Shared helpers.** The same normalizers the checker uses are exported for
483
+ anything that grounds a quote, price or number in a source
484
+ (`normalizeForQuote`, `quoteFoundIn`, `checkQuotedPrice`,
485
+ `numbersGrounded`), plus `decodeHtmlEntities`, `removeDashes`,
486
+ `outboundPlainText` and `isWriterNote`.
487
+
488
+ `BUSINESS_CLAIMS_CONTRACT_VERSION` is bumped when the stored review's shape
489
+ changes incompatibly.
490
+
491
+ ## The outreach contract
492
+
493
+ Outreach earns the right to send on its own one segment at a time. A segment
494
+ is one pitch, sent from one mailbox, to one kind of business. Both the API
495
+ that writes outreach and the one that sends it read segments by these rules,
496
+ so they can't disagree about what a person turned on.
497
+
498
+ - **Segment keys.** `outreachSegment({ source, narrativeId, mailboxId,
499
+ industry })` builds one: `drip:<pitch id>@<mailbox id>|<industry>` for a
500
+ drip, or `mcp:-|<industry>` for an agent's one-off email, which is counted
501
+ but never turned on. `parseOutreachSegmentKey(key)` reads a key strictly
502
+ and returns `null` for anything else, so a caller-chosen key never reaches
503
+ storage. `isGrantableSegmentKey(key)` is true only for a drip segment with a
504
+ known industry. `outreachSegmentLabel` is the one human label.
505
+ - **Industry.** `prospectIndustry(prospect)` picks the prospect's industry
506
+ and `normalizeIndustry(value)` folds it to one key ("Roofing Contractors"
507
+ and "roofing" are the same segment). A prospect with none is
508
+ `UNSPECIFIED_INDUSTRY`, and those always wait for a person.
509
+ - **The pitch's fingerprint.** `narrativeFingerprint(narrative)` hashes the
510
+ pitch fields only (`NARRATIVE_PITCH_FIELDS`). Rewriting the pitch changes
511
+ it and lapses the segment. Renaming it, switching it off and on, or
512
+ tightening its forbidden phrases doesn't.
513
+ - **States.** `SEGMENT_STATES` is `ungranted`, `granted`, `paused` (it
514
+ stopped itself), `lapsed` (the pitch changed) and `revoked`.
515
+ - **Grants.** `SegmentGrant` is a person's standing approval of one segment,
516
+ either from approving its first email unedited (`first_email`) or by
517
+ accepting it once the segment earned it (`earned`).
518
+ `readSegmentGrants(settings)` and `activeSegmentGrant(settings, key)` read
519
+ them. `segmentGrantCovers(grant, row)` says whether a grant covers one
520
+ email: not when it's revoked or paused, names someone else, was written
521
+ before the grant, has a different pitch fingerprint or sends from another
522
+ mailbox.
523
+ - **Evidence.** `grantEvidenceRefusal(grant, rows, scope)` checks the
524
+ approvals a grant stands on and returns why it can't be trusted, or `null`.
525
+ A grant that fails counts as no grant, so one written into settings by hand
526
+ never sends anything.
527
+ - **The bar.** `GRADUATION_BAR` is the one track record that earns sending
528
+ without a click per email: at least 5 measured, at least 70% of them won,
529
+ and none that went wrong without being undone. An earned grant needs
530
+ `GRADUATION_BAR.minMeasured` approvals on record.
531
+ - **Who may turn one on.** `segmentGranterEligible(roles)`: never an agent;
532
+ otherwise an owner or admin of the project, its organization or the agency
533
+ that manages it.
534
+ - **Company domains.** `PUBLIC_MAILBOX_DOMAINS` and
535
+ `isPublicMailboxDomain(value)`: free mailbox providers, which never count as
536
+ a company's domain.
537
+ - **A drip's row.** `dripRowDisposition(row)` says what a drip's ledger row
538
+ means for its lead: `live`, `sent`, `retire`, `declined` or `refile`. A
539
+ status it doesn't know reads as `live`, so a lead is never written to twice.
540
+
541
+ `OUTREACH_CONTRACT_VERSION` is bumped when a grant's shape or the segment key
542
+ changes incompatibly.
543
+
421
544
  ## Why it exists
422
545
 
423
546
  A rule that lives in several copies is how two of them end up disagreeing.
@@ -0,0 +1,456 @@
1
+ /** Bumped when a stored shape here (OutboundClaimReview) changes incompatibly. */
2
+ export declare const BUSINESS_CLAIMS_CONTRACT_VERSION = 1;
3
+ /**
4
+ * Decode HTML character references in one pass: named ones (the table
5
+ * above), decimal (&#39;) and hex (&#x27;). One pass, so a double-encoded
6
+ * "&amp;#39;" comes back as the literal "&#39;" it was written as, never as
7
+ * "'". An invalid code point (zero, a surrogate, past U+10FFFF) is left as
8
+ * written, and &#160; is a plain space.
9
+ */
10
+ export declare function decodeHtmlEntities(text: string): string;
11
+ /** A quote shorter than this (after normalizeForQuote) grounds nothing. */
12
+ export declare const MIN_QUOTE_CHARS = 12;
13
+ /**
14
+ * Text as a quote is compared: entities decoded (named, decimal and hex),
15
+ * compatibility forms folded (… is ...), invisible characters removed, curly
16
+ * quotes straight, every dash a hyphen with no space around it, "$ 40" as
17
+ * "$40", whitespace collapsed, lowercase. Applied to both sides, so a quote
18
+ * copied off the page matches however the page spelled its punctuation, and
19
+ * nothing else does.
20
+ */
21
+ export declare function normalizeForQuote(text: unknown): string;
22
+ /**
23
+ * The form every claim matcher reads: normalizeForQuote (NFKC, invisible
24
+ * characters removed, entities decoded, lowercase). Use it on a raw string
25
+ * you want to compare the way checkOutboundCopy does.
26
+ */
27
+ export declare function normalizeMatchable(text: unknown): string;
28
+ /**
29
+ * Where `needle` occurs in `haystack` as a whole span, or -1: never the start
30
+ * or the tail of a longer number ("$90" inside "$900", "$4,000" inside
31
+ * "$4,000,000", "200" inside "$1,200") and never a word cut in half. Both
32
+ * sides are already normalized (normalizeForQuote).
33
+ */
34
+ export declare function wholeSpanAt(haystack: string, needle: string): number;
35
+ /**
36
+ * Whether `quote` appears in the source text, verbatim after normalizeForQuote
37
+ * and as a whole span (wholeSpanAt: a quote that stops in the middle of a
38
+ * number or a word isn't the source's words). `normalizedSource` is already
39
+ * normalized (each source is normalized once). A quote shorter than
40
+ * `minChars` grounds nothing. A price keeps its punctuation here, so
41
+ * "$4,000" never matches "4 000".
42
+ */
43
+ export declare function quoteFoundIn(quote: unknown, normalizedSource: string, minChars?: number): boolean;
44
+ /** Dollar amounts in normalized text: "$4,000" is 4000, "$4k" is 4000, "$12.50" is 12.5. */
45
+ export declare function moneyAmounts(normalized: string): number[];
46
+ /**
47
+ * Words just before a price that make it a floor, a ceiling or one end of a
48
+ * range. A dash or "to" counts only after a number ("$1,000-$3,000"), never
49
+ * after a name ("Essential - $1,000" is a price list, not a range).
50
+ */
51
+ export declare const PRICE_RANGE_BEFORE: RegExp;
52
+ /** Just after a price: "+", or the other end of a range. */
53
+ export declare const PRICE_RANGE_AFTER: RegExp;
54
+ /**
55
+ * An offer's price checked against its quote: the price must appear in the
56
+ * quote exactly as written (after normalizeForQuote), as a whole span
57
+ * (wholeSpanAt: "$90" never passes against "$900"), and be a dollar amount or
58
+ * "free". A price that's one end of what the quote says ("$2,000" out of
59
+ * "$2,000 - $9,000", "$500" out of "starting at $500", "$25,000" out of
60
+ * "$25,000+") fails: the display has to carry the whole span. Returns min/max
61
+ * read from the amounts, or null when the price fails.
62
+ */
63
+ export declare function checkQuotedPrice(display: unknown, quote: unknown): {
64
+ min?: number;
65
+ max?: number;
66
+ } | null;
67
+ /** The numbers written in normalized text, commas and trailing punctuation dropped: "1,200." is "1200". */
68
+ export declare function numbersIn(normalized: string): string[];
69
+ /** Every number written in `value` also appears in the quote: a claim can't carry a figure its source doesn't. */
70
+ export declare function numbersGrounded(value: unknown, quote: unknown): boolean;
71
+ /**
72
+ * House style: no em or en dashes in anything a person reads. Takes them out
73
+ * of generated copy and stored values.
74
+ *
75
+ * - A range keeps a hyphen: a number range ("3-4 beds"), a price range
76
+ * ("$2,000 - $9,000"), a time range ("9:00 AM - 6:00 PM"), and an unspaced
77
+ * en dash between words ("Mon-Fri").
78
+ * - Any other em or en dash between words becomes a comma ("great light, call
79
+ * me").
80
+ *
81
+ * Never run a verbatim quote through this: a quote stays as it was written.
82
+ */
83
+ export declare function removeDashes(text: string): string;
84
+ /** Whether a sentence reads as a note about how the copy was written (its instructions or evidence), not copy for the reader. */
85
+ export declare function isWriterNote(text: unknown): boolean;
86
+ export type OutboundFormat = 'html' | 'text';
87
+ export type OutboundChannel = 'email' | 'sms';
88
+ /**
89
+ * The text a reader sees in an email body, one line per block.
90
+ *
91
+ * - html: scripts, styles and comments dropped; source whitespace collapsed;
92
+ * a line break at br, p, li, div, headings, rows and other blocks; a link
93
+ * `<a href="u">t</a>` as "t (u)" unless t is the address itself; an
94
+ * image's alt and title text, and any other element's title, on their own
95
+ * line; inline tags removed without a space (so "guaran<b></b>teed" is one
96
+ * word); entities decoded once; spaces collapsed and empty lines dropped.
97
+ * - text: entities decoded once, nothing else.
98
+ */
99
+ export declare function outboundPlainText(body: unknown, format: OutboundFormat): string;
100
+ /**
101
+ * Who wrote the copy, stamped once when the row is created
102
+ * (payload.claim_author) and never derived from who filed it:
103
+ * - signal: Signal's own generators, however they were asked;
104
+ * - agent: an MCP agent's own words;
105
+ * - operator: a fixed template or a person's own copy.
106
+ */
107
+ export type OutboundAuthor = 'signal' | 'agent' | 'operator';
108
+ export declare const OUTBOUND_AUTHORS: ReadonlyArray<OutboundAuthor>;
109
+ /** Refused outright (400 outbound_copy_refused), at filing and again at send. */
110
+ export type OutboundBlockCode = 'empty_subject' | 'empty_body' | 'unrendered_token' | 'platform_banned_claim' | 'project_banned_claim' | 'forbidden_phrase' | 'price_mismatch' | 'mixed_script' | 'dash' | 'unsafe_markup' | 'prompt_control_token';
111
+ /** Needs a person's look (or, for an agent's own copy, is reported back): forces propose. */
112
+ export type OutboundFlagCode = 'price_unverifiable' | 'unsupported_number' | 'citation_invalid' | 'citation_mismatch' | 'citation_wrong_source' | 'banned_claim_negated' | 'writer_note' | 'number_from_research' | 'audit_flag' | 'audit_not_run';
113
+ /** House style. Never blocks and never forces propose. */
114
+ export type OutboundStyleCode = 'uncontracted' | 'dash' | 'first_person_firm';
115
+ export type OutboundFindingCode = OutboundBlockCode | OutboundFlagCode | OutboundStyleCode;
116
+ export interface OutboundFinding {
117
+ /** Stable: FNV-1a of the code, the normalized sentence and the normalized match. A new sentence is a new id. */
118
+ id: string;
119
+ code: OutboundFindingCode;
120
+ /** The sentence as the reader sees it (entities decoded). */
121
+ sentence: string;
122
+ /** What matched, as normalized for matching. */
123
+ match: string;
124
+ reason: string;
125
+ fix: string;
126
+ /** The Business Brief fact a citation finding is about. */
127
+ fact_id?: string;
128
+ }
129
+ /**
130
+ * FNV-1a (32-bit) of a string's UTF-16 code units, as 8 lowercase hex digits.
131
+ * The one hash the contracts use for finding ids and fingerprints.
132
+ */
133
+ export declare function fnv1a(text: string): string;
134
+ /** A finding's id: the same code, sentence and match always give the same id, anywhere. */
135
+ export declare function outboundFindingId(code: string, sentence: string, match: string): string;
136
+ /**
137
+ * Build a finding with its stable id. Signal's gate uses it for the findings
138
+ * only it can make (prompt_control_token, audit_flag, audit_not_run), so
139
+ * every finding's id is computed one way.
140
+ */
141
+ export declare function makeOutboundFinding(code: OutboundFindingCode, sentence: string, match: string, extra?: {
142
+ reason?: string;
143
+ fix?: string;
144
+ fact_id?: string;
145
+ }): OutboundFinding;
146
+ export type PlatformBannedClaimId = 'guaranteed_ranking' | 'number_one_on_google' | 'guaranteed_results' | 'hundred_percent_guaranteed' | 'risk_free' | 'fake_scarcity' | 'google_endorsed' | 'pay_nothing_guarantee';
147
+ /** Claims no outbound copy may make, for any business. Generic on purpose: a project adds its own in its Business Brief. */
148
+ export declare const PLATFORM_BANNED_CLAIMS: ReadonlyArray<{
149
+ id: PlatformBannedClaimId;
150
+ description: string;
151
+ }>;
152
+ export interface PlatformClaimHit {
153
+ id: PlatformBannedClaimId;
154
+ /** Offset into the normalized text. */
155
+ index: number;
156
+ match: string;
157
+ /** True when a negation directly governing its guarantee or promise takes it back ("We can't guarantee rankings"). */
158
+ negated: boolean;
159
+ }
160
+ /**
161
+ * Every PLATFORM_BANNED_CLAIMS hit in a text. A negation suppresses a hit
162
+ * only when it directly governs the guarantee or promise ("can't
163
+ * guarantee", "no one can promise"), or the verb a hit starts with ("we
164
+ * can't get you to the top of Google", "not endorsed by Google"); "not
165
+ * only", "no other", "don't settle", "don't wait" and "don't miss" never do.
166
+ * A suppressed hit comes back with `negated: true`: still worth a person's
167
+ * look. A #1 or top-of-Google hit needs a verb that puts someone there, so
168
+ * an observation ("you're near the top of Google") isn't one.
169
+ */
170
+ export declare function findPlatformBannedClaims(text: unknown): PlatformClaimHit[];
171
+ export interface PhraseHit {
172
+ /** The phrase as you passed it. */
173
+ phrase: string;
174
+ /** Offset into the normalized text. */
175
+ index: number;
176
+ /** The phrase as normalized. */
177
+ match: string;
178
+ }
179
+ /**
180
+ * Literal phrases found as whole spans, case-insensitive, after both sides
181
+ * are normalized (entities, invisible characters, quotes and dashes). The one
182
+ * matcher for a project's banned claims and a narrative's forbidden phrases.
183
+ */
184
+ export declare function findPhrases(text: unknown, phrases: ReadonlyArray<unknown> | null | undefined): PhraseHit[];
185
+ export interface SpanHit {
186
+ /** Offset into the text the finder read (normalized for findUnrenderedTokens, as given otherwise). */
187
+ index: number;
188
+ match: string;
189
+ }
190
+ /**
191
+ * Template text that never got filled in: {{x}}, {%x%}, [[x]], %TOKEN%,
192
+ * ${x}, {x}, unresolved {a|b} spintax, a bracketed name or company
193
+ * placeholder ("[First Name]"), a greeting to undefined or null, and
194
+ * "[object Object]". Reads the normalized text.
195
+ */
196
+ export declare function findUnrenderedTokens(text: unknown): SpanHit[];
197
+ /**
198
+ * Dashes the house voice never uses: an em dash anywhere, and an en dash
199
+ * between words. An en dash inside a number, time or $ range ("3–4",
200
+ * "9:00 AM – 6:00 PM", "$2,000 – $9,000") or unspaced between two
201
+ * words ("Mon–Fri") is a range, and a hyphen is never a dash. Reads the
202
+ * text as given (decode entities first), so it can tell the dashes apart.
203
+ */
204
+ export declare function findDashes(text: unknown): Array<SpanHit & {
205
+ kind: 'em' | 'en';
206
+ }>;
207
+ /**
208
+ * Words that mix Latin letters with Cyrillic or Greek ones ("Gооgle"
209
+ * with two Cyrillic o's), which only look like ordinary words. A word that's
210
+ * all Cyrillic or all Greek is left alone.
211
+ */
212
+ export declare function findMixedScript(text: unknown): SpanHit[];
213
+ /**
214
+ * Markup an agent's HTML email may not carry: <style>, <script>, on*=
215
+ * event handlers, CSS content: in a style attribute, embedded frames or
216
+ * objects, and javascript: links. Reads the HTML as written.
217
+ */
218
+ export declare function findUnsafeMarkup(html: unknown): SpanHit[];
219
+ export type PriceShape = 'exact' | 'floor' | 'ceiling' | 'range';
220
+ export interface PriceMention {
221
+ /** Offset into the normalized text. */
222
+ index: number;
223
+ /** The whole mention, floor or ceiling words and the range included ("starting at $4,000", "$4,000-$15,000"). */
224
+ match: string;
225
+ shape: PriceShape;
226
+ min: number;
227
+ max: number;
228
+ /** The dollar amounts in it, as written. */
229
+ amounts: string[];
230
+ }
231
+ /**
232
+ * Every price in a text, with its shape: exact ("$4,500"), a floor
233
+ * ("starting at $4,000", "$25,000+"), a ceiling ("up to $900") or a range
234
+ * ("$4,000 - $15,000", "between $4k and $15k"). Reads the normalized text.
235
+ */
236
+ export declare function findPriceMentions(text: unknown): PriceMention[];
237
+ export interface ClaimNumber {
238
+ /** Offset into the normalized text. */
239
+ index: number;
240
+ /** The number as written, with its % or x ("45%", "3x", "120", "$300"). */
241
+ match: string;
242
+ /** Its digits, commas dropped ("1200", "4.9"): what grounding compares. */
243
+ value: string;
244
+ kind: 'percent' | 'multiplier' | 'count' | 'money';
245
+ }
246
+ /**
247
+ * The claim numbers in a text (see ClaimNumber) that none of `sources`
248
+ * carries. Times, dates, phones, years, addresses and the digits of a web or
249
+ * email address aren't claims and are skipped. Reads the normalized text.
250
+ */
251
+ export declare function findUnsupportedNumbers(text: unknown, sources?: ReadonlyArray<unknown>): ClaimNumber[];
252
+ export type ClaimSubject = 'sender' | 'recipient' | 'unknown';
253
+ /**
254
+ * Whose claim the number at `index` is: the nearest pronoun or name before
255
+ * it in the same clause (a clause ends at , ; : or a dash, and at "and" or
256
+ * "but"). We, our, us (and I, me, my) and the sender's names mean the sender;
257
+ * you, your and the recipient's names mean the recipient. A bare I or we
258
+ * followed by an observation verb ("I noticed", "we measured") names neither.
259
+ * 'unknown' when the clause names neither: a caller treats that as the
260
+ * sender's unless the number is in the research about the recipient.
261
+ */
262
+ export declare function claimSubjectOf(sentence: string, index: number, names?: {
263
+ senderNames?: ReadonlyArray<string> | null;
264
+ recipientNames?: ReadonlyArray<string> | null;
265
+ }): ClaimSubject;
266
+ /**
267
+ * "I" with the firm's work: build, built, design, offer, work with, put
268
+ * together, deliver ("I built your competitor's site"). The firm's work is
269
+ * "we"; "I" is for what the sender noticed, read or thinks. Quotes are left
270
+ * alone.
271
+ */
272
+ export declare function findFirstPersonFirm(text: unknown): SpanHit[];
273
+ /** A claim the writer made and what backs it: a Business Brief fact, or the research about the recipient. */
274
+ export type OutboundClaim = {
275
+ text: string;
276
+ fact_id?: string | null;
277
+ } | {
278
+ text: string;
279
+ evidence: 'research';
280
+ };
281
+ /** A price as the Business Brief publishes it ("$4,000 - $15,000", "Starting at $500 a month"). */
282
+ export interface OutboundPrice {
283
+ display: string;
284
+ fact_id?: string | null;
285
+ }
286
+ /** A Business Brief fact a claim may cite by id. Only a verified one supports anything. */
287
+ export interface OutboundFact {
288
+ id: string;
289
+ text: string;
290
+ verified: boolean;
291
+ }
292
+ /**
293
+ * The project's rules, pre-resolved by the caller from its Business Brief and
294
+ * narrative, so no brain shape lives here. Frozen into a review, they're what
295
+ * the send side re-checks the BLOCK set against.
296
+ */
297
+ export interface OutboundRules {
298
+ /** Published prices. */
299
+ prices: ReadonlyArray<OutboundPrice>;
300
+ /** True once the brief publishes a price: then a $ amount that matches none of them is refused, not flagged. */
301
+ prices_known: boolean;
302
+ /** The project's banned claims, matched literally. */
303
+ banned: ReadonlyArray<string>;
304
+ /** The narrative's forbidden phrases, matched literally. */
305
+ forbidden: ReadonlyArray<string>;
306
+ facts?: ReadonlyArray<OutboundFact>;
307
+ /** The business's own names, for telling a claim about the sender from one about the recipient. */
308
+ sender_names?: ReadonlyArray<string>;
309
+ }
310
+ export interface OutboundCopy {
311
+ subject?: string | null;
312
+ body: string;
313
+ format: OutboundFormat;
314
+ channel: OutboundChannel;
315
+ }
316
+ export interface OutboundEvidence {
317
+ /** Primary evidence about the recipient (their homepage text, measured scores). The only research that grounds anything. */
318
+ primary?: string | null;
319
+ /** Earlier guesses (a cached model audit). Never grounds a claim; for the model audit's fence only. */
320
+ hints?: string | null;
321
+ }
322
+ export interface OutboundCheckContext {
323
+ author: OutboundAuthor;
324
+ /** Price, banned and forbidden rules run only when set. */
325
+ rules?: OutboundRules | null;
326
+ claims?: ReadonlyArray<OutboundClaim> | null;
327
+ evidence?: OutboundEvidence | null;
328
+ /**
329
+ * cited: a number must be backed by a claim the writer cited (Signal's copy).
330
+ * present: a number may also be backed by a verified fact or the primary
331
+ * evidence that carries it (an agent's own copy). Defaults to present for
332
+ * an agent and cited otherwise.
333
+ */
334
+ numbers?: 'cited' | 'present';
335
+ recipient?: {
336
+ names?: ReadonlyArray<string> | null;
337
+ } | null;
338
+ }
339
+ export interface OutboundCheck {
340
+ /** Refused at filing and again at send. */
341
+ blocking: OutboundFinding[];
342
+ /** Forces propose; a person settles them (an agent's own are reported back). Always empty for operator copy. */
343
+ flags: OutboundFinding[];
344
+ /** House style. Never blocks. */
345
+ style: OutboundFinding[];
346
+ /** The body as the reader sees it (outboundPlainText). */
347
+ plain: string;
348
+ }
349
+ /**
350
+ * The one outbound checker. Reads the copy as the reader sees it
351
+ * (outboundPlainText, then normalized for matching, entities decoded once),
352
+ * sentence by sentence, and sorts what it finds into:
353
+ *
354
+ * - blocking: empty subject (email), empty body (fewer than 8 letters in an
355
+ * email, none in a text), unrendered tokens, platform banned claims, the
356
+ * project's banned claims and the narrative's forbidden phrases, a price
357
+ * that matches no published one (once prices are known), mixed-script
358
+ * words, and for an agent's own copy dashes and unsafe HTML markup.
359
+ * - flags (never for operator copy): unverifiable prices, unsupported
360
+ * numbers, citations that are unknown, unverified, don't match or come
361
+ * from the wrong source, a banned claim a negation takes back, writer
362
+ * notes, and for an agent's own copy numbers taken from the research.
363
+ * - style: uncontracted forms, dashes (Signal and operator copy) and "I" for
364
+ * the firm's work.
365
+ *
366
+ * Research grounds a number or a price only in a sentence about the
367
+ * recipient (claimSubjectOf); in a sentence about the sender it's
368
+ * citation_wrong_source. A clause that names nobody counts as the
369
+ * recipient's only when the research about them carries that number, and a
370
+ * subject line may repeat a number the body cites that way. Price rules run
371
+ * only when ctx.rules is set. An author it doesn't know is checked as an
372
+ * agent, the strictest set.
373
+ */
374
+ export declare function checkOutboundCopy(copy: OutboundCopy, ctx: OutboundCheckContext): OutboundCheck;
375
+ export declare const OUTBOUND_AUDIT_STATUSES: readonly ['clean', 'flagged', 'not_run', 'not_needed', 'stale'];
376
+ export type OutboundAuditStatus = (typeof OUTBOUND_AUDIT_STATUSES)[number];
377
+ export interface OutboundClaimAudit {
378
+ status: OutboundAuditStatus;
379
+ [key: string]: unknown;
380
+ }
381
+ /** A person said they checked these flags, at approval. */
382
+ export interface OutboundAcknowledgement {
383
+ by: string;
384
+ at: string;
385
+ /** The route it came through ('dashboard', 'drafts', ...). */
386
+ via: string;
387
+ flag_ids: string[];
388
+ }
389
+ /** An MCP agent stood behind the flags on its own copy. An assertion, never a review: it settles flags only on author agent copy. */
390
+ export interface OutboundAssertion {
391
+ by: string;
392
+ at: string;
393
+ flag_ids: string[];
394
+ }
395
+ /** payload.claim_review: what the checker and the audit found when the copy was filed, and who settled it. */
396
+ export interface OutboundClaimReview {
397
+ version: 1;
398
+ checked_at: string;
399
+ author: OutboundAuthor;
400
+ brain_version: number | null;
401
+ /** The rules the copy was checked against, frozen, so the send side re-checks the same ones. */
402
+ rules: OutboundRules | null;
403
+ flags: OutboundFinding[];
404
+ style: OutboundFinding[];
405
+ audit: OutboundClaimAudit;
406
+ acknowledged: OutboundAcknowledgement | null;
407
+ asserted: OutboundAssertion | null;
408
+ }
409
+ /**
410
+ * A stored claim review, read strictly. Null means not reviewed: missing,
411
+ * from another version, or malformed anywhere (a caller treats a malformed
412
+ * review exactly like a missing one, never as a clean one).
413
+ */
414
+ export declare function readOutboundClaimReview(value: unknown): OutboundClaimReview | null;
415
+ /**
416
+ * The flags nobody has settled: in neither acknowledged.flag_ids (a person,
417
+ * at approval) nor, on an agent's own copy only, asserted.flag_ids (the agent
418
+ * standing behind them). Empty means it may go out.
419
+ */
420
+ export declare function unsettledFlags(review: OutboundClaimReview): OutboundFinding[];
421
+ /**
422
+ * The one wording for a refusal: every fix, numbered, each with the sentence
423
+ * it's about. Contracted, and never an em or en dash (one in a quoted
424
+ * sentence is spelled out as "[em dash]").
425
+ */
426
+ export declare function outboundRefusalMessage(findings: ReadonlyArray<Pick<OutboundFinding, 'sentence' | 'reason' | 'fix'>>): string;
427
+ export type ReadBackStatus = 'verified' | 'empty' | 'mismatch' | 'unreadable';
428
+ export interface ReadBackVerdict {
429
+ status: ReadBackStatus;
430
+ /** Length of what was read, normalized. */
431
+ body_chars: number;
432
+ /** Length of what was meant to go out, normalized. */
433
+ intended_chars: number;
434
+ /** How much of the intended text, from the first character it stops matching, isn't there. 0 when verified. */
435
+ chars_missing: number;
436
+ /** Links in the intended copy that aren't in what was read. */
437
+ urls_missing: string[];
438
+ }
439
+ /** A body to compare: a string (read as HTML when it has a tag), or the body with its format. */
440
+ export type ReadBackBody = string | {
441
+ body: string | null | undefined;
442
+ format?: OutboundFormat;
443
+ };
444
+ /**
445
+ * Whether what a mailbox says it sent is what was meant to go out. Both sides
446
+ * are read as the reader sees them and normalized, so markup, entities,
447
+ * quotes and whitespace don't count.
448
+ *
449
+ * - verified: the whole intended text is in what was read (a footer or a
450
+ * quoted header around it is fine), and so is every link it carries.
451
+ * - empty: what was read has fewer than 8 letters.
452
+ * - mismatch: anything else, with how much of the intended text is missing
453
+ * (from the first point it stops matching) and which links are.
454
+ * - unreadable: nothing could be read.
455
+ */
456
+ export declare function readBackVerdict(intended: ReadBackBody, read: ReadBackBody | null | undefined): ReadBackVerdict;