- Shipped
- August 27, 2026 at 8:06 PM UTC
- Author
- Kamo
- Commit
- 4260460
Closes the TODO Task 2 left behind. Until this commit a verified inbound STOP reached BulkTextInboundController, was recognised, was logged, and was then DROPPED — the endpoint accepted opt-outs and suppressed nothing. SmsKeywordService is the single writer: it appends the WORM ConsentRecord and updates the AgentContactState row the outbound gate already reads, in one transaction (plan Decision M2 — ledger plus derived projection, not two sources of truth; MS2 makes the gate read ConsentRecord directly, after which the projection becomes a cache). Order: normalise or refuse; interpret; insert the idempotency receipt; then STOP / START / HELP. UNKNOWN returns immediately and writes nothing, so an ordinary message falls through to normal handling. Decisions worth arguing with later. * Writes AgentContactState directly rather than calling AIService. **************** is already exposed at POST **************** and is still unusable from here for three independent reasons: VOIPService has no client to AIService; AIService authenticates that surface with a DIFFERENT secret **************** not internal.auth.secret); and the endpoint accepts no bare E.164, only **************** — and a bare number is exactly what an inbound STOP is. * Contact key is "tel:<e164>". AGENT_CONTACT_STATE is unique on (ORGANIZATION_ID, CONTACT_KEY) and has NO phone column, so there is nowhere else a bare number can go. CONSEQUENCE, NOT CLOSED BY MS1: this row does not merge with a later "lead:<uid>" row for the same human, so a gate consulting the lead-keyed row sees no opt-out. MS2 closes it by making the gate read ConsentRecord, which is number-keyed. * A bare STOP sets all four channels. It does not say "SMS only", and reading it that way leaves the tenant emailing someone who told them to stop. START re-grants SMS ONLY — a START texted to a campaign number is evidence about SMS and nothing else. * A null org records the receipt and NOTHING else. No consent row, no projection: a ConsentRecord with a null organization is the global-suppression posture C1 forbids, and **************** is nullable = false anyway. Evidence a STOP arrived still beats today's silence. * consentedAt prefers the carrier's event time and records which was used. Twilio's inbound webhook carries NO timestamp at all, so the entity javadoc's "app-set from the event, never now()" is not satisfiable for it. Flowroute does send `timestamp`, so InboundSms gained a `carrierEventAt` component and the Flowroute parser reads it (Instant / OffsetDateTime / bare local, normalised to UTC; anything unreadable is null, never a guess). The consent row's proofBlob carries "consentedAtSource": "carrier" | "receipt". A receipt time is defensible and a fabricated one is not; the two being indistinguishable afterwards is what would not survive review. * handle() takes a record, not the plan's six positional parameters. The plan's signature could not carry the receipt time, the carrier event time or the raw proof payload, and four of its parameters were adjacent Strings — transposing `from` and `to` compiles silently and suppresses the tenant's own number. VOIPServiceApplication gains **************** **************** needs no entry: basePackages matches sub-packages and com.kamo.z.shared.comms is already listed. VoipRepositoryScanTest drives Spring Data's JpaRepositoriesRegistrar over the same package list and asserts the three repositories SmsKeywordService injects get bean definitions. A full @SpringBootTest boot is NOT available in this service — no test database, no test resources, no existing context test — so this exercises the registrar that actually decides the outcome, without a DataSource. TDD — observed failures, verbatim. Red before implementing: [ERROR] SmsKeywordServiceTest.java cannot find symbol [ERROR] symbol: class SmsKeywordService [ERROR] symbol: class InboundKeywordEvent [ERROR] location: class **************** [INFO] BUILD FAILURE [ERROR] **************** cannot find symbol [ERROR] symbol: method carrierEventAt() Green after: SmsKeywordServiceTest 17/17; full suite 314/314. Mutation 1 — removed the org from the ConsentRecord write: [ERROR] Tests run: 17, Failures: 2, Errors: 0 <<< FAILURE! expected: 100L but was: null [ERROR] **************** [a consent row with a null org IS global suppression — the posture C1 forbids] [ERROR] **************** Mutation 2 — removed the receipt insert: [ERROR] Tests run: 17, Failures: 3, Errors: 0 <<< FAILURE! Wanted but not invoked: [ERROR] **************** [ERROR] **************** [ERROR] **************** NOTE: the plan says this mutation should turn the RETRY test red. It does not. **************** stubs the exists() pre-check to true, so it short-circuits before the insert and stays GREEN under this mutation. The plan named the wrong test. Mutation 2b below is the one that covers the retry path, and without running it the M1 idempotency claim would have rested on a mutation that proves something else. Mutation 2b — removed the exists() pre-check instead: [ERROR] Tests run: 17, Failures: 2, Errors: 0 <<< FAILURE! **************** Never wanted here: [ERROR] **************** [ERROR] **************** Mutation 3 — opted out SMS only instead of all four channels: [ERROR] Tests run: 17, Failures: 1, Errors: 0 <<< FAILURE! Expecting value to be true but was false [ERROR] **************** Mutation 4 — dropped **************** from the scanned packages, i.e. the CrashLoop itself: [ERROR] Tests run: 3, Failures: 2, Errors: 0 <<< FAILURE! [ERROR] **************** [ConsentRecordRepository has no bean definition — **************** is not covered by @EnableJpaRepositories. This is not a runtime null: the context REFUSES TO START and the pod CrashLoops.] [ERROR] **************** [this test's probe has drifted from VOIPServiceApplication's real annotation] All reverted. Full suite after revert: Tests run: 314, Failures: 0, Errors: 0. DDL HAS NOT LANDED. This depends on kamo-shared-library adding comms_sms_inbound_receipt and **************** neither of which exists in the database yet. Built locally against a shared-lib jar in a PRIVATE -Dmaven.repo.local; ~/.m2's 1.6.0 jar was verified byte-identical before and after (md5 ****************