hub v0.118.0: the household's e-mails follow the household's language (R-558 Part A)
gates / gates (push) Successful in 23s
gates / gates (push) Successful in 23s
The hub has written every customer e-mail in Hungarian whatever the box was set to. The box has published its language since controller v0.247.0; nothing read it. Now it does. Nothing an operator reads changes. The Hungarian mails are byte-identical, and that is a diff rather than a reading: 56 goldens per language captured from v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged after every sentence was routed through the new bundle. - internal/i18n: flat bundle, 79 keys, hu authoritative + hu fallback, ceiling 0. - customerMessages/severityLabels are DERIVED from the bundle, so a sentence is written in one place and all 40+ tests that read those maps still work. - Language order: last reported -> created-with -> hu. reports.language defaults to EMPTY, never hu: "never told us" is not "chose Hungarian". - message_customer on POST /api/v1/event, additive and optional forever, for the sentences the box composes and the hub cannot translate. - The bind page is per-language, and its `expired` state stays Hungarian: it is the state an unknown token lands in, so rendering a real English customer's token in English would make the LANGUAGE answer what the TEXT refuses to. Two defects found inside the release: - R-581: the newest report was picked by received_at, which has SECOND granularity, so same-second reports tied and the winner was arbitrary. Ordered by the autoincrement id now. GetCustomers() still has the shape - row open. - R-582: the English copy-guard stems, ported word for word from Hungarian, convicted 141 honest sentences. The English claim is a phrase with a modal. R-555 closed: the language allowlist entry is out of wire_contract_gate.py. hub_copy_gate.py follows the sentences into the bundle - without that it would have scanned four files that no longer hold any customer text and reported success. Three new decoys incl. an innocent control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -107,7 +107,28 @@ func severityNotifies(severity string) bool {
|
||||
|
||||
// ProcessEvent evaluates an event and sends notifications as appropriate.
|
||||
// Safe to call from goroutines.
|
||||
//
|
||||
// This is the HUB-GENERATED entry point: the hub composed `message` itself (a checker, a staleness
|
||||
// sweep, an escrow decision), so there is no second sentence to carry and the customer mail is
|
||||
// rendered from the bundle alone.
|
||||
func (d *Dispatcher) ProcessEvent(customerID, eventType, severity, message, detailsJSON, source string) {
|
||||
d.processEvent(customerID, eventType, severity, message, "", detailsJSON, source)
|
||||
}
|
||||
|
||||
// ProcessBoxEvent is the entry point for an event a BOX sent (POST /api/v1/event).
|
||||
//
|
||||
// `messageCustomer` is the box's own sentence in the household's language, sent beside the Hungarian
|
||||
// `message` by controller v0.256.0 and later (R-558). It is optional forever — an older box sends
|
||||
// none and the mail is then exactly what it was.
|
||||
//
|
||||
// Kept SEPARATE from ProcessEvent rather than added as a parameter to it: 44 of the 45 call sites
|
||||
// are hub-generated and have no such sentence, and widening all of them would have meant 44 edits
|
||||
// whose only content is an empty string — churn that hides the one call site that matters.
|
||||
func (d *Dispatcher) ProcessBoxEvent(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) {
|
||||
d.processEvent(customerID, eventType, severity, message, messageCustomer, detailsJSON, source)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) processEvent(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) {
|
||||
if d.resendAPIKey == "" {
|
||||
return
|
||||
}
|
||||
@@ -122,7 +143,7 @@ func (d *Dispatcher) ProcessEvent(customerID, eventType, severity, message, deta
|
||||
// branch: *_recovered stays severity "info" (semantics frozen), but is no longer silent.
|
||||
// Operator always hears both edges; the customer hears recovery iff they heard the down.
|
||||
if _, isRecovery := recoveredPairedDownTypes[eventType]; isRecovery {
|
||||
d.processRecovery(customerID, eventType, severity, message, detailsJSON, source)
|
||||
d.processRecovery(customerID, eventType, severity, message, messageCustomer, detailsJSON, source)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,7 +188,7 @@ func (d *Dispatcher) ProcessEvent(customerID, eventType, severity, message, deta
|
||||
d.processOperator(customerID, eventType, severity, message, detailsJSON, source)
|
||||
|
||||
// Customer channel
|
||||
d.processCustomer(customerID, eventType, severity, message, detailsJSON, source)
|
||||
d.processCustomer(customerID, eventType, severity, message, messageCustomer, detailsJSON, source)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendTestEmail(customerID string) {
|
||||
@@ -219,7 +240,7 @@ Dashboard: https://hub.felhom.eu/customers/%s`, customerID, customerID)
|
||||
// - Customer leg: gated by the PAIRING rule, not enabled_events — "recovery notifies exactly
|
||||
// whoever the down notified." Evidence = a customer-channel status=sent row for the paired
|
||||
// stale/down set newer than the last customer-channel sent recovery of this type.
|
||||
func (d *Dispatcher) processRecovery(customerID, eventType, severity, message, detailsJSON, source string) {
|
||||
func (d *Dispatcher) processRecovery(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) {
|
||||
d.processOperator(customerID, eventType, severity, message, detailsJSON, source)
|
||||
|
||||
if d.store.IsCustomerBlocked(customerID) {
|
||||
@@ -263,7 +284,8 @@ func (d *Dispatcher) processRecovery(customerID, eventType, severity, message, d
|
||||
d.custCooldowns[cooldownKey] = time.Now()
|
||||
d.mu.Unlock()
|
||||
|
||||
subject, body := FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON)
|
||||
subject, body := FormatCustomerEmail(d.store.CustomerLanguage(customerID),
|
||||
customerID, eventType, severity, message, messageCustomer, detailsJSON)
|
||||
if err := d.sendEmailFn(prefs.Email, subject, body, priorityHeaders(severity)); err != nil {
|
||||
d.logger.Printf("[ERROR] Customer recovery email failed for %s/%s: %v", customerID, eventType, err)
|
||||
d.store.LogNotification(customerID, eventType, severity, message, "failed", err.Error(), "customer")
|
||||
@@ -609,7 +631,7 @@ var operatorOnlyEvents = map[string]bool{
|
||||
// Read-only: the register itself stays unexported so nothing can widen it at runtime.
|
||||
func IsOperatorOnly(eventType string) bool { return operatorOnlyEvents[eventType] }
|
||||
|
||||
func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, detailsJSON, source string) {
|
||||
func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) {
|
||||
// R-97c: operator-tier events stop here, BEFORE prefs are consulted — the point is that no
|
||||
// customer configuration can opt in. Logged rather than dropped, so the skip is visible in
|
||||
// notification_log instead of looking like a delivery that never happened.
|
||||
@@ -652,7 +674,8 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, d
|
||||
d.custCooldowns[cooldownKey] = time.Now()
|
||||
d.mu.Unlock()
|
||||
|
||||
subject, body := FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON)
|
||||
subject, body := FormatCustomerEmail(d.store.CustomerLanguage(customerID),
|
||||
customerID, eventType, severity, message, messageCustomer, detailsJSON)
|
||||
|
||||
if err := d.sendEmailFn(prefs.Email, subject, body, priorityHeaders(severity)); err != nil {
|
||||
d.logger.Printf("[ERROR] Customer email failed for %s/%s: %v", customerID, eventType, err)
|
||||
@@ -718,7 +741,7 @@ func (d *Dispatcher) SendClaimEmail(kind, customerID, email, domain, code string
|
||||
d.logger.Printf("[ERROR] claim %s email for %s NOT sent: no Resend API key configured", kind, customerID)
|
||||
return fmt.Errorf("notify: no resend api key")
|
||||
}
|
||||
subject, body := FormatClaimEmail(kind, customerID, domain, code)
|
||||
subject, body := FormatClaimEmail(d.store.CustomerLanguage(customerID), kind, customerID, domain, code)
|
||||
eventType := "claim_" + kind
|
||||
if err := d.sendEmailFn(email, subject, body, nil); err != nil {
|
||||
d.logger.Printf("[ERROR] claim %s email to customer %s failed: %v", kind, customerID, err)
|
||||
@@ -740,7 +763,7 @@ func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error {
|
||||
d.logger.Printf("[ERROR] self-bind link email for %s NOT sent: no Resend API key configured", customerID)
|
||||
return fmt.Errorf("notify: no resend api key")
|
||||
}
|
||||
subject, body := FormatSelfBindEmail(customerID, link)
|
||||
subject, body := FormatSelfBindEmail(d.store.CustomerLanguage(customerID), customerID, link)
|
||||
if err := d.sendEmailFn(email, subject, body, nil); err != nil {
|
||||
d.logger.Printf("[ERROR] self-bind link email to customer %s failed: %v", customerID, err)
|
||||
d.store.LogNotification(customerID, "selfbind_link", "info", subject, "failed", err.Error(), "customer")
|
||||
|
||||
Reference in New Issue
Block a user