Mabadiliko
Nini kipya.
Kila kuboresha jukwaa, kama ni meli.
· VOIPService
SmsKeywordService — the one writer for inbound keywords (MS1 Task 3)
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 ****************
· kamo-internal
Detach the chat navigator from the window it maximizes
The conversation navigator was a child prop of ToolWindowShell, rendered inside the maximized window's content box. Clicking a row in it opens that conversation maximized, which demotes the window holding it — so the navigator unmounted and remounted on every use. The member navigated by destroying the thing they were navigating with: bucket, scroll position and search text all reset the instant they clicked. It is a dock-level sibling now. ToolDock mounts exactly one ChatNavigatorPanel, fixed down the left edge, and the maximized window stops short of it (`leftInset`) instead of being drawn underneath. Across a swap the panel never unmounts, so from React's point of view nothing happens to it. The bucket seed is frozen at the first toolId it ever saw, so the list does not re-filter out from under a member who just clicked a row. Motion is lead-and-trail: maximizing, the navigator springs out of the left edge and the window expands into what is left behind it 70ms later; restoring plays the reverse, window first, navigator trailing by 90ms. Neither component stores a direction — both delays derive from `maximized`, so the flag IS the direction. Shared timings live in maximizeMotion.ts because the two halves can no longer see each other's state. A row click is choreographed separately (useMaximizedSwap): the pane freezes and the two conversations cross-dissolve inside it, rather than one window flying to the dock while another flies out of it beside a navigator standing perfectly still. The demoted window keeps the pane's geometry while it fades, then gets a settle beat at its dock slot with transitions still off, so it reappears by fading instead of flying back across the viewport. Opacity and transform move from CSS to framer-motion: a CSS transition cannot express "start from 8px lower" for an already-mounted element, which is what a window fading up into the pane has to do. Geometry stays on CSS — framer would re-measure calc(100vw - Npx) every frame. prefers-reduced-motion drops all of it to an opacity swap. Collapse state lifts into useChatNavigatorCollapse: ToolDock needs the navigator's width to size the window, and a component owning its own width could not tell it. No new i18n keys. Verified: tsc clean, 144 tests pass across tools/ and conversations/, all guard scripts pass. The two load-bearing behaviours (frozen bucket seed, pane held through the dissolve) were mutation-tested. Not visually verified — this repo has no headless browser.
· kamo-internal
Company phone (main/toll free) on a member's signature, with their extension
The Content tab of **************** gains two switches: Company Phone (Main) and Company Phone (Toll Free). Enabled, they print the organization's published line followed by the sending member's extension — "(555) 555-5555 x81234" — so a reply-by-phone reaches the person who wrote, not the switchboard. The numbers come from Contact Information on **************** The extension is the one the member is actually assigned: MemberVoipConfig, read from VOIPService and only when their VOIP is enabled, since a member with it switched off does not answer that extension. TeamMember.phoneWorkExtension is the fallback, and is all an organization running no phone server through Kamo will ever have. No extension means the number prints bare rather than with a dangling "x". Same preference QuoteAgentContactResolver already applies server-side. companyPhone becomes companyPhoneMain + companyPhoneTollFree, which fixes two things it was hiding: - applyFieldConfig gated both numbers behind one field with `if (!local && !tollFree)`, so an organization that enabled only its toll-free line still had its local one printed. - CampaignEditor was writing a companyPhone key into SignatureContent; with the field gone the compiler finds it, but the spread had been letting it through silently, so a campaign preview would have dropped the line. All 29 signature templates now render both lines. Three did before, so the equivalent department and company toggles were largely inert — enabling one did nothing on the other 26 layouts and read as a broken setting. The department and company toggles keep their stored keys and are relabelled to the same wording, so the three scopes no longer call the same number two different things. Four independent paths build signature content — the settings preview, the composer, campaigns and meeting invitations — and all four are updated. VOIPService is not reachable through forwardToApi (APIService routes no /api/voip prefix), so the invite route gets the base-URL helper the catch-all already had, lifted into **************** Needs EmailService and the KamoInitializerService migration to have landed: the two toggles are persisted org-wide.
· EmailService
Persist the two company phone toggles
Reads and writes userCompanyPhoneMain / userCompanyPhoneTollFree on the signature field config, so the Content tab's new switches survive a save. saveFieldConfig copies field by field onto the existing row rather than replacing it, so a toggle omitted there is silently dropped on every save after the first — both new fields are added to that copy as well as to the request mapper and the response. Needs the matching columns: KamoInitializerService's **************** must have run before this ships.
· kamo-shared-library
Attribute free extensions to the server they are on
An org running a busy FreePBX server beside a new, empty KamoPBX server saw "21 billable extensions — $63.00 / month" while looking at the KamoPBX one. Both numbers were true of the organization and neither was true of the server on screen, and there was no way for a screen to tell the difference: the breakdown carried instanceId per billable extension but collapsed the free ones into a single organization-wide int. unassignedThirdPartyByInstance replaces that int. unassignedThirdPartyCount() is kept as a derived sum rather than carried alongside, so the total and the parts cannot drift apart, and every existing caller and assertion is unaffected. Adds billableCountFor(instance), countFor(instance, class) and **************** — enough for a per-server line that respects the class rules, which differ in exactly the way that matters here: a hosted extension is billable whether anyone is assigned or not, a third-party one only when somebody is. So an empty KamoPBX server is $0 and a KamoPBX server with five unassigned extensions is not. Version deliberately unchanged at 1.6.0. Built and tested in an isolated worktree off origin/main — another session has seven unrelated files open here, and installing from this tree would have published their work in progress. 2305 tests pass.
· InitializerService
Land the company phone toggle columns before EmailService reads them
ADD COLUMN IF NOT EXISTS on **************** for user_company_phone_main and user_company_phone_toll_free. This service's ddl-auto: update would add both from the entity, but every service that reads them runs ddl-auto: none, and Hibernate's own ALTER carries no DEFAULT. Stating it here lands them FALSE for existing organizations rather than NULL, which is what "off by default" is supposed to mean. Order 0: the entity maps both columns from the moment this build starts, so a runner ordered after one that loads it through JPA would never execute — the earlier one would die on "column does not exist" and take the whole run with it.
· kamo-shared-library
The company phone toggles an individual signature can carry
Two org-wide flags on email_signature_field_config: user_company_phone_main and user_company_phone_toll_free. They put the organization's published line on a member's own signature carrying that member's extension — "(555) 555-5555 x81234" — which is how a recipient reaches the person who wrote to them rather than the switchboard. Off by default. An organization that publishes no main number, or whose members hold direct lines, must not find an extra line appearing under every name after a deploy.
· kamo-shared-library
The inbound-keyword receipt, and re-key consent by campaign (MS1 Task 3)
Two things the consent ledger needs before an inbound STOP can be recorded tenant-scoped and exactly once. `SmsInboundReceipt` (comms_sms_inbound_receipt) is the idempotency anchor from plan Decision M1. Both inbound rails already dedupe and neither dedupe can cover a keyword: **************** dedupes by asking whether a `VoipMessage` row exists for the carrier's message id, but a keyword message is intercepted and returns BEFORE that row is written — so the second delivery of a carrier retry finds no row and is processed again. Without this table a carrier retrying STOP three times appends three revocation records to a WORM ledger that can never be cleaned up. Unique (ORG_ID, CARRIER_MESSAGE_ID); WORM total-block guards copied from ConsentRecord, deliberately not from AgentOutboundTouch whose append-only is convention with no guard at all. ORG_ID is nullable so an unattributable STOP is still recorded as evidence — and SQL unique constraints do not constrain NULLs, so the repository carries a separate **************** A null passed to the ordinary derived finder renders as `= NULL`, which is never true, so the org-less retry check would have silently passed every time. That hole is documented on the entity; it closes when ADD5/MS3 makes the org resolvable. `ConsentRecord` gains ONE nullable column, `campaign_id`, and ONE index, (organization_id, raw_number, scope). Nothing else moved: the WORM guards, the party-XOR-rawNumber `assertIdentified()` that the DB CHECK mlos_consent_one_identity_chk depends on, the three pre-existing indexes and the nullable organization_id are all unchanged, and `ConsentRecordCampaignKeyTest` is a regression fence around each of them. The new org-scoped finders sit next to the pre-existing org-AGNOSTIC **************** That one is correct for the MLOS global-STOP reading and wrong for MS1, where a homeowner who STOPs Bob's Plumbing must stay reachable by Dave's HVAC. Both are documented at the declaration so picking the wrong one is visible at the call site rather than being a silent cross-tenant suppression. TDD — observed failures, verbatim. Red before implementing (mvn -o -Dmaven.repo.local=<private> test): [ERROR] **************** cannot find symbol [ERROR] symbol: class SmsInboundReceipt [ERROR] location: class **************** (9 occurrences) [ERROR] **************** cannot find symbol [ERROR] **************** cannot find symbol [ERROR] **************** cannot find symbol [INFO] BUILD FAILURE Green after: Tests run: 19, Failures: 0, Errors: 0 (with NotificationConsentTest). Mutation A — dropped `unique = true` from the receipt's index: [ERROR] Tests run: 5, Failures: 1, Errors: 0 <<< FAILURE! -- in **************** java.lang.AssertionError: comms_sms_inbound_receipt needs a UNIQUE index — it is the only thing that stops a carrier retry appending a second revocation record [ERROR] **************** Mutation B — removed the (organization_id, raw_number, scope) index: [ERROR] Tests run: 6, Failures: 1, Errors: 0 <<< FAILURE! -- in **************** [ERROR] **************** [an (organization_id, raw_number, scope) index — the tenant-scoped opt-out read] Both reverted. Full suite after revert: Tests run: 2295, Failures: 0, Errors: 0 — including SharedLibBeanSafetyTest, which the two new classes pass because neither carries a Spring stereotype. DDL HAS NOT LANDED. comms_sms_inbound_receipt does not exist and mlos_consent_record has no campaign_id column. This must not ship before KamoInitializerService applies both and REVOKEs UPDATE/DELETE on the new table (plan Task 5) — entity callbacks do not fire for bulk JPQL or native SQL.
· kamo-shared-library
The sync journal, and a terminal run that really is frozen
BL1 Task 5, shared-library half. Five journal enums, three entities and three repositories under **************** No stereotypes: BillingWormGuard is a plain final class with a private constructor, and SharedLibBeanSafetyTest is green. Two deliberate improvements over the payroll source: 1. THE TERMINAL-RUN GUARD PAYROLL DOCUMENTS AND DOES NOT HAVE (defect 5). PayrollSyncRun's javadoc claims "a terminal run is never rewritten" with nothing behind it. BillingSyncRun enforces it. The plan specifies the guard as a bare read of the current field: if (state != null && state != BillingSyncRunState.RUNNING) { throw ... } That version is WRONG and would have broken production. @PreUpdate fires AFTER the caller has mutated the entity, so it sees the terminal state BillingSyncJournal.complete() has just assigned and refuses the one write the row exists to receive — complete() would throw on every run and closeStaleRuns() on every sweep. The guard therefore compares against the state as LOADED, captured by a @PostLoad snapshot into a @Transient field. A row never loaded (a fresh `new`) is judged on its current state, which keeps the plan's own test meaningful. 2. dbNow() (Self-Review's flagged gap). Payroll reads the database clock from TimecardPunchRepository; InvoicingService has no punch table. Added **************** — @Query(value = "SELECT CAST(now() AS timestamp)", nativeQuery = true) — with the SQL-standard CAST, because a `::` cast is silently mangled: Hibernate reads `:` as a named-parameter prefix and turns now()::timestamp into :timestamp. BillingSyncJournal is its only caller and the only real writer of occurredAt; the entities' @PrePersist LocalDateTime.now() fallbacks are kept as last-resort guards and documented as such (defect 6). Both WORM entities are served by NARROW repositories extending Repository, not JpaRepository, so there is no batch delete to walk past @PreRemove. TDD evidence — RED first (compilation, before any of the classes existed): [ERROR] **************** cannot find symbol [ERROR] symbol: class BillingSyncIssue [ERROR] location: class **************** ... 20 further "cannot find symbol" errors, BUILD FAILURE GREEN after implementing: Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 Mutations, each run from an isolated `git archive HEAD` copy with its own target/ and its own -Dmaven.repo.local (this checkout is shared with other sessions and a red suite here is usually someone else's red phase): MUTATION D — enforceTerminalImmutability's `throw` replaced with `return` (the mutation the plan's Step 6 names): Tests run: 7, Failures: 2, Errors: 0, Skipped: 0 <<< FAILURE! **************** <<< FAILURE! java.lang.AssertionError: Expecting code to raise a throwable. at **************** **************** <<< FAILURE! java.lang.AssertionError: Expecting code to raise a throwable. at **************** MUTATION E — the guard rewritten EXACTLY as the plan specifies it, i.e. a bare read of `state` with no @PostLoad snapshot. This is the evidence that the plan's version is broken: Tests run: 7, Failures: 1, Errors: 0, Skipped: 0 <<< FAILURE! **************** <<< FAILURE! java.lang.AssertionError: Expecting code not to raise a throwable but caught **************** BillingSyncRun null is SUCCEEDED and is evidence about a completed run; it cannot be rewritten. at **************** at **************** MUTATION F — BillingWormGuard.reject made a no-op: Tests run: 7, Failures: 4, Errors: 0, Skipped: 0 <<< FAILURE! **************** -- Expecting code to raise a throwable. **************** -- Expecting code to raise a throwable. **************** -- Expecting code to raise a throwable. **************** -- Expecting code to raise a throwable. All three reverted. Full library suite afterwards: Tests run: 2288, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS <version> untouched at 1.6.0. Not pushed — Task 9 lands DDL first.
· VOIPService
Carrier-facing inbound SMS webhook (MS1 Task 2)
POST **************** -- form-encoded or JSON, guarded by the CARRIER'S OWN SIGNATURE. Why a new endpoint. /api/voip/webhook cannot receive a STOP and fails silently: it readTree()s the body with no `consumes` restriction, every bulk-text carrier posts **************** the parse throws, the exception is swallowed and the carrier is answered 200 OK -- so it never retries. Confirmed in the current source before writing this. Why this path. **************** holds "/api/bulktext/send" and matches with startsWith, so anything under it demands an X-Internal-Auth header a carrier cannot send. /api/bulktext/inbound is a SIBLING of /send, is added to **************** and is deliberately NOT registered in InternalAuthFilter. Twilio signature: HMAC-SHA1 over the full URL (query string included) plus the POST parameters appended name-then-value in case-sensitive sorted order, base64, compared to X-Twilio-Signature with MessageDigest.isEqual. Verified against Twilio's security documentation, then pinned to the published vector from Twilio's own twilio-java RequestValidatorTest (token 12345 -> RSOYDt4T1cUTdK1PDd93/VVr8B8=), so the test cannot pass for a wrong algorithm. Modelled on MicrosoftTeamsProvider (blank secret -> log + false; exception -> false; constant-time compare); NOT on RingCentralProvider's `return true; // TODO`, and RingCentralSignatureUtil's equalsIgnoreCase is not reused. The signed URL is rebuilt from X-Forwarded-Proto/Host, not getRequestURL(). The edge terminates TLS, so getRequestURL() returns the container's own scheme and host, which is not what Twilio signed -- every signature would fail and the failure would look exactly like a forgery. FLOWROUTE IS PARSE-ONLY AND STILL REFUSES EVERY WEBHOOK. Flowroute publishes no webhook signature: its inbound-message docs specify the callback URL, the application/vnd.api+json content type and the payload shape, and no signature header, HMAC scheme or shared secret. There is nothing to verify, so it keeps the fail-closed default rather than getting an invented scheme -- an invented one would either reject all genuine traffic or be written to return true, which is the forgery surface this interface exists to prevent. Flowroute also does not post form-encoded, so `consumes` accepts vnd.api+json too. Sinch, Infobip and Vonage are unimplemented on both methods and log a warning naming themselves. TDD evidence ------------ Red (class absent): [ERROR] **************** cannot find symbol symbol: class BulkTextInboundController location: class **************** Green: Tests run: 27, Failures: 0, Errors: 0, Skipped: 0 Mutation 1 -- THE ONE THE PLAN NAMES, and it proves nothing. Plan Task 2 Step 8 says: make validateWebhookSignature default to `true` and aMissingWebhookSecretRejects must go red. Observed: Tests run: 24, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS Nothing went red. Every test drives Twilio, which OVERRIDES the default, so the fail-closed default was exercised by no test at all and could have been silently inverted at any time -- turning four carriers from "refuses everything" into "accepts anything" on an endpoint where accepting anything lets a stranger revoke any consumer's consent. Closed by adding **************** which pins the default on a bare implementation and on the four real carriers relying on it. Re-running the same mutation against it: **************** **************** [an unimplemented carrier must never accept an unverifiable webhook] Expecting value to be false but was true **************** **************** [SinchTextProvider must fail closed] Expecting value to be false but was true Mutation 2 -- the one that actually targets the plan's named test: Twilio's blank-authToken guard changed from `return false` to `return true`. Observed: **************** **************** expected: 403 but was: 200 **************** **************** Expecting value to be false but was true Mutation 3 -- the parameter sort reversed (TreeMap natural order -> Comparator.reverseOrder()), to show the algorithm test is not vacuous: **************** **************** Expecting value to be true but was false **************** **************** expected: 200 but was: 403 All three mutations reverted. Full suite after revert: Tests run: 290, Failures: 0, Errors: 0, Skipped: 0 / BUILD SUCCESS Known gaps, deliberate and logged at runtime **************** * A verified inbound message is recognised and LOGGED BUT NOT RECORDED. The hand-off to SmsKeywordService is a marked TODO because that class arrives in Task 3. This endpoint suppresses nothing yet. * Org resolution scans active instances and matches the to-number with ContactCanon.phone. There is no number inventory -- the only ownership record is the single nullable FROM_PHONE_NUMBER on BulkTextProviderInstance, with no finder and no index. No match, or a match across two orgs, attributes the message to nobody and logs; guessing a tenant would record a revocation against the wrong one, which is worse than not recording it. Needs ADD5/MS3. * InboundSms carries no carrier timestamp: Twilio's inbound SMS webhook does not send one. Task 3's "consentedAt comes from the carrier event, never now()" is therefore not satisfiable for Twilio. Refs: **************** Task 2
· kamo-shared-library
E.164 normalizer and SMS keyword interpreter (MS1 Task 1)
Two pure classes with no I/O and no Spring stereotype, so every service can load them and SharedLibBeanSafetyTest stays green. E164.normalize(raw, defaultRegion) -> Optional<String>. Deliberately narrow and NANP-only: libphonenumber is NOT a dependency of this module and was not added for this, because adding a transitive dependency to a library all 28 services load is not a decision an opt-out parser gets to make. A leading '+' is accepted as-is; 10 bare digits with a NANP region, or 11 led by '1', become +1; anything else is empty. Refusing is the point -- a guess that lands on a real but different number suppresses a stranger AND leaves the person who texted STOP still receiving texts. NOT ContactCanon.phone(), which is a lossy US-only comparison key (last 10 digits, leading 1 dropped) that the platform's lead-to-call matching depends on. Unchanged and unreused here. **************** -> SmsKeyword {STOP,HELP,START,UNKNOWN}. CTIA's vocabulary plus ARRET/AIDE. Matching is EXACT set membership after **************** never contains(). TDD evidence ------------ Red (classes absent): [ERROR] **************** cannot find symbol [ERROR] symbol: variable SmsKeywordInterpreter [ERROR] location: class **************** Green: Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 Mutation 1 -- exact match changed to **************** (and the same for HELP/START). Observed red: **************** **************** expected: UNKNOWN but was: STOP **************** **************** [UNSTOP] expected: START but was: STOP The second failure was not predicted by the plan and is worth recording: "UNSTOP" CONTAINS "STOP", so a substring implementation reads a consumer's re-opt-in as an opt-out. contains() is not merely imprecise here, it inverts the meaning of the one message that undoes a suppression. Mutation 2 -- E164's final `return Optional.empty()` changed to `return Optional.of("+1" + digits)`. Observed red: **************** java.lang.AssertionError: Expecting an empty Optional but was containing value: "+112345" Both mutations reverted. Full suite after revert: Tests run: 2284, Failures: 0, Errors: 0, Skipped: 0 / BUILD SUCCESS Refs: **************** Task 1
· kamo-shared-library
The provider connection row, one per org
BL1 Task 1. A near-verbatim transliteration of the payroll provider enums and PayrollConnection into **************** Enums, one entity and one repository — no stereotypes, so SharedLibBeanSafetyTest stays green and the owning service (InvoicingService) declares every bean. New relative to payroll: WEBHOOK_SECRET_CIPHERTEXT, a per-org rotatable webhook secret in its own column (B2). One platform-wide secret would mean rotating after any single org's leak breaks every other org's payment ingestion at the same instant. PlatformOAuthProviderType has NO QUICKBOOKS, XERO or SQUARE constant — it ends at ADP_WFN(17), its ids are persisted and it is documented append-only. Per the plan's Task 1 Step 3 instruction, all three ship PER_ORG_CREDENTIALS with a null registration rather than growing a shared ordinal enum in this task. That makes the plan's **************** vacuous on its usesPlatformOAuth() branch, so a fifth test states the consequence directly: no type may claim PLATFORM_OAUTH with no registration to point at. TDD evidence. RED (test written, no implementation): [ERROR] **************** cannot find symbol [ERROR] symbol: variable BillingProviderType [ERROR] location: class **************** [ERROR] **************** cannot find symbol [ERROR] symbol: variable BillingAuthKind GREEN: [INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in **************** MUTATION (KAMO_NATIVE authKind NONE -> PER_ORG_CREDENTIALS) — exactly one test red, which is the point: [ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0 <<< FAILURE! [ERROR] **************** <<< FAILURE! **************** expected: NONE but was: PER_ORG_CREDENTIALS at **************** Reverted. FULL SUITE: Tests run: 2271, Failures: 0, Errors: 0, Skipped: 0 / BUILD SUCCESS. Run in an isolated `git archive HEAD` copy with its own target/ and its own -Dmaven.repo.local, NOT in the shared checkout: another session was mid-TDD in it **************** with no implementation yet), so `mvn test` there is red for reasons unrelated to this change. ~/.m2 was not written to and mvn install was not run. Not pushed — Task 9 lands the DDL first.
· KlusterServices
Give every kamocrm extension WebRTC, and gate outbound calling
All 21 kamocrm team members now hold an 8XXX extension on the k1m1 PBX (Chris renumbered off 7255 to 8134). Two changes here back that. WebRTC endpoint blocks now cover every staff extension rather than the three someone had noticed were broken. The kamo softphone is a browser, so DTLS/ICE is not an enhancement — an extension without a block registers over WS and then has no audio on any call. 8888 had sat unlisted for months. Outbound calling is now permission-gated: only the extensions listed in [kamo-outbound-allowed] reach the trunk. Everyone keeps extension-to- extension dialling and inbound from the PSTN, both of which run through paths this does not touch. The gate lives in [from-internal-custom] rather than on the outbound route because from-internal-xfer includes that context ahead of from-internal-additional, so it sees a dialled number before outrt-1 does. The route's own match_cid column was the alternative and would need one duplicated pattern row per permitted extension, and denies with FreePBX's "cannot be completed as dialed" — a message that sends staff hunting for a typo in a number that was fine. Membership is tested with DIALPLAN_EXISTS against a context of one line per person, so nothing half-matches: 80800 and an empty caller id both deny, which is the safe direction to fail. The three intercept patterns are a mirror of outbound route PrimaryRoiute's dial patterns and have to stay one. Verified on k1m1: all 21 endpoints report ice_support=true, media_encryption=dtls, the WS transport and direct_media=false; all 21 carry the right AMPUSER device and hint keys; the allowed path still resolves to outrt-1 for both domestic and 011 patterns.
· kamo-internal
ServiceJobApi.createJob, and the back-link builder
Restores the client half of SW6c Task 2, which landed as 157fa714 and was dropped by a history rewrite in a concurrent session; the create screen itself survived inside bddd3617 and has been importing both of these since. `createJob` posts `CreateJobInput` at the base path and answers with the detail projection, so a caller may adopt the returned job whole. Seven fields and not one column the server owns: no status, no job number, no totals, no sign-off. SW6b removed exactly those five from `JobInput` after finding `PUT /{uid}` accepted them verbatim, and `CreateJobInput` joins the contract test's input pairs so a `status` added on this side alone fails rather than being serialised and silently ignored. `commerceBackHref` is documented at its definition: `/commerce?tab=service` never named a real tab, and every caller today passes no market because no service record carries one.
· kamo-internal
The service-request grid and client read the real endpoints
Restores the rest of SW6c Task 4, which landed as d58a6aba and was dropped by a history rewrite in a concurrent session; the detail page survived inside bddd3617 and has been importing `serviceQuoteWorkOrder` since. `PipelineServiceRequestsTab` calls `GET /service-quotes` instead of `generateMockServiceRequests`. The requester, department, category, priority, SLA, technician and team columns are gone: none has a source on the wire, and a column that renders an em dash forever reads as "nothing recorded" rather than as "this was never real" — how `serviceName`, `serviceId` and `engagementId` survived a whole screen's lifetime before SW6a. Status is `IntentStatus`'s five values, not the fixture's six, and the status buttons are gone with it: they moved a local array and raised a success toast for a write that never left the browser. Create sends exactly one offering and says so. `CreateServiceQuoteRequest` advertises `List<Long> serviceOfferingUids` and the service keeps only `.get(0)`, silently. The New button used to point at **************** which does not exist; it opens a dialog. `marketId` cannot filter this list, so the `data.filter(() => true)` that dressed up as a filter is gone and a caption says every market shows the same rows. `posApi.ServiceQuote` declared `serviceId` and `serviceName` against a DTO sending `serviceOfferingUid` and `serviceOfferingName` — the same defect on the sibling type — and now mirrors `ServiceQuoteDTO`. `WorkOrder`'s phantom members are corrected too, because one of them turns out to be load-bearing: `acceptServiceQuote` answers with the `FulfillmentOrder`, whose uid is not the `ServiceJob`'s, and `engagementUid` is the only thread across. `serviceQuoteWorkOrder.ts` follows it, and its test pins the part that would work on a seeded database and stop in production — `JsSafeLongSerializer` quotes a `Long` by value, so the same field arrives as a string on a 19-digit id and a number on a small one.
· kamo-internal
Screenshots AND videos on a bug report
Reporters were describing screen recordings in prose and then posting the file into a chat, because the report form only took stills. It now takes MP4, MOV and WebM beside the four image formats, up to 100 MB each, inside the same ten-file cap. The dropzone no longer implements its own rules. It hands the batch to `ingestFiles` against a new SYSTEM_BUG_ATTACHMENT_POLICY, the same path a chat message and an email go through, which is what gets it per-class size ceilings, a dropped folder recognised as a folder, a duplicate drop ignored, and a copied range of spreadsheet cells left as text instead of silently becoming a bitmap. A refused file no longer discards the rest of the batch with it. Object URLs move to `useDraftThumbnails`, so nothing here has to remember to revoke one. Video renders the way it already does everywhere else in the product: a poster frame drawn with the `#t=0.1` trick and a play badge on the tile, browser controls in the lightbox. Thumbnails are metadata-only fetches, so a drawer full of tiles does not pull whole clips down — which is what the Range support added to MediaService is for, and why this proxy passes Range and 206 through in both directions rather than collapsing them. The upload route stops parsing the body with formData(). That held the whole file in this process's heap, survivable for a 10 MB screenshot and not for a 100 MB recording; it now pipes through node:http like the chat upload does, for the same undici-timeout reason. The button counts files while it works — fetch reports no upload progress, and "Uploading…" sitting there for minutes reads as a hang. Send To AI lists images only. A session reads a picture and cannot watch a video, so offering one would be offering to fail; the dialog says how many are staying behind, and SecurityService refuses one by name if a stale console sends it.
· MediaService
Attach a video to a bug report, not only a screenshot
A still cannot show the three clicks before the thing broke, so reporters were describing recordings in prose and posting the file into a chat instead. The attachment endpoint now takes MP4, MOV and WebM beside the four image formats. - The magic-number sniff grew a video half. An ftyp box only says "ISO base media", so the brand is matched against an allowlist: HEIC, AVIF and 3GP carry the same box, and a still stored as video/mp4 reaches a <video> element that shows a dead player. Matroska is accepted only when its EBML DocType says "webm" — a plain .mkv plays in no browser the console runs in. - Videos get their own 100 MB ceiling; images keep 10 MB. The ten-file cap spans both kinds. Order of checks is by cost: count, then the outer ceiling, then the format, then the kind's own ceiling — sniffing first would read a file that was always going to be refused. - An unnamed upload is named for what the bytes are. The stored object is keyed by that extension, so calling a pasted recording screenshot.png would put .png on an MP4 and hand the browser a <video> it will not start. - The content endpoint answers Range requests, which is not optional once videos are attachable: a <video> cannot seek without them, Safari opens every video with "bytes=0-1" and reads a 200 as "this server does not do video", and a gallery thumbnail is drawn from a metadata-only fetch rather than the whole clip. Parsing mirrors ImagingProxyController. The RangeStream is now closed when the response finishes; nothing was closing it before.
· kamo-internal
Restore the pipeline Quotes tab and wire quote conversion
`POST /quotes/{uid}/convert` shipped in SW4 and nothing has ever called it: `quotesApi` had ten methods and `convert` was not among them, so the endpoint was live and unreachable. This puts a Quotes sub-tab back on the SERVICE pipeline and offers Convert on an ACCEPTED quote. The reason this needed a test before it needed code: `subTabLabels` is a `Record<PipelineSubTabId, string>`, so a missing label is a compile error, but `renderSubContent` decided what to draw with four `if (id === "intents") return X; return Y;` chains, and that trailing `return Y` answers every id nobody thought about. Appending an id therefore put a new tab on screen that silently drew the commitments list, in all four verticals, and typechecked. That is the defect `pipelineSubTabs.ts`'s own header narrates having shipped once already. **************** asserts, against the source, that every `SUB_TAB_IDS` entry is named in every vertical. It was red before any branch changed — on `commitments`, in all four verticals, because the fall-through never names it. Deleting one `case` from the finished code leaves `tsc --noEmit` silent and turns the test red, which is the whole point of it existing. The chains are now switches with no `default`. A vertical with no quotes surface still writes `case "quotes":` beside its commitments case, so the next id added makes someone choose four times instead of inheriting an answer. Only SERVICE lists `quotes` in `SUB_TABS_BY_VERTICAL`, so only SERVICE shows the tab — a Quotes tab on RENTAL that rendered Bookings would be the same silent wrongness in a new place. Strip order still comes from `SUB_TAB_IDS` alone: the new map is a set, and `Tab value` stays the `SUB_TAB_IDS` index so omitting a tab cannot renumber the others. `subIndexForVertical` clamps a `?sub=quotes` link that lands on a vertical without the tab, which MUI would otherwise render as an empty selection over fall-back content. `quotesApi.convert` keeps 409 and 404 apart, because they say different things: 409 is "this quote's state is wrong, and the server's reason is the useful half", 404 is "it is gone from this org". Telling someone a record they are looking at was not found when the truth is "not accepted yet" sends them hunting a data problem that does not exist, so only 404 forces a reload. `QuoteConversion.orderUid` is a `string`. `Order extends BaseEntity`, whose id is an `INT8 DEFAULT unique_rowid()`; the server already declares the field as a String, and the Next proxy reparses the body in Node one hop upstream of the browser, so a bare int64 would round before any client code could see it. `total` is typed `string | number` and read through `Number()`: it is a `BigDecimal`, and `HttpWireJacksonConfig` stringifies `Long` only. Convert is offered on ACCEPTED and nothing else; accept and decoder stay on the customer's tokenised page, where the acceptance an order rests on is actually given. Production holds 15 quotes, all DRAFT, so the button is currently visible on none of them.
· kamo-marketing
Our price comes from the catalog, and can't be typed again
The comparison pages hardcoded KamoCRM at $29 Starter and $64 Business while the catalog sold them at $34 and $72. Nine files, forty-two mentions, and the build was green the whole time — a price in prose is just prose. They carry {starterMonthly} / {businessMonthly} tokens now, resolved at render from the same catalog endpoint the pricing page reads, sharing its cache entry via an identical URL and revalidate. Repricing a plan in the catalog is the only edit anyone makes. The multiplier form covers the totals the copy computes out loud — {businessMonthly*10} for a ten-seat team, {businessMonthly*50*12} for the three-year Salesforce comparison. Those were the worst of the hardcoded numbers: quoted against a competitor's bill, read as authoritative, and wrong by exactly the factor the unit price behind them was wrong by. Substitution runs after localization, so translated pages get live numbers too, and it is applied wherever competitor data is read rather than only where it currently needs to be — a field that starts mentioning money later should not have to remember to opt in. check-price-tokens guards both directions: our figure typed as a literal, and a token that would not resolve. A typo'd {businessMonthy} ships as visible garbage, which is worse than a stale number. Competitors' prices stay literal, which is the point of a comparison — the guard tells them apart by proximity to a Kamo mention and by whether the amount is an endpoint of a range. FALLBACK_KAMO_PRICES is what CI renders with, since the API key is absent there and these pages prerender. It is the live catalog value, deliberately: a stale fallback would be the same bug in a new place.
· KlusterServices
Recorded-call notice on extension-to-extension calls
Completes the third call path. Staff-to-staff calls never touch the attendant — they go from-internal -> ext-local, where the Dial belongs to FreePBX — so [from-internal-custom] now intercepts real extensions and routes them through [kamo-internal-connect], which owns its Dial and can carry aA(). Interception is only safe because of two things checked first, not assumed: - from-internal-noxfer, which from-internal consults BEFORE from-internal-xfer, does not resolve extensions. Had it done so, from-internal-custom would never have been reached and this would have silently done nothing. - from-internal-custom is included by from-internal-xfer ahead of from-internal-additional, so it sees extensions first. Anything that is not a real extension is handed straight back to FreePBX, so nothing else on the internal dialplan changes. DIALPLAN_EXISTS is tested rather than assumed, because a bare Goto to a missing extension hangs up on the caller instead of playing "not in service". Regression-tested all three internal cases: - 8912 (real, offline) -> kamo-internal-connect, Dial PJSIP/8912,15,aA(...), userfield DS:CHANUNAVAIL/VM:default, falls to Voicemail - 8080 (real, online) -> Dial PJSIP/8080,15,aA(...), phone rang - 7777 (ext-test) -> still reaches ext-test and loops back through from-pstn - 3000 (bad number) -> still reaches bad-number. It writes no CDR row, which is not a failure: bad-number does ResetCDR() and CDR_PROP(disable)=true by design. BOTH parties hear the AGENT recording on internal calls: there is no customer on the call, so the client-facing wording would be addressed to someone who is not present. Each side is its own global, so that is a one-line change. Internal no-answer plays vm-nobodyavail when the extension has no mailbox — deliberately NOT the customer queue, which is where inbound callers fall back.
· SecurityService
POST /service-jobs, gated on MANAGE_SERVICE_WORK
The create verb the work-order surface never had. app/commerce/service/new is a seventeen-line placeholder because there was nothing to call. Gated on MANAGE_SERVICE_WORK like every other write here. It is a CHILD of VIEW_SERVICE_WORK, so accepting view would still look gated while admitting anyone who can merely see the board — ServiceJobRightsGateTest drives the new handler with a view-only session and asserts the service is never reached. The tenant comes off the session and reaches the service as its first argument, where it becomes a predicate inside the query; the body's marketUid and customerUid name nothing about an organisation. A market or account belonging to another tenant is "not found" and answers 404, indistinguishable from one that never existed. CreateJobInput's components are asserted: no status, no jobNumber, no totals, no sign-off. The verb-set assertion now expects four POSTs, so the Next proxy for this surface still has to export all five verbs.
· kamo-shared-library
CreateJob, so a work order can be raised without an order
The one verb the service-job surface was missing. Until now the only thing on the platform that could produce a ServiceJob row was **************** off a paid order — which is why every service-work table holds zero rows. CreateJobInput carries no column the server owns: no status, no jobNumber, no totals, no signedOff/signoffAt. SW6b took exactly those five off JobInput after finding PUT /{uid} accepted them verbatim; a create path that took them would be the same door with a different name on it. sourceCommitment stays null, deliberately. processOrderPaid opens with `if (order.getCommitment() != null) return;`, so a commitment invented here to fill a nullable column would disable the whole paid-order downstream if this job later joined an order. One ServiceVisit, sequenceNo 1, SCHEDULED, no calendar event and no assignee — the same four facts createFromCommitment writes, so a job looks the same however it was raised. The market and the customer are resolved WITH the org predicate in the query (findByIdAndOrganizationId / findByUidAndOrganization), the pair SW4 used on QuoteService.create. The market cannot be stored — there is no market column on ServiceJob and none on Engagement — so its one durable effect is the engagement type, through **************** exactly as a quote's is.
· kamo-internal
Mirror VIEW/MANAGE_ORDERS and VIEW/MANAGE_PRICING
The client half of closing the order-mutation hole. SecurityService now gates 39 order, pricing and tax endpoints on four new rights; these are the two mirrors of the Java enum that have to move with it, or the rights exist on the server and are ungrantable from the UI. rightsHierarchy.ts gains four edges — VIEW_ORDERS and VIEW_PRICING under ACCESS_COMMERCE, MANAGE_ORDERS under the first and MANAGE_PRICING under the second — which is what makes the editor cascade agree with the server's normalisation. RoleRightType.ts gains the four constants AND their four _values entries; a constant left out of that array is invisible to everything that iterates the enum, and the permission editors iterate. No permission-section change was needed: all four carry ServiceType.POS and the Commerce row already exists in both permissionAppSections.ts and its inline duplicate in SecurityRoleManager.tsx, so they are grantable on arrival. Both comments are corrected to say so rather than leaving the count stale. No dictionary change either — right labels are the Java `display` string mirrored verbatim, not an i18n key. Service Work's five added nothing to kamo-translation-dictionary for the same reason. Parity total 189 -> 193. Verified against the Java source by rightsHierarchyParity and roleRightMirrorParity, both of which read kamo-shared-library directly; full suite 278 files / 3603 tests, 0 failures.
· kamo-shared-library
VIEW/MANAGE_ORDERS and VIEW/MANAGE_PRICING, ids 273-276
No order-mutation right existed anywhere. POSController's order endpoints and every pricing and tax handler in CommerceMarketController read the org off the session and checked nothing else, so any authenticated member of any role could create, edit, refund and re-status orders — and rewrite price lists, tax classes, tax zones, tax rates and customer tiers — inside their own org. Never cross-tenant; role simply meant nothing on those surfaces. Four rights rather than three: MANAGE hangs off VIEW in both pairs so that changing an order or a price cannot be granted without the ability to read what is being changed, and so that a write endpoint accepting the VIEW right is a detectable downgrade rather than an invisible one. VIEW_PRICING is not decoration — reading what the tax rates and price lists ARE is an ordinary finance and audit need that must not carry the power to change them. Max id was re-checked against origin/main immediately before writing: 272. A duplicate id is a Collectors.toMap failure in a static initialiser, i.e. every service that loads this enum stops booting. Pinned counts move 263/189/35/39 -> 267/193/35/39: all four are children and ACCESS_COMMERCE was already a root-with-children, so only two numbers move. Note: this commit also carries a five-line comment removal in RoleRightType.java made by a concurrent session in this checkout. `git commit --only` takes the worktree state of the path; the change is theirs, not mine, and is preserved here rather than discarded.
· KlusterServices
Recorded-call notice on direct-dialled extensions too
A caller who dials an extension over the greeting now hears the notice as well. That path went through from-did-direct, where the Dial belongs to FreePBX's ext-local, so A() could not be attached to it — the caller reached an agent with no notice at all. Direct dials now route through [kamo-direct-connect], which owns its own Dial and therefore carries aA(agent:client) exactly like the queue path. The cost is re-implementing the two things ext-local gave for free, both preserved here: - ring timer: per-extension AMPUSER/<ext>/ringtimer where set, else FreePBX's RINGTIMER_DEFAULT (15s), so an extension rings for as long as it used to - no-answer: the extension's own mailbox if it has one, otherwise back into the queue for a live person rather than dead-ending the caller. The queue's 10-minute timeout still carries them to the main mailbox eventually. Kept separate from [kamo-agent-connect] deliberately. That context is reached by queue members, where a no-answer must fall back to app_queue so it can try the next agent; adding a voicemail fallback there would drop a QUEUED caller into one agent's personal mailbox. Verified live: 7255 (no mailbox, offline) rang 15s then returned to the queue, which rang both agents and connected on 8080. Branch selection is now visible in the CDR via userfield — the 8912 run recorded DS:CHANUNAVAIL/VM:default, i.e. it correctly fell through to Voicemail. Asterisk then logged "No audio available on Local/...;2", which is the medialess test channel, not the dialplan: a channel from `channel originate ... application Wait` has no media path for playback, the same reason MixMonitor returns an empty file on it.
· KlusterServices
Recorded-call notice to both parties at once, then a bridge tone
When an agent answers a queued call, the caller hears the client notice and the agent hears the agent notice at the same time, then a shared 1000Hz tone marks the moment they are connected. Queue members now point at Local channels into [kamo-agent-connect] rather than straight at PJSIP/<ext>, so the final leg is a Dial() — Dial's A(x:y) is the only thing in Asterisk that plays DIFFERENT audio to each party SIMULTANEOUSLY and then bridges. Queue's own announceoverride is not a substitute: it plays to the agent only, and before bridging while the caller is still on hold music. The lowercase `a` before A() is the linchpin and is not optional. Without it Dial leaves the calling channel unanswered until the announcement finishes, so app_queue never bridges and the caller-side file plays into a channel nobody is listening to. queue_log made this measurable: CONNECT at 7s (after the audio) without `a`, CONNECT at 0s with it. Proven by recording the caller leg with hold music silenced and cross- correlating the captured audio: vs client file 0.833 vs agent file 0.068 vs hold music 0.044 and the 1000Hz tone detected in the caller's own audio at t=5.75s. Both files are padded to exactly 7.53s (client speech is 0.32s shorter, so it carries the difference as silence) and end with the same tone, so the tones fire together and neither party is released early. Tone level is matched to speech (RMS 3082 vs ~3200) — ffmpeg's sine filter is far below full scale, so it needed calibrating rather than guessing. Known limitation: this covers the QUEUE path. A caller who dials an extension directly over the greeting still reaches from-did-direct and hears no notice; routing that through this context would trade away FreePBX's per-extension ring timer and voicemail fallback.
· InitializerService
4 GB per seat on every plan, and seats rather than users
Storage stops being a tier differentiator. The 2/4/5 ladder put one gigabyte between Starter and Business on a page that then had to explain what "pooled" meant, so it is now 4 GB per seat everywhere: stated once on Free, inherited by the cards above it. No display code needed a special case — the cards render a tier's delta over the one below, so equal rows drop out on their own. Enterprise loses "150 GB for the organization". StorageQuotaService reads only numeric_value and multiplies it by seat_count, so that row was billed as 150 GB PER SEAT while the page promised a flat pool. The prose and the code disagreed and the code won silently. The USERS feature is labelled "Seats" with unit "seats". The feature CODE is deliberately unchanged — it is matched on by name across the fleet. Also repairs the internal owner plan, found while checking nobody was pushed over the new ceiling: both its measured rows carried NULL with numeric_is_unlimited = false, which planStorage reads as a hard zero. The plan whose description reads "Covers every feature" was entitled to no storage and one seat, and kamoclub holds it at 99 seats.
· kamo-internal
The six work-order tabs persist
SW6b Task 4. Labour, parts, time, vendors, sign-off and notes were React `useState` on `commerce/service/[id]` and lost everything on reload. They now write to the endpoints SW6b Task 3 landed. The client mirrors the three breaking changes in `ServiceJobApi.java`: * `JobInput` loses `labourTotal`, `materialTotal`, `total`, `signedOff` and `signoffAt`. The totals are recomputed server-side from the job's lines on every line write, and sign-off goes through `PATCH /{uid}/signoff`, which stamps its own clock. Leaving them on the general update was a second door onto five columns the server owns, and the second door is the one nobody checks. * `JobView` gains `lines`, `notes` and `vendors`. **Null means "not loaded", empty means "none"** — `listJobs` leaves all three null so the grid does not run three queries per row, and every tab tells the two apart rather than drawing "no lines" over a job that has twelve. * The proxy exports all five verbs. POST and DELETE were deliberately absent while a job had no children; a line and a vendor engagement are created and removed here, so both are mapped now. **************** asserts the set in both directions and was widened with them. Every mutation answers with the whole detail `JobView` and each handler hands that straight to `adopt`. Nothing merges a write's effect by hand: the three totals are the server's and no client arithmetic reproduces them. `adopt` deliberately does NOT re-seed the sign-off draft — SW6a's page re-seeded every local field from each response, so adding a line silently discarded whatever the engineer had typed into the completion write-up. Only the load and a successful sign-off save re-seed it. The notes tab is ported from the markup salvaged out of the dead work-order dialog, reusing its five existing keys. Two departures, both recorded in the code: the note author is an id and the tab says "You" for the current member and omits the author line for anyone else, because no endpoint resolves a batch of member ids to names and a bare 19-digit number under a heading that says "author" helps nobody; and the salvage's `.reverse()` is dropped, because the server already answers newest-first and reversing that puts the oldest on top. `commerceVendorDirectory.ts` is new and exists because `posApi.getVendors()` cannot be used for the vendor picker: `POSController.getVendors` answers with the `Vendor` entity, whose `uid` is a ~19-digit `unique_rowid()` written as a bare JSON number, so `JSON.parse` rounds it before any component sees it and `posApi.Vendor` duly declares `uid: number`. Engaging a vendor with a rounded id names a row that does not exist. This module reads the response as text and quotes long `"uid"` values before parsing. The narrow fix is a DTO that sends the id as a string, as `ServiceJobApi` does; until that exists this is the honest read. Dictionary landed first: kamo-translation-dictionary 0eaab740.
· KlusterServices
Pass the advertised public IP to the media pod
· SecurityService
Eight work-order endpoints for lines, notes, vendors, sign-off and the clock
SW6b Task 3, controller half. POST/PUT/DELETE /{jobUid}/lines/..., POST /{jobUid}/notes, POST/DELETE /{jobUid}/vendors/..., PATCH /{jobUid}/signoff and PATCH **************** Reads stay on VIEW_SERVICE_WORK; every one of the eight is MANAGE_SERVICE_WORK. MANAGE_SERVICE_WORK is a CHILD of VIEW_SERVICE_WORK, so a mutation cloned from the read handler beside it accepts view on a write and still looks gated in review. ServiceJobRightsGateTest drives all twelve handlers with a view-only session and asserts the service is never reached -- a handler that acts and then checks returns 403 having already written the row. Both ids reach the service on every child endpoint, and the service resolves the child with the job uid and the org as predicates in one query. Without the parent one, DELETE /{jobUid}/lines/{lineUid} reaches any line in the org through a URL that looks entirely correct. A note's author is memberId(req) and never the body: NoteInput carries a body and nothing else, on a record with no edit path precisely so that what it says stays what was said. Clocking out of a visit nobody clocked in to is a 409 naming the visit's state, not a 400 and not a 500 -- the request is well formed and the row exists, and what is wrong is the state. Engaging the same vendor twice is a 409 too. The test now reads the mapped handler names off the controller and asserts this class drives every one of them, so a ninth endpoint added and forgotten here fails rather than being covered by nothing. The verb-set assertion is widened to all five verbs: the Next proxy for this surface must export GET, POST, PUT, PATCH and DELETE or every save answers 405 from Next with nothing in the server logs. Mutation-tested, 19 mutations, each a distinct named failure: the right check deleted on each of the eight, MANAGE swapped for VIEW on each of the eight, the check moved after the service call, the session org replaced with a literal, and one verb changed.

Ulimwengu wako unasubiri.
Kuanza bure katika dakika. Ongeza bidhaa wakati wowote wewe ni tayari - hakuna uhamiaji, hakuna logins mpya.
Bure milele - hakuna kadi ya mkopo inahitajika