diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..311502b --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +IDRIC_SMS_REQUEST ?= idric-sms-request + +.PHONY: test + +test: + sh -n services/sms/idric_sms_service.sh + sh -n tests/sms_service_test.sh + IDRIC_SMS_REQUEST="$(IDRIC_SMS_REQUEST)" sh tests/sms_service_test.sh diff --git a/README.md b/README.md index 43cec6a..be4cf98 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,10 @@ git clone --recurse-submodules https://github.com/isomorphisms/grease.git ``` Keeping the Oils-derived tree as a pinned submodule avoids copying the full upstream repository while giving Grease one stable, reproducible source location for CI and local builds. + +## Service glue + +Filesystem-oriented service runners live under `services/`. The first one is +the [inspectable SMS service](services/sms/README.md): deterministic Idriç +request parsing, durable one-time reminders, explicit consent provenance, +`CANCEL`/`STOP`, and a fake outbox. diff --git a/services/sms/README.md b/services/sms/README.md new file mode 100644 index 0000000..c956947 --- /dev/null +++ b/services/sms/README.md @@ -0,0 +1,59 @@ +# Inspectable SMS service state + +This is the first filesystem service runner for `Network.SMS` in Idric-Net. It +uses the compiled `idric-sms-request` program for deterministic request parsing +and a fake outbox for transport. It does not contain a language model or a +provider API. + +The first runner uses UTC deliberately. A transport supplies a trusted +telephone address, receive time, and unique inbound message id: + +```sh +idric_sms_service.sh init ./state +idric_sms_service.sh inbound ./state +15550000001 \ + 2026-09-01T14:00:00Z in_000001 'REMIND 15:00' +idric_sms_service.sh run_due ./state 2026-09-01T15:00:00Z +``` + +The receipt is: + +```text +scheduled evt_in_000001 2026-09-01T15:00:00Z +sent evt_in_000001 +``` + +and `state/fake_outbox/fake_evt_in_000001/body` contains `hey`. + +## State transitions + +```text +inbound// trusted envelope plus original body +addresses/ address-to-principal lookup +principals// unprivileged correspondent and addresses +consent// exact authorization provenance +scheduled/// durable pending event +sent// event moved here after fake delivery +cancelled// event moved here by CANCEL or STOP +fake_outbox// inspectable provider-independent delivery +opt_out/ durable STOP state +``` + +Creating a reminder creates directories and small ordinary files. Firing or +cancelling it is an atomic `mv` out of `scheduled/`. `STOP` creates an opt-out +marker and moves every pending event for that principal to `cancelled/`. +Possession of an address record never grants blanket permission: every outbound +event names one record under `consent/`, including the principal, destination, +request time, inbound id, original request, scheduled time, and message. + +The unknown-caller path creates a `public_correspondent`, not a Unix account and +not a privileged Idriç principal. An explicit later enrollment mechanism may +promote or link principals without treating a telephone number as permanent +identity. + +## Boundary + +The Grease runner owns filesystem transitions and invocation. Idric-Net owns +telephone, principal, message, request, authorization, and transport meanings. +The fake outbox is permanent diagnostic infrastructure. A future provider, +GSM-modem, or Android adapter should consume the same outbound event rather than +changing the scheduler's state model. diff --git a/services/sms/idric_sms_service.sh b/services/sms/idric_sms_service.sh new file mode 100755 index 0000000..706cbf0 --- /dev/null +++ b/services/sms/idric_sms_service.sh @@ -0,0 +1,371 @@ +#!/bin/sh + +set -eu + +program_name=${0##*/} + +die() { + printf '%s: %s\n' "$program_name" "$1" >&2 + exit 1 +} + +usage() { + die "usage: $program_name init STATE | inbound STATE FROM RECEIVED_AT INBOUND_ID BODY | run_due STATE NOW" +} + +write_file() { + write_destination=$1 + write_value=$2 + write_temporary="${write_destination}.tmp.$$" + umask 077 + printf '%s\n' "$write_value" > "$write_temporary" + mv "$write_temporary" "$write_destination" +} + +safe_id() { + case "$1" in + ''|*[!A-Za-z0-9_-]*) return 1 ;; + *) return 0 ;; + esac +} + +valid_phone() { + case "$1" in + +*) digits=${1#+} ;; + *) return 1 ;; + esac + case "$digits" in + ''|*[!0-9]*) return 1 ;; + *) return 0 ;; + esac +} + +valid_instant() { + case "$1" in + ????-??-??T??:??:??Z) ;; + *) return 1 ;; + esac + date -u -d "$1" '+%Y-%m-%dT%H:%M:%SZ' >/dev/null 2>&1 +} + +initialize_state() { + state=$1 + umask 077 + mkdir -p \ + "$state/addresses" \ + "$state/principals" \ + "$state/inbound" \ + "$state/consent" \ + "$state/scheduled" \ + "$state/sent" \ + "$state/cancelled" \ + "$state/fake_outbox" \ + "$state/opt_out" \ + "$state/counters" \ + "$state/locks" + if [ ! -f "$state/counters/next_principal" ]; then + write_file "$state/counters/next_principal" 1 + fi +} + +acquire_principal_lock() { + state=$1 + attempt=0 + while ! mkdir "$state/locks/principal" 2>/dev/null; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 50 ] || die "principal allocator remained locked" + sleep 0.1 + done +} + +resolve_principal() { + state=$1 + phone=$2 + address_record="$state/addresses/$phone" + if [ -f "$address_record" ]; then + principal=$(sed -n '1p' "$address_record") + safe_id "$principal" || die "invalid principal mapping for $phone" + printf '%s\n' "$principal" + return + fi + + acquire_principal_lock "$state" + if [ -f "$address_record" ]; then + principal=$(sed -n '1p' "$address_record") + rmdir "$state/locks/principal" + printf '%s\n' "$principal" + return + fi + + next=$(sed -n '1p' "$state/counters/next_principal") + case "$next" in + ''|*[!0-9]*) + rmdir "$state/locks/principal" + die "invalid next principal counter" + ;; + esac + principal=$(printf '%06d' "$next") + principal_dir="$state/principals/$principal" + mkdir -p "$principal_dir/addresses" "$principal_dir/consent" "$principal_dir/stop" + write_file "$principal_dir/kind" public_correspondent + write_file "$principal_dir/addresses/phone" "$phone" + write_file "$address_record" "$principal" + write_file "$state/counters/next_principal" $((next + 1)) + rmdir "$state/locks/principal" + printf '%s\n' "$principal" +} + +record_inbound() { + state=$1 + phone=$2 + received_at=$3 + inbound_id=$4 + body=$5 + principal=$6 + final="$state/inbound/$inbound_id" + [ ! -e "$final" ] || die "inbound id already exists: $inbound_id" + temporary="$state/inbound/.${inbound_id}.tmp.$$" + mkdir "$temporary" + write_file "$temporary/from" "$phone" + write_file "$temporary/received_at" "$received_at" + write_file "$temporary/body" "$body" + write_file "$temporary/principal" "$principal" + mv "$temporary" "$final" +} + +record_authorization() { + state=$1 + event_id=$2 + principal=$3 + phone=$4 + requested_at=$5 + inbound_id=$6 + original_request=$7 + scheduled_for=$8 + message=$9 + final="$state/consent/$event_id" + [ ! -e "$final" ] || die "authorization already exists: $event_id" + temporary="$state/consent/.${event_id}.tmp.$$" + mkdir "$temporary" + write_file "$temporary/principal" "$principal" + write_file "$temporary/destination" "$phone" + write_file "$temporary/requested_at" "$requested_at" + write_file "$temporary/inbound_id" "$inbound_id" + write_file "$temporary/original_request" "$original_request" + write_file "$temporary/scheduled_for" "$scheduled_for" + write_file "$temporary/message" "$message" + mv "$temporary" "$final" + : > "$state/principals/$principal/consent/$event_id" +} + +make_event() { + destination=$1 + event_id=$2 + principal=$3 + phone=$4 + message=$5 + authorization=$6 + scheduled_for=$7 + temporary="${destination}/.${event_id}.tmp.$$" + final="$destination/$event_id" + [ ! -e "$final" ] || die "event already exists: $event_id" + mkdir "$temporary" + write_file "$temporary/principal" "$principal" + write_file "$temporary/destination" "$phone" + write_file "$temporary/message" "$message" + write_file "$temporary/authorization" "$authorization" + write_file "$temporary/scheduled_for" "$scheduled_for" + mv "$temporary" "$final" +} + +derive_due() { + received_at=$1 + clock=$2 + received_epoch=$(date -u -d "$received_at" '+%s') || die "invalid received time: $received_at" + received_date=${received_at%%T*} + candidate="${received_date}T${clock}:00Z" + candidate_epoch=$(date -u -d "$candidate" '+%s') || die "invalid reminder time: $clock" + if [ "$candidate_epoch" -le "$received_epoch" ]; then + next_date=$(date -u -d "$received_date + 1 day" '+%Y-%m-%d') || die "could not advance reminder date" + candidate="${next_date}T${clock}:00Z" + fi + printf '%s\n' "$candidate" +} + +fake_deliver() { + state=$1 + event_dir=$2 + sent_at=$3 + event_id=${event_dir##*/} + receipt="fake_$event_id" + outbox="$state/fake_outbox/$receipt" + [ ! -e "$outbox" ] || die "fake receipt already exists: $receipt" + mkdir "$outbox" + write_file "$outbox/event_id" "$event_id" + write_file "$outbox/to" "$(sed -n '1p' "$event_dir/destination")" + write_file "$outbox/body" "$(sed -n '1p' "$event_dir/message")" + write_file "$outbox/sent_at" "$sent_at" + write_file "$event_dir/transport" fake + write_file "$event_dir/transport_receipt" "$receipt" + write_file "$event_dir/sent_at" "$sent_at" + mv "$event_dir" "$state/sent/$event_id" +} + +cancel_event() { + state=$1 + principal=$2 + event_id=$3 + cancelled_at=$4 + cancellation_inbound=$5 + reason=$6 + found= + for candidate in "$state"/scheduled/*/"$event_id"; do + [ -d "$candidate" ] || continue + event_principal=$(sed -n '1p' "$candidate/principal") + [ "$event_principal" = "$principal" ] || die "event is not owned by this principal: $event_id" + write_file "$candidate/cancelled_at" "$cancelled_at" + write_file "$candidate/cancellation_inbound_id" "$cancellation_inbound" + write_file "$candidate/cancellation_reason" "$reason" + mv "$candidate" "$state/cancelled/$event_id" + due_dir=${candidate%/*} + rmdir "$due_dir" 2>/dev/null || true + found=yes + break + done + [ -n "$found" ] || die "scheduled event not found: $event_id" +} + +handle_echo() { + state=$1 + principal=$2 + phone=$3 + received_at=$4 + inbound_id=$5 + original=$6 + message=$7 + [ ! -e "$state/opt_out/$principal" ] || die "principal has opted out" + event_id="echo_$inbound_id" + record_authorization "$state" "$event_id" "$principal" "$phone" "$received_at" "$inbound_id" "$original" immediate "$message" + make_event "$state/scheduled" "$event_id" "$principal" "$phone" "$message" "consent/$event_id" immediate + fake_deliver "$state" "$state/scheduled/$event_id" "$received_at" + printf 'echoed %s\n' "$event_id" +} + +handle_remind() { + state=$1 + principal=$2 + phone=$3 + received_at=$4 + inbound_id=$5 + original=$6 + clock=$7 + [ ! -e "$state/opt_out/$principal" ] || die "principal has opted out" + event_id="evt_$inbound_id" + due=$(derive_due "$received_at" "$clock") + due_dir="$state/scheduled/$due" + mkdir -p "$due_dir" + record_authorization "$state" "$event_id" "$principal" "$phone" "$received_at" "$inbound_id" "$original" "$due" hey + make_event "$due_dir" "$event_id" "$principal" "$phone" hey "consent/$event_id" "$due" + printf 'scheduled %s %s\n' "$event_id" "$due" +} + +handle_stop() { + state=$1 + principal=$2 + received_at=$3 + inbound_id=$4 + : > "$state/opt_out/$principal" + stop_dir="$state/principals/$principal/stop/$inbound_id" + mkdir "$stop_dir" + write_file "$stop_dir/requested_at" "$received_at" + cancelled=0 + for candidate in "$state"/scheduled/*/*; do + [ -d "$candidate" ] || continue + event_principal=$(sed -n '1p' "$candidate/principal") + [ "$event_principal" = "$principal" ] || continue + event_id=${candidate##*/} + cancel_event "$state" "$principal" "$event_id" "$received_at" "$inbound_id" STOP + cancelled=$((cancelled + 1)) + done + printf 'stopped %s cancelled=%s\n' "$principal" "$cancelled" +} + +handle_inbound() { + [ "$#" -eq 5 ] || usage + state=$1 + phone=$2 + received_at=$3 + inbound_id=$4 + body=$5 + valid_phone "$phone" || die "telephone address must be '+' followed by digits" + valid_instant "$received_at" || die "received time must be UTC YYYY-MM-DDTHH:MM:SSZ" + safe_id "$inbound_id" || die "inbound id may contain only letters, digits, '-' or '_'" + initialize_state "$state" + principal=$(resolve_principal "$state" "$phone") + record_inbound "$state" "$phone" "$received_at" "$inbound_id" "$body" "$principal" + + parser=${IDRIC_SMS_REQUEST:-idric-sms-request} + if ! normalized=$($parser "$body"); then + printf '%s\n' "$normalized" >&2 + exit 1 + fi + + case "$normalized" in + 'ECHO '*) + message=${normalized#ECHO } + handle_echo "$state" "$principal" "$phone" "$received_at" "$inbound_id" "$body" "$message" + ;; + 'REMIND '*) + clock=${normalized#REMIND } + handle_remind "$state" "$principal" "$phone" "$received_at" "$inbound_id" "$body" "$clock" + ;; + 'CANCEL '*) + event_id=${normalized#CANCEL } + cancel_event "$state" "$principal" "$event_id" "$received_at" "$inbound_id" CANCEL + printf 'cancelled %s\n' "$event_id" + ;; + STOP) + handle_stop "$state" "$principal" "$received_at" "$inbound_id" + ;; + *) die "Idriç parser returned an unknown request" ;; + esac +} + +run_due() { + [ "$#" -eq 2 ] || usage + state=$1 + now=$2 + valid_instant "$now" || die "run time must be UTC YYYY-MM-DDTHH:MM:SSZ" + initialize_state "$state" + for event_dir in "$state"/scheduled/*/*; do + [ -d "$event_dir" ] || continue + due_dir=${event_dir%/*} + due=${due_dir##*/} + [ "$due" \> "$now" ] || { + event_id=${event_dir##*/} + principal=$(sed -n '1p' "$event_dir/principal") + if [ -e "$state/opt_out/$principal" ]; then + cancel_event "$state" "$principal" "$event_id" "$now" scheduler STOP + printf 'cancelled %s opted_out\n' "$event_id" + else + fake_deliver "$state" "$event_dir" "$now" + rmdir "$due_dir" 2>/dev/null || true + printf 'sent %s\n' "$event_id" + fi + } + done +} + +[ "$#" -ge 1 ] || usage +command=$1 +shift + +case "$command" in + init) + [ "$#" -eq 1 ] || usage + initialize_state "$1" + ;; + inbound) handle_inbound "$@" ;; + run_due) run_due "$@" ;; + *) usage ;; +esac diff --git a/tests/sms_service_test.sh b/tests/sms_service_test.sh new file mode 100755 index 0000000..8b4e90e --- /dev/null +++ b/tests/sms_service_test.sh @@ -0,0 +1,101 @@ +#!/bin/sh + +set -eu + +service=${SMS_SERVICE:-./services/sms/idric_sms_service.sh} +parser=${IDRIC_SMS_REQUEST:-idric-sms-request} +test_root=$(mktemp -d) +state="$test_root/state" + +cleanup() { + case "$test_root" in + /tmp/*) rm -rf -- "$test_root" ;; + esac +} +trap cleanup EXIT HUP INT TERM + +fail() { + printf 'FAIL %s\n' "$1" >&2 + exit 1 +} + +assert_file() { + [ -f "$1" ] || fail "missing $1" +} + +assert_value() { + actual=$(sed -n '1p' "$1") + [ "$actual" = "$2" ] || fail "$1: expected '$2', got '$actual'" +} + +run_service() { + IDRIC_SMS_REQUEST="$parser" "$service" "$@" +} + +run_service init "$state" + +imani=$(run_service inbound "$state" +15550000001 2026-09-01T14:00:00Z in_imani 'REMIND 15:00') +[ "$imani" = 'scheduled evt_in_imani 2026-09-01T15:00:00Z' ] || fail "Imani schedule receipt" +assert_value "$state/consent/evt_in_imani/inbound_id" in_imani +assert_value "$state/consent/evt_in_imani/destination" +15550000001 +assert_value "$state/consent/evt_in_imani/original_request" 'REMIND 15:00' +assert_value "$state/scheduled/2026-09-01T15:00:00Z/evt_in_imani/message" hey + +run_service run_due "$state" 2026-09-01T14:59:59Z >/dev/null +[ ! -e "$state/fake_outbox/fake_evt_in_imani" ] || fail "Imani reminder fired early" + +# This is a separate process invocation: scheduled state survived the restart. +sent=$(run_service run_due "$state" 2026-09-01T15:00:00Z) +[ "$sent" = 'sent evt_in_imani' ] || fail "Imani send receipt" +assert_value "$state/fake_outbox/fake_evt_in_imani/body" hey +assert_value "$state/fake_outbox/fake_evt_in_imani/to" +15550000001 +assert_file "$state/sent/evt_in_imani/transport_receipt" +[ ! -e "$state/scheduled/2026-09-01T15:00:00Z/evt_in_imani" ] || fail "sent event remained scheduled" +printf 'ok durable REMIND sends hey with consent provenance\n' + +felicity=$(run_service inbound "$state" +15550000002 2026-09-01T14:10:00Z in_felicity 'ECHO hey') +[ "$felicity" = 'echoed echo_in_felicity' ] || fail "Felicity echo receipt" +assert_value "$state/fake_outbox/fake_echo_in_felicity/body" hey +assert_value "$state/sent/echo_in_felicity/authorization" consent/echo_in_felicity +printf 'ok ECHO crosses inbound and fake outbound boundaries\n' + +run_service inbound "$state" +15550000003 2026-09-01T14:00:00Z in_hasan 'REMIND 15:00' >/dev/null +cancelled=$(run_service inbound "$state" +15550000003 2026-09-01T14:05:00Z in_hasan_cancel 'CANCEL evt_in_hasan') +[ "$cancelled" = 'cancelled evt_in_hasan' ] || fail "Hasan cancellation receipt" +assert_file "$state/cancelled/evt_in_hasan/cancelled_at" +run_service run_due "$state" 2026-09-01T15:00:00Z >/dev/null +[ ! -e "$state/fake_outbox/fake_evt_in_hasan" ] || fail "cancelled Hasan reminder was sent" +printf 'ok CANCEL moves scheduled state and prevents delivery\n' + +run_service inbound "$state" +15550000004 2026-09-01T14:00:00Z in_armani 'REMIND 16:00' >/dev/null +stopped=$(run_service inbound "$state" +15550000004 2026-09-01T14:01:00Z in_armani_stop STOP) +case "$stopped" in + 'stopped '*"cancelled=1") ;; + *) fail "Armani STOP receipt" ;; +esac +assert_file "$state/opt_out/$(sed -n '1p' "$state/addresses/+15550000004")" +assert_file "$state/cancelled/evt_in_armani/cancellation_reason" +if run_service inbound "$state" +15550000004 2026-09-01T14:02:00Z in_armani_again 'REMIND 16:00' >/dev/null 2>&1; then + fail "opted-out principal scheduled another message" +fi +printf 'ok STOP is durable and cancels outstanding reminders\n' + +run_service inbound "$state" +15550000005 2026-09-01T14:00:00Z in_gina 'REMIND 17:00' >/dev/null +run_service inbound "$state" +15550000006 2026-09-01T14:00:00Z in_moe 'REMIND 17:00' >/dev/null +assert_file "$state/scheduled/2026-09-01T17:00:00Z/evt_in_gina/message" +assert_file "$state/scheduled/2026-09-01T17:00:00Z/evt_in_moe/message" +run_service run_due "$state" 2026-09-01T17:00:00Z >/dev/null +assert_file "$state/fake_outbox/fake_evt_in_gina/body" +assert_file "$state/fake_outbox/fake_evt_in_moe/body" +printf 'ok same-time reminders remain distinct across principals\n' + +run_service inbound "$state" +15550000007 2026-09-01T14:00:00Z in_cash 'ECHO hey' >/dev/null +cash_principal=$(sed -n '1p' "$state/addresses/+15550000007") +assert_value "$state/principals/$cash_principal/kind" public_correspondent +printf 'ok unknown caller becomes an unprivileged correspondent\n' + +if run_service inbound "$state" +15550000008 2026-09-01T14:00:00Z in_paul 'remind me at 3' >/dev/null 2>&1; then + fail "natural language bypassed deterministic parsing" +fi +assert_file "$state/inbound/in_paul/body" +printf 'ok rejected natural language causes no scheduled effect\n'