feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- This migration contains deletion evidence and source-suppression records.
|
||||
-- Refuse automated rollback so restoring an old schema cannot silently resurrect data.
|
||||
DO $$ BEGIN RAISE EXCEPTION 'CALL-MEMORY-1.1 downgrade requires an audited export and deletion-ledger replay; use a forward migration'; END $$;
|
||||
@@ -0,0 +1,270 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE curated_memory ADD COLUMN memory_type text NOT NULL DEFAULT 'STORY'
|
||||
CHECK(memory_type IN ('RELATIONSHIP','PROFILE','EVENT','PREFERENCE','OPEN_ITEM','STORY','INTERACTION'));
|
||||
ALTER TABLE curated_memory ADD COLUMN proactive_policy text NOT NULL DEFAULT 'ALLOWED'
|
||||
CHECK(proactive_policy IN ('ALLOWED','RESPOND_ONLY','NEVER'));
|
||||
ALTER TABLE curated_memory ADD COLUMN entity_key text NOT NULL DEFAULT '';
|
||||
ALTER TABLE curated_memory ADD COLUMN item_status text CHECK(item_status IN ('OPEN','COMPLETED','CANCELLED','UNKNOWN'));
|
||||
ALTER TABLE curated_memory ADD COLUMN planned_at timestamptz;
|
||||
ALTER TABLE curated_memory ADD COLUMN completed_at timestamptz;
|
||||
ALTER TABLE curated_memory ADD COLUMN completion_basis jsonb;
|
||||
ALTER TABLE curated_memory ADD COLUMN statement_basis text NOT NULL DEFAULT 'USER_CONFIRMED';
|
||||
ALTER TABLE curated_memory ADD COLUMN source_time timestamptz;
|
||||
ALTER TABLE curated_memory ADD CONSTRAINT open_item_fields CHECK (
|
||||
(memory_type='OPEN_ITEM' AND item_status IS NOT NULL AND (item_status<>'COMPLETED' OR (completed_at IS NOT NULL AND completion_basis IS NOT NULL)))
|
||||
OR (memory_type<>'OPEN_ITEM' AND item_status IS NULL));
|
||||
ALTER TABLE memory_version ALTER COLUMN confirmed_by DROP NOT NULL;
|
||||
|
||||
CREATE TABLE memory_user_state (
|
||||
subject_id uuid PRIMARY KEY REFERENCES subject_profile(id), tenant_id uuid NOT NULL REFERENCES tenant(id),
|
||||
memory_revision bigint NOT NULL DEFAULT 0 CHECK(memory_revision>=0),
|
||||
privacy_epoch bigint NOT NULL DEFAULT 0 CHECK(privacy_epoch>=0),
|
||||
viking_visible_revision bigint NOT NULL DEFAULT 0 CHECK(viking_visible_revision>=0 AND viking_visible_revision<=memory_revision),
|
||||
legacy_projection_pending boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now());
|
||||
INSERT INTO memory_user_state(subject_id,tenant_id,legacy_projection_pending) SELECT id,tenant_id,true FROM subject_profile;
|
||||
|
||||
CREATE TABLE call_provider_profile (
|
||||
id text PRIMARY KEY CHECK(length(id)<=64), protocol_version text NOT NULL CHECK(protocol_version='CALL-MEMORY-1.1'),
|
||||
sdk_version text NOT NULL, bot_id text NOT NULL, model_version text NOT NULL,
|
||||
transport text NOT NULL CHECK(transport='RTC'), audio_profile jsonb NOT NULL,
|
||||
vendor_template jsonb NOT NULL, field_mapping jsonb NOT NULL, capabilities jsonb NOT NULL DEFAULT '{}',
|
||||
evidence jsonb NOT NULL DEFAULT '{}', projection_schema jsonb NOT NULL DEFAULT '{}', published boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(id,sdk_version));
|
||||
CREATE TABLE call_device_ready (
|
||||
device_id uuid PRIMARY KEY REFERENCES device(id), boot_id uuid NOT NULL, binding_generation bigint NOT NULL,
|
||||
provider_profile_id text NOT NULL REFERENCES call_provider_profile(id), firmware_version text NOT NULL,
|
||||
expires_at timestamptz NOT NULL);
|
||||
|
||||
CREATE TABLE call_session (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), device_id uuid NOT NULL REFERENCES device(id),
|
||||
tenant_id uuid NOT NULL REFERENCES tenant(id), subject_id uuid REFERENCES subject_profile(id),
|
||||
boot_id uuid NOT NULL, hook_event_id text NOT NULL CHECK(length(hook_event_id) BETWEEN 1 AND 128),
|
||||
call_generation bigint NOT NULL CHECK(call_generation>=0), binding_generation bigint NOT NULL CHECK(binding_generation>=0),
|
||||
prepare_digest text, purpose text NOT NULL DEFAULT 'NORMAL' CHECK(purpose IN ('NORMAL','FIRST_TEST')),
|
||||
manifest_id uuid, manifest_b64 text, manifest_mac text, manifest_status text NOT NULL DEFAULT 'NONE'
|
||||
CHECK(manifest_status IN ('NONE','ISSUED','REVOKED','DISCARDED')),
|
||||
provider_profile_id text REFERENCES call_provider_profile(id), media_attempt_id text,
|
||||
privacy_epoch bigint NOT NULL DEFAULT 0, memory_revision bigint NOT NULL DEFAULT 0,
|
||||
memory_mode text NOT NULL DEFAULT 'OFF' CHECK(memory_mode IN ('OFF','HOT_ONLY','NATIVE_VIKING')),
|
||||
budget_snapshot jsonb,
|
||||
expires_at timestamptz, lease_expires_at timestamptz,
|
||||
authorization_renewed_at timestamptz,
|
||||
call_state text NOT NULL DEFAULT 'PREPARING' CHECK(call_state IN ('PREPARING','STARTING','TALKING','STOPPING','ENDED','CANCELLED','FAILED')),
|
||||
remote_state text NOT NULL DEFAULT 'UNKNOWN' CHECK(remote_state IN ('NOT_STARTED','UNKNOWN','ACTIVE','STOPPED_OBSERVED')),
|
||||
transcript_state text NOT NULL DEFAULT 'COLLECTING' CHECK(transcript_state IN ('COLLECTING','DRAINING','COMPLETE','PARTIAL','EMPTY')),
|
||||
transcript_revision bigint NOT NULL DEFAULT 0, highest_contiguous_seq bigint NOT NULL DEFAULT 0,
|
||||
last_text_at timestamptz, drain_started_at timestamptz, text_gap boolean NOT NULL DEFAULT false,
|
||||
local_audio_off boolean NOT NULL DEFAULT false, sdk_stopped boolean NOT NULL DEFAULT false,
|
||||
retrieval_observation text NOT NULL DEFAULT 'NOT_OBSERVABLE',
|
||||
created_at timestamptz NOT NULL DEFAULT now(), ended_at timestamptz,
|
||||
UNIQUE(device_id,boot_id,hook_event_id));
|
||||
CREATE INDEX call_session_subject_active ON call_session(subject_id) WHERE manifest_status='ISSUED';
|
||||
ALTER TABLE device_activation_attempt ADD COLUMN call_session_id uuid REFERENCES call_session(id);
|
||||
CREATE UNIQUE INDEX activation_call_unique ON device_activation_attempt(call_session_id) WHERE call_session_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX call_device_unresolved_media ON call_session(device_id)
|
||||
WHERE manifest_status IN ('ISSUED','REVOKED') AND NOT (local_audio_off AND sdk_stopped AND remote_state IN ('NOT_STARTED','STOPPED_OBSERVED'));
|
||||
|
||||
CREATE TABLE call_request_result (
|
||||
actor text NOT NULL, request_id uuid NOT NULL, path text NOT NULL, body_digest text NOT NULL,
|
||||
response jsonb NOT NULL, expires_at timestamptz NOT NULL DEFAULT now()+interval '7 days',
|
||||
PRIMARY KEY(actor,request_id));
|
||||
CREATE TABLE call_event (
|
||||
session_id uuid NOT NULL REFERENCES call_session(id), event_id text NOT NULL,
|
||||
seq bigint NOT NULL CHECK(seq>0), event_type text NOT NULL, digest text NOT NULL, payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(session_id,event_id), UNIQUE(session_id,seq));
|
||||
CREATE TABLE call_text_fragment (
|
||||
session_id uuid NOT NULL REFERENCES call_session(id), utterance_id text NOT NULL, source_revision bigint NOT NULL CHECK(source_revision>=0),
|
||||
part_index integer NOT NULL CHECK(part_index BETWEEN 0 AND 15), part_count integer NOT NULL CHECK(part_count BETWEEN 1 AND 16),
|
||||
speaker text NOT NULL CHECK(speaker IN ('USER','ASSISTANT','UNKNOWN')), text_content text NOT NULL,
|
||||
content_sha256 text NOT NULL, is_final boolean NOT NULL, interrupted boolean NOT NULL DEFAULT false,
|
||||
source_at timestamptz NOT NULL, PRIMARY KEY(session_id,utterance_id,source_revision,part_index),
|
||||
CHECK(part_index<part_count AND octet_length(text_content)<=4096));
|
||||
CREATE TABLE call_utterance (
|
||||
session_id uuid NOT NULL REFERENCES call_session(id), utterance_id text NOT NULL, source_revision bigint NOT NULL,
|
||||
speaker text NOT NULL CHECK(speaker IN ('USER','ASSISTANT','UNKNOWN')), text_content text NOT NULL,
|
||||
interrupted boolean NOT NULL, playback_complete boolean NOT NULL DEFAULT false, source_at timestamptz NOT NULL,
|
||||
PRIMARY KEY(session_id,utterance_id));
|
||||
CREATE TABLE call_command (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), session_id uuid NOT NULL REFERENCES call_session(id),
|
||||
privacy_epoch bigint NOT NULL, type text NOT NULL CHECK(type IN ('STOP_CALL','PLAY_LOCAL_NOTICE')),
|
||||
reason text NOT NULL, expires_at timestamptz NOT NULL, phase text, evidence jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(session_id,privacy_epoch,type));
|
||||
|
||||
CREATE TABLE memory_mutation (
|
||||
id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenant(id), subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
actor_id uuid REFERENCES user_account(id), action text NOT NULL CHECK(action IN ('ADD','CORRECT','SET_USAGE','DELETE')),
|
||||
source_session_id uuid REFERENCES call_session(id), utterance_id text, source_revision bigint, action_index integer CHECK(action_index BETWEEN 0 AND 15),
|
||||
request_digest text NOT NULL, revision bigint NOT NULL DEFAULT 1,
|
||||
state text NOT NULL CHECK(state IN ('RECEIVED','NEEDS_CONFIRMATION','APPLYING','PROPAGATING','COMPLETED','REJECTED','CANCELLED','EXPIRED','BLOCKED')),
|
||||
targets jsonb NOT NULL DEFAULT '[]', expected_versions jsonb NOT NULL DEFAULT '{}', patch jsonb NOT NULL DEFAULT '{}',
|
||||
source_refs jsonb NOT NULL DEFAULT '[]',
|
||||
scope text NOT NULL DEFAULT 'MEMORY_AND_DERIVED' CHECK(scope IN ('MEMORY_AND_DERIVED','SOURCE_AND_DERIVED')),
|
||||
proposal_digest text, confirmation_hash text, confirmation_expires_at timestamptz, confirmation_used_at timestamptz,
|
||||
memory_revision bigint, privacy_epoch bigint,
|
||||
stage_states jsonb NOT NULL DEFAULT '{"main_store":"PENDING","serving_projection":"PENDING","derived_context":"PENDING","active_context":"PENDING","source_cleanup":"PENDING","backup_cleanup":"PENDING"}',
|
||||
required_stages jsonb NOT NULL DEFAULT '["main_store","serving_projection","derived_context","active_context","source_cleanup","backup_cleanup"]',
|
||||
feedback_state text NOT NULL DEFAULT 'PAGE_ONLY', error_code text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(source_session_id,utterance_id,source_revision,action_index));
|
||||
CREATE INDEX memory_mutation_subject ON memory_mutation(subject_id,created_at DESC);
|
||||
CREATE TABLE memory_access_grant (
|
||||
subject_id uuid NOT NULL REFERENCES subject_profile(id), user_id uuid NOT NULL REFERENCES user_account(id),
|
||||
can_read boolean NOT NULL DEFAULT false, can_write boolean NOT NULL DEFAULT false,
|
||||
sensitive_read boolean NOT NULL DEFAULT false, version bigint NOT NULL DEFAULT 1, PRIMARY KEY(subject_id,user_id), CHECK(NOT can_write OR can_read));
|
||||
-- Existing household owners retain memory access; device-manage alone grants nothing.
|
||||
INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read)
|
||||
SELECT sp.id,hm.user_id,true,true,true FROM subject_profile sp JOIN household_member hm ON hm.household_id=sp.household_id
|
||||
WHERE hm.status='active' AND hm.role='owner';
|
||||
|
||||
CREATE TABLE memory_projection_job (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid NOT NULL, subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
memory_id uuid NOT NULL REFERENCES curated_memory(id), record_version bigint NOT NULL, memory_revision bigint NOT NULL,
|
||||
critical boolean NOT NULL, state text NOT NULL DEFAULT 'PENDING'
|
||||
CHECK(state IN ('PENDING','WRITING','WRITE_ACKED','VISIBLE','RECONCILING','BLOCKED')),
|
||||
provider_kind text, provider_id text, last_error text, attempts integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(subject_id,memory_revision));
|
||||
CREATE INDEX memory_projection_due ON memory_projection_job(next_attempt_at) WHERE state<>'VISIBLE';
|
||||
CREATE TABLE memory_projection (
|
||||
memory_id uuid PRIMARY KEY REFERENCES curated_memory(id), subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
provider_kind text NOT NULL, provider_id text NOT NULL, record_version bigint NOT NULL, visible_version bigint NOT NULL DEFAULT 0,
|
||||
operation_key text NOT NULL UNIQUE, updated_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE memory_source_suppression (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
memory_id uuid NOT NULL REFERENCES curated_memory(id), source_refs jsonb NOT NULL, privacy_epoch bigint NOT NULL,
|
||||
scope text NOT NULL DEFAULT 'MEMORY_AND_DERIVED', created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(memory_id,privacy_epoch));
|
||||
CREATE TABLE memory_deletion_ledger (
|
||||
memory_id uuid NOT NULL REFERENCES curated_memory(id), privacy_epoch bigint NOT NULL, subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
scope text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), backup_cleanup_verified_at timestamptz,
|
||||
evidence_ref text, PRIMARY KEY(memory_id,privacy_epoch));
|
||||
CREATE TABLE call_memory_operator_evidence (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
operation text NOT NULL, call_session_id uuid REFERENCES call_session(id), evidence_ref text NOT NULL, recorded_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE FUNCTION call_legacy_cleanup_pending(sid uuid) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
SELECT EXISTS(SELECT 1 FROM conversation c WHERE c.subject_id=sid AND c.runtime_type='volc_hardware_agent' AND c.stop_reason='memory_changed'
|
||||
AND NOT EXISTS(SELECT 1 FROM call_memory_operator_evidence e WHERE e.subject_id=sid AND e.operation='attest-legacy-cleanup' AND e.recorded_at>=c.updated_at))
|
||||
$$;
|
||||
CREATE FUNCTION call_memory_authorized(sid uuid) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
SELECT EXISTS(SELECT 1 FROM subject_profile sp JOIN household h ON h.id=sp.household_id WHERE sp.id=sid AND sp.status='active' AND h.status='active')
|
||||
AND EXISTS(SELECT 1 FROM consent_record WHERE subject_id=sid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))
|
||||
AND NOT EXISTS(SELECT 1 FROM privacy_request WHERE subject_id=sid AND request_type='DELETE' AND status NOT IN ('FAILED','REJECTED','CANCELLED'))
|
||||
$$;
|
||||
ALTER TABLE privacy_subtask DROP CONSTRAINT privacy_subtask_subsystem_check;
|
||||
ALTER TABLE privacy_subtask ADD CONSTRAINT privacy_subtask_subsystem_check CHECK(subsystem IN ('postgresql','redis','object_storage','pgvector','provider_index','export_files','call_memory'));
|
||||
CREATE TABLE call_postprocess_job (
|
||||
session_id uuid NOT NULL REFERENCES call_session(id), transcript_revision bigint NOT NULL, extractor_version text NOT NULL,
|
||||
state text NOT NULL DEFAULT 'PENDING', last_error text, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(session_id,transcript_revision,extractor_version));
|
||||
|
||||
-- A delayed legacy upload must not restore a source explicitly erased by scope.
|
||||
CREATE FUNCTION retain_erased_story_source() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF NEW.final_text IS DISTINCT FROM OLD.final_text AND EXISTS(
|
||||
SELECT 1 FROM memory_source_suppression s CROSS JOIN LATERAL jsonb_array_elements(s.source_refs) r
|
||||
JOIN story_draft sd ON sd.public_id::text=r->>'story_id'
|
||||
JOIN story_revision sr ON sr.story_id=sd.id JOIN story_source ss ON ss.story_revision_id=sr.id
|
||||
WHERE s.subject_id=NEW.subject_id AND s.scope='SOURCE_AND_DERIVED' AND ss.conversation_turn_id=NEW.id
|
||||
) THEN NEW.final_text='[deleted]'; NEW.provider_ref=NULL; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER retain_erased_story_source BEFORE UPDATE ON conversation_turn FOR EACH ROW EXECUTE FUNCTION retain_erased_story_source();
|
||||
|
||||
-- Database boundary includes edits made by existing APIs and background workers.
|
||||
CREATE FUNCTION call_context_purge(sid uuid) RETURNS void LANGUAGE plpgsql AS $$ BEGIN
|
||||
UPDATE call_request_result SET response='{"revoked":true}'::jsonb WHERE response->>'manifest_b64' IN(SELECT manifest_b64 FROM call_session WHERE subject_id=sid AND manifest_status<>'ISSUED');
|
||||
UPDATE call_session SET manifest_b64=NULL WHERE subject_id=sid AND manifest_status<>'ISSUED';
|
||||
UPDATE context_snapshot SET sanitized_summary=NULL,memory_versions='[]'::jsonb WHERE subject_id=sid;
|
||||
UPDATE idempotency_record SET response_status=409,response_body='{"error":{"code":"MEMORY_CONTEXT_REVOKED","message":"记忆上下文已撤销,请开始新的通话"}}'::jsonb,updated_at=now() WHERE state='completed' AND resource_id IN(
|
||||
SELECT id FROM conversation WHERE subject_id=sid UNION SELECT public_id FROM conversation WHERE subject_id=sid UNION SELECT id FROM curated_memory WHERE subject_id=sid UNION SELECT public_id FROM curated_memory WHERE subject_id=sid);
|
||||
END $$;
|
||||
CREATE FUNCTION memory_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE rev bigint; epoch bigint; critical boolean; refs jsonb;
|
||||
BEGIN
|
||||
IF NEW.current_version_id IS NULL THEN RETURN NEW; END IF;
|
||||
IF TG_OP='UPDATE' AND OLD.status='PENDING_DELETE' AND NEW.status='DELETED' AND OLD.current_version_id=NEW.current_version_id AND OLD.version=NEW.version THEN RETURN NEW; END IF;
|
||||
IF TG_OP='UPDATE' AND OLD.current_version_id=NEW.current_version_id AND OLD.status=NEW.status
|
||||
AND OLD.proactive_policy=NEW.proactive_policy AND OLD.item_status IS NOT DISTINCT FROM NEW.item_status
|
||||
AND OLD.planned_at IS NOT DISTINCT FROM NEW.planned_at AND OLD.sensitivity=NEW.sensitivity THEN RETURN NEW; END IF;
|
||||
critical := TG_OP='UPDATE' AND OLD.current_version_id IS NOT NULL;
|
||||
INSERT INTO memory_user_state(subject_id,tenant_id) VALUES(NEW.subject_id,NEW.tenant_id) ON CONFLICT DO NOTHING;
|
||||
UPDATE memory_user_state SET memory_revision=memory_revision+1,privacy_epoch=privacy_epoch+CASE WHEN critical THEN 1 ELSE 0 END,updated_at=now()
|
||||
WHERE subject_id=NEW.subject_id RETURNING memory_revision,privacy_epoch INTO rev,epoch;
|
||||
INSERT INTO memory_projection_job(tenant_id,subject_id,memory_id,record_version,memory_revision,critical)
|
||||
VALUES(NEW.tenant_id,NEW.subject_id,NEW.id,NEW.version,rev,critical);
|
||||
IF critical THEN
|
||||
UPDATE conversation_summary SET status='SUPERSEDED' WHERE subject_id=NEW.subject_id AND status='READY';
|
||||
UPDATE call_session SET manifest_status='REVOKED',call_state='STOPPING',lease_expires_at=LEAST(lease_expires_at,now())
|
||||
WHERE subject_id=NEW.subject_id AND manifest_status='ISSUED';
|
||||
INSERT INTO call_command(session_id,privacy_epoch,type,reason,expires_at)
|
||||
SELECT id,epoch,'STOP_CALL','MEMORY_CHANGED',now()+interval '7 days' FROM call_session
|
||||
WHERE subject_id=NEW.subject_id AND manifest_status='REVOKED' AND NOT(local_audio_off AND sdk_stopped AND remote_state IN ('NOT_STARTED','STOPPED_OBSERVED'))
|
||||
ON CONFLICT DO NOTHING;
|
||||
UPDATE conversation SET status='STOPPING',stop_reason='memory_changed',lease_expires_at=NULL,version=version+1,updated_at=now()
|
||||
WHERE subject_id=NEW.subject_id AND runtime_type='volc_hardware_agent' AND status IN ('ROOM_ISSUED','ACTIVE');
|
||||
UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id IN
|
||||
(SELECT id FROM conversation WHERE subject_id=NEW.subject_id AND stop_reason='memory_changed' AND status='STOPPING');
|
||||
PERFORM call_context_purge(NEW.subject_id);
|
||||
SELECT source_refs INTO refs FROM memory_version WHERE id=OLD.current_version_id;
|
||||
INSERT INTO memory_source_suppression(subject_id,memory_id,source_refs,privacy_epoch)
|
||||
VALUES(NEW.subject_id,NEW.id,COALESCE(refs,'[]'),epoch) ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
IF NEW.status IN ('PENDING_DELETE','DELETED') THEN
|
||||
INSERT INTO memory_deletion_ledger(memory_id,privacy_epoch,subject_id,scope) VALUES(NEW.id,epoch,NEW.subject_id,'MEMORY_AND_DERIVED') ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER memory_changed AFTER INSERT OR UPDATE ON curated_memory FOR EACH ROW EXECUTE FUNCTION memory_changed();
|
||||
|
||||
-- Backfill projection work without rewriting or publishing historical transcripts.
|
||||
WITH numbered AS (
|
||||
SELECT id,tenant_id,subject_id,version,row_number() OVER(PARTITION BY subject_id ORDER BY created_at,id) AS revision
|
||||
FROM curated_memory WHERE current_version_id IS NOT NULL)
|
||||
INSERT INTO memory_projection_job(tenant_id,subject_id,memory_id,record_version,memory_revision,critical)
|
||||
SELECT tenant_id,subject_id,id,version,revision,true FROM numbered;
|
||||
UPDATE memory_user_state us SET memory_revision=(SELECT COALESCE(max(memory_revision),0) FROM memory_projection_job j WHERE j.subject_id=us.subject_id);
|
||||
|
||||
-- Current authorization changes fence already-issued manifests in the same commit.
|
||||
CREATE FUNCTION call_privacy_fence(sid uuid, why text) RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE epoch bigint;
|
||||
BEGIN
|
||||
INSERT INTO memory_user_state(subject_id,tenant_id) SELECT id,tenant_id FROM subject_profile WHERE id=sid ON CONFLICT DO NOTHING;
|
||||
UPDATE memory_user_state SET privacy_epoch=privacy_epoch+1,updated_at=now() WHERE subject_id=sid RETURNING privacy_epoch INTO epoch;
|
||||
UPDATE call_session SET manifest_status='REVOKED',call_state='STOPPING',lease_expires_at=LEAST(lease_expires_at,now()) WHERE subject_id=sid AND manifest_status='ISSUED';
|
||||
INSERT INTO call_command(session_id,privacy_epoch,type,reason,expires_at) SELECT id,epoch,'STOP_CALL',why,now()+interval '7 days' FROM call_session WHERE subject_id=sid AND manifest_status='REVOKED' AND NOT(local_audio_off AND sdk_stopped AND remote_state IN ('NOT_STARTED','STOPPED_OBSERVED')) ON CONFLICT DO NOTHING;
|
||||
UPDATE conversation_summary SET status='SUPERSEDED' WHERE subject_id=sid AND status='READY';
|
||||
UPDATE conversation SET status='STOPPING',stop_reason='memory_changed',lease_expires_at=NULL,version=version+1,updated_at=now() WHERE subject_id=sid AND runtime_type='volc_hardware_agent' AND status IN ('ROOM_ISSUED','ACTIVE');
|
||||
UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id IN(SELECT id FROM conversation WHERE subject_id=sid AND stop_reason='memory_changed' AND status='STOPPING');
|
||||
PERFORM call_context_purge(sid);
|
||||
END $$;
|
||||
CREATE FUNCTION call_authorization_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='consent_record' THEN
|
||||
IF NEW.consent_type IN ('personalized_memory','transcript_storage') AND (NEW.withdrawn_at IS NOT NULL OR NEW.expires_at<=now()) AND (OLD.withdrawn_at IS DISTINCT FROM NEW.withdrawn_at OR OLD.expires_at IS DISTINCT FROM NEW.expires_at) THEN
|
||||
PERFORM call_privacy_fence(NEW.subject_id,'CONSENT_REVOKED');
|
||||
UPDATE curated_memory SET status='DISABLED',version=version+1,updated_at=now() WHERE subject_id=NEW.subject_id AND status='ACTIVE';
|
||||
END IF;
|
||||
ELSIF TG_TABLE_NAME='subject_profile' THEN
|
||||
IF OLD.status IS DISTINCT FROM NEW.status AND NEW.status<>'active' THEN PERFORM call_privacy_fence(NEW.id,'SUBJECT_UNAVAILABLE'); END IF;
|
||||
ELSIF TG_TABLE_NAME='device_binding' THEN
|
||||
IF OLD.unbound_at IS NULL AND NEW.unbound_at IS NOT NULL THEN PERFORM call_privacy_fence(OLD.subject_id,'BINDING_REVOKED'); END IF;
|
||||
ELSIF TG_TABLE_NAME='privacy_request' THEN
|
||||
IF NEW.request_type='DELETE' THEN
|
||||
PERFORM call_privacy_fence(NEW.subject_id,'PRIVACY_DELETE');
|
||||
IF EXISTS(SELECT 1 FROM call_session WHERE subject_id=NEW.subject_id) OR EXISTS(SELECT 1 FROM memory_projection_job WHERE subject_id=NEW.subject_id) THEN
|
||||
INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref) VALUES(NEW.id,'call_memory','subject',NEW.subject_id::text) ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER call_consent_changed AFTER UPDATE ON consent_record FOR EACH ROW EXECUTE FUNCTION call_authorization_changed();
|
||||
CREATE TRIGGER call_subject_changed AFTER UPDATE ON subject_profile FOR EACH ROW EXECUTE FUNCTION call_authorization_changed();
|
||||
CREATE TRIGGER call_binding_changed AFTER UPDATE ON device_binding FOR EACH ROW EXECUTE FUNCTION call_authorization_changed();
|
||||
CREATE TRIGGER call_privacy_requested AFTER INSERT ON privacy_request FOR EACH ROW EXECUTE FUNCTION call_authorization_changed();
|
||||
-- Publishing freezes the precise evidence and mapping used to sign a manifest.
|
||||
CREATE FUNCTION immutable_call_profile() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF OLD.published AND (to_jsonb(OLD)-'published') IS DISTINCT FROM (to_jsonb(NEW)-'published') THEN RAISE EXCEPTION 'published call profile is immutable; create a new profile id'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER immutable_call_profile BEFORE UPDATE ON call_provider_profile FOR EACH ROW EXECUTE FUNCTION immutable_call_profile();
|
||||
COMMIT;
|
||||
@@ -0,0 +1,4 @@
|
||||
BEGIN;
|
||||
DROP TABLE customer_sms_challenge;
|
||||
DROP TABLE customer_login_session;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,25 @@
|
||||
BEGIN;
|
||||
CREATE TABLE customer_login_session (
|
||||
token_hash char(64) PRIMARY KEY,
|
||||
csrf_hash char(64) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz
|
||||
);
|
||||
CREATE INDEX customer_login_session_expiry ON customer_login_session(expires_at);
|
||||
CREATE TABLE customer_sms_challenge (
|
||||
id uuid PRIMARY KEY,
|
||||
session_hash char(64) NOT NULL REFERENCES customer_login_session(token_hash) ON DELETE CASCADE,
|
||||
phone_digest char(64) NOT NULL,
|
||||
phone_ciphertext bytea NOT NULL,
|
||||
code_hash char(64) NOT NULL,
|
||||
request_id uuid NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'SENDING' CHECK(status IN ('SENDING','SENT','FAILED','CONSUMED')),
|
||||
attempts integer NOT NULL DEFAULT 0 CHECK(attempts BETWEEN 0 AND 5),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz,
|
||||
UNIQUE(session_hash,request_id)
|
||||
);
|
||||
CREATE INDEX customer_sms_phone_recent ON customer_sms_challenge(phone_digest,created_at);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
ALTER TABLE onboarding_account_session DROP COLUMN phone_verified_at;
|
||||
ALTER TABLE onboarding_account_session DROP COLUMN phone_digest;
|
||||
DROP TABLE customer_phone_credential;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
BEGIN;
|
||||
CREATE TABLE customer_phone_credential (
|
||||
phone_digest char(64) PRIMARY KEY,
|
||||
phone_masked text NOT NULL,
|
||||
issuer text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
password_hash text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(issuer,subject)
|
||||
);
|
||||
ALTER TABLE onboarding_account_session ADD COLUMN phone_digest char(64) REFERENCES customer_phone_credential(phone_digest);
|
||||
ALTER TABLE onboarding_account_session ADD COLUMN phone_verified_at timestamptz;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
DROP TABLE operator_account_session;
|
||||
DROP TABLE operator_sms_challenge;
|
||||
DROP TABLE operator_phone_credential;
|
||||
DROP TABLE operator_login_session;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,9 @@
|
||||
BEGIN;
|
||||
CREATE TABLE operator_login_session (LIKE customer_login_session INCLUDING ALL);
|
||||
CREATE TABLE operator_phone_credential (LIKE customer_phone_credential INCLUDING ALL);
|
||||
CREATE TABLE operator_sms_challenge (LIKE customer_sms_challenge INCLUDING ALL);
|
||||
ALTER TABLE operator_sms_challenge ADD FOREIGN KEY (session_hash) REFERENCES operator_login_session(token_hash) ON DELETE CASCADE;
|
||||
CREATE TABLE operator_account_session (LIKE onboarding_account_session INCLUDING ALL);
|
||||
ALTER TABLE operator_account_session ADD FOREIGN KEY (user_id) REFERENCES user_account(id);
|
||||
ALTER TABLE operator_account_session ADD FOREIGN KEY (phone_digest) REFERENCES operator_phone_credential(phone_digest);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,3 @@
|
||||
BEGIN;
|
||||
DROP TABLE device_ble_binding;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,22 @@
|
||||
BEGIN;
|
||||
CREATE TABLE device_ble_binding (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token_hash char(64) NOT NULL UNIQUE,
|
||||
device_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES user_account(id),
|
||||
household_id uuid NOT NULL,
|
||||
subject_id uuid NOT NULL,
|
||||
expected_generation bigint NOT NULL,
|
||||
status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','COMMITTED','CANCELLED')),
|
||||
expires_at timestamptz NOT NULL,
|
||||
binding_id uuid REFERENCES device_binding(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz,
|
||||
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id),
|
||||
FOREIGN KEY(household_id,tenant_id) REFERENCES household(id,tenant_id),
|
||||
FOREIGN KEY(subject_id,tenant_id) REFERENCES subject_profile(id,tenant_id)
|
||||
);
|
||||
CREATE INDEX device_ble_binding_device ON device_ble_binding(device_id,created_at DESC);
|
||||
CREATE UNIQUE INDEX device_ble_binding_one_pending_actor ON device_ble_binding(device_id,user_id) WHERE status='PENDING';
|
||||
COMMIT;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE subject_voice_agent;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE subject_voice_agent (
|
||||
subject_id uuid PRIMARY KEY REFERENCES subject_profile(id),
|
||||
bot_name text NOT NULL UNIQUE,
|
||||
bot_id text UNIQUE,
|
||||
status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','READY','FAILED')),
|
||||
config jsonb NOT NULL DEFAULT '{}',
|
||||
agent_config jsonb NOT NULL DEFAULT '{}',
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE subject_voice_agent IS 'One Volcano S2S bot per elder. Provider configuration stays on the server.';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE customer_wechat_identity;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE customer_wechat_identity (
|
||||
app_id text NOT NULL,
|
||||
open_id text NOT NULL,
|
||||
phone_digest char(64) NOT NULL REFERENCES customer_phone_credential(phone_digest),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(app_id,open_id),
|
||||
UNIQUE(app_id,phone_digest)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS fleet_ota;
|
||||
DROP TABLE IF EXISTS fleet_presence;
|
||||
DROP TABLE IF EXISTS fleet_firmware;
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE fleet_firmware (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid NOT NULL REFERENCES tenant(id),
|
||||
version_code bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
|
||||
model text NOT NULL, version text NOT NULL, release_notes text NOT NULL DEFAULT '',
|
||||
size bigint NOT NULL CHECK(size > 0 AND size <= 33554432), sha256 text NOT NULL,
|
||||
object_ref text NOT NULL UNIQUE, status text NOT NULL DEFAULT 'UPLOADING' CHECK(status IN ('UPLOADING','READY')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(tenant_id,model,version)
|
||||
);
|
||||
CREATE INDEX fleet_firmware_latest ON fleet_firmware(tenant_id,model,version_code DESC) WHERE status='READY';
|
||||
CREATE TABLE fleet_presence (
|
||||
device_id uuid PRIMARY KEY REFERENCES device(id), password_hash bytea NOT NULL,
|
||||
connection_id uuid, connected boolean NOT NULL DEFAULT false, last_seen_at timestamptz,
|
||||
firmware_version text NOT NULL DEFAULT '', version_code bigint NOT NULL DEFAULT 0 CHECK(version_code>=0),
|
||||
ota_supported boolean NOT NULL DEFAULT false
|
||||
);
|
||||
CREATE TABLE fleet_ota (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), device_id uuid NOT NULL REFERENCES device(id),
|
||||
release_id uuid NOT NULL REFERENCES fleet_firmware(id), request_id uuid NOT NULL UNIQUE,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','success','failed','timeout')),
|
||||
message text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX fleet_ota_active ON fleet_ota(device_id) WHERE status IN ('pending','accepted');
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE managed_memory_origin;
|
||||
DROP TABLE call_memory_sync;
|
||||
DROP FUNCTION managed_call_memory_authorized(uuid);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Durable transcript upload and a local, editable mirror of provider memories.
|
||||
CREATE FUNCTION managed_call_memory_authorized(sid uuid) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
SELECT call_memory_authorized(sid) AND EXISTS(SELECT 1 FROM consent_record WHERE subject_id=sid AND consent_type='transcript_storage' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))
|
||||
$$;
|
||||
CREATE TABLE call_memory_sync (
|
||||
session_id uuid NOT NULL REFERENCES call_session(id),
|
||||
transcript_revision bigint NOT NULL,
|
||||
state text NOT NULL DEFAULT 'PENDING' CHECK(state IN ('PENDING','UPLOADED','SYNCED','SKIPPED')),
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
uploaded_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(session_id,transcript_revision)
|
||||
);
|
||||
CREATE INDEX call_memory_sync_due ON call_memory_sync(next_attempt_at);
|
||||
CREATE TABLE managed_memory_origin (
|
||||
memory_id uuid PRIMARY KEY REFERENCES curated_memory(id),
|
||||
subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
session_id uuid NOT NULL REFERENCES call_session(id),
|
||||
provider_id text NOT NULL,
|
||||
provider_digest text NOT NULL,
|
||||
imported_version bigint NOT NULL DEFAULT 1,
|
||||
UNIQUE(subject_id,provider_id)
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
BEGIN;
|
||||
-- Do not silently discard ownership when rolling back.
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM device_account_binding) THEN
|
||||
RAISE EXCEPTION 'Account device bindings exist; restore the matching backup instead of dropping ownership';
|
||||
END IF;
|
||||
END $$;
|
||||
DROP TRIGGER device_account_binding_reset ON device;
|
||||
DROP FUNCTION reset_device_account_binding();
|
||||
DROP TRIGGER device_account_assignment_finish ON device_binding;
|
||||
DROP FUNCTION finish_device_account_assignment();
|
||||
DROP TRIGGER device_account_assignment_guard ON device_binding;
|
||||
DROP FUNCTION guard_device_account_assignment();
|
||||
DELETE FROM device_ble_binding WHERE subject_id IS NULL;
|
||||
ALTER TABLE device_ble_binding DROP CONSTRAINT device_ble_binding_profile_pair, DROP COLUMN account_binding_id;
|
||||
ALTER TABLE device_ble_binding ALTER COLUMN household_id SET NOT NULL, ALTER COLUMN subject_id SET NOT NULL;
|
||||
DROP TABLE device_account_binding;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,47 @@
|
||||
BEGIN;
|
||||
-- Physical possession can establish account ownership before a family exists.
|
||||
CREATE TABLE device_account_binding (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
generation bigint NOT NULL,
|
||||
bound_at timestamptz NOT NULL DEFAULT now(),
|
||||
released_at timestamptz,
|
||||
assigned_binding_id uuid REFERENCES device_binding(id),
|
||||
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id),
|
||||
FOREIGN KEY(user_id,tenant_id) REFERENCES user_account(id,tenant_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX device_account_binding_active ON device_account_binding(device_id) WHERE released_at IS NULL;
|
||||
CREATE INDEX device_account_binding_user ON device_account_binding(user_id) WHERE released_at IS NULL;
|
||||
ALTER TABLE device_ble_binding ALTER COLUMN household_id DROP NOT NULL, ALTER COLUMN subject_id DROP NOT NULL;
|
||||
ALTER TABLE device_ble_binding ADD COLUMN account_binding_id uuid REFERENCES device_account_binding(id);
|
||||
ALTER TABLE device_ble_binding ADD CONSTRAINT device_ble_binding_profile_pair CHECK ((household_id IS NULL) = (subject_id IS NULL));
|
||||
|
||||
-- Existing physical-confirm claims cannot steal an account-owned device.
|
||||
CREATE FUNCTION guard_device_account_assignment() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE owner device_account_binding%ROWTYPE;
|
||||
BEGIN
|
||||
PERFORM 1 FROM device WHERE id=NEW.device_id FOR UPDATE;
|
||||
SELECT * INTO owner FROM device_account_binding WHERE device_id=NEW.device_id AND released_at IS NULL;
|
||||
IF FOUND AND (owner.user_id IS DISTINCT FROM NEW.bound_by OR owner.id::text IS DISTINCT FROM current_setting('app.device_account_binding',true)) THEN
|
||||
RAISE EXCEPTION 'DEVICE_CONFIRMATION_REQUIRED' USING ERRCODE='P0001';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER device_account_assignment_guard BEFORE INSERT ON device_binding FOR EACH ROW EXECUTE FUNCTION guard_device_account_assignment();
|
||||
CREATE FUNCTION finish_device_account_assignment() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
UPDATE device_account_binding SET released_at=now(),assigned_binding_id=NEW.id WHERE device_id=NEW.device_id AND released_at IS NULL;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER device_account_assignment_finish AFTER INSERT ON device_binding FOR EACH ROW EXECUTE FUNCTION finish_device_account_assignment();
|
||||
CREATE FUNCTION reset_device_account_binding() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.factory_reset_counter IS DISTINCT FROM OLD.factory_reset_counter THEN
|
||||
UPDATE device_account_binding SET released_at=now() WHERE device_id=NEW.id AND released_at IS NULL;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER device_account_binding_reset AFTER UPDATE OF factory_reset_counter ON device FOR EACH ROW EXECUTE FUNCTION reset_device_account_binding();
|
||||
COMMIT;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS fleet_firmware_download_ticket;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE fleet_firmware_download_ticket (
|
||||
token_hash bytea PRIMARY KEY CHECK(octet_length(token_hash)=32),
|
||||
release_id uuid NOT NULL REFERENCES fleet_firmware(id) ON DELETE CASCADE,
|
||||
ota_id uuid REFERENCES fleet_ota(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX fleet_firmware_download_ticket_ota ON fleet_firmware_download_ticket(ota_id) WHERE ota_id IS NOT NULL;
|
||||
CREATE INDEX fleet_firmware_download_ticket_expiry ON fleet_firmware_download_ticket(expires_at) WHERE revoked_at IS NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
ALTER TABLE operator_sms_challenge DROP COLUMN purpose;
|
||||
ALTER TABLE customer_sms_challenge DROP COLUMN purpose;
|
||||
ALTER TABLE onboarding_sms_challenge DROP COLUMN purpose;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
ALTER TABLE onboarding_sms_challenge ADD COLUMN purpose varchar(32) NOT NULL DEFAULT 'LOGIN' CHECK (purpose IN ('LOGIN','CHANGE_PASSWORD'));
|
||||
ALTER TABLE customer_sms_challenge ADD COLUMN purpose varchar(32) NOT NULL DEFAULT 'LOGIN' CHECK (purpose IN ('LOGIN','CHANGE_PASSWORD'));
|
||||
ALTER TABLE operator_sms_challenge ADD COLUMN purpose varchar(32) NOT NULL DEFAULT 'LOGIN' CHECK (purpose IN ('LOGIN','CHANGE_PASSWORD'));
|
||||
COMMIT;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE login_captcha_policy;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE login_captcha_policy (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
saas_mode text NOT NULL DEFAULT 'image' CHECK (saas_mode IN ('image','tencent')),
|
||||
mini_mode text NOT NULL DEFAULT 'image' CHECK (mini_mode IN ('image','tencent')),
|
||||
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO login_captcha_policy(singleton) VALUES(true);
|
||||
@@ -0,0 +1,3 @@
|
||||
BEGIN;
|
||||
DROP TABLE trusted_login_device;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,13 @@
|
||||
BEGIN;
|
||||
CREATE TABLE trusted_login_device (
|
||||
token_hash char(64) PRIMARY KEY,
|
||||
realm text NOT NULL CHECK (realm IN ('customer','operator')),
|
||||
phone_digest char(64) NOT NULL,
|
||||
credential_digest char(64) NOT NULL,
|
||||
verified_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz
|
||||
);
|
||||
CREATE INDEX trusted_login_device_phone_idx ON trusted_login_device(realm,phone_digest);
|
||||
CREATE INDEX trusted_login_device_expiry_idx ON trusted_login_device(expires_at);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
BEGIN;
|
||||
DROP TRIGGER IF EXISTS household_member_web_event ON household_member;
|
||||
DROP FUNCTION IF EXISTS household_member_web_event_trigger();
|
||||
DROP INDEX IF EXISTS web_event_household_recent_idx;
|
||||
ALTER TABLE web_event ALTER COLUMN expires_at SET DEFAULT (now() + interval '15 minutes');
|
||||
DELETE FROM web_event WHERE event_type='household.member.updated';
|
||||
ALTER TABLE web_event DROP CONSTRAINT web_event_event_type_check;
|
||||
ALTER TABLE web_event ADD CONSTRAINT web_event_event_type_check CHECK (event_type IN (
|
||||
'device.presence.changed','device.binding.reset',
|
||||
'conversation.started','conversation.completed','conversation.failed',
|
||||
'story.created','story.revised','media.upload.completed','export.ready',
|
||||
'privacy_request.updated','device.command.updated'));
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
ALTER TABLE web_event DROP CONSTRAINT web_event_event_type_check;
|
||||
ALTER TABLE web_event ADD CONSTRAINT web_event_event_type_check CHECK (event_type IN (
|
||||
'device.presence.changed','device.binding.reset',
|
||||
'conversation.started','conversation.completed','conversation.failed',
|
||||
'story.created','story.revised','media.upload.completed','export.ready',
|
||||
'privacy_request.updated','device.command.updated','household.member.updated'));
|
||||
-- Retain metadata for the seven-day family activity view. No message content is stored here.
|
||||
ALTER TABLE web_event ALTER COLUMN expires_at SET DEFAULT (now() + interval '7 days');
|
||||
CREATE INDEX web_event_household_recent_idx ON web_event(household_id, occurred_at DESC);
|
||||
CREATE FUNCTION household_member_web_event_trigger() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP='INSERT' OR OLD.status IS DISTINCT FROM NEW.status OR OLD.role IS DISTINCT FROM NEW.role THEN
|
||||
PERFORM append_web_event(NEW.tenant_id,NEW.household_id,NULL,'household.member.updated','household',
|
||||
(SELECT public_id FROM household WHERE id=NEW.household_id),NEW.status);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER household_member_web_event AFTER INSERT OR UPDATE ON household_member
|
||||
FOR EACH ROW EXECUTE FUNCTION household_member_web_event_trigger();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Invalidate slide challenges before restoring the previous constraint.
|
||||
UPDATE login_captcha_policy SET saas_mode='image',version=version+1,updated_at=now()
|
||||
WHERE saas_mode='slide';
|
||||
ALTER TABLE login_captcha_policy DROP CONSTRAINT login_captcha_policy_saas_mode_check;
|
||||
ALTER TABLE login_captcha_policy ADD CONSTRAINT login_captcha_policy_saas_mode_check
|
||||
CHECK (saas_mode IN ('image','tencent'));
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Keep defaults and the unpublished mini-program's image mode unchanged.
|
||||
ALTER TABLE login_captcha_policy DROP CONSTRAINT login_captcha_policy_saas_mode_check;
|
||||
ALTER TABLE login_captcha_policy ADD CONSTRAINT login_captcha_policy_saas_mode_check
|
||||
CHECK (saas_mode IN ('image','slide','tencent'));
|
||||
@@ -0,0 +1,21 @@
|
||||
BEGIN;
|
||||
-- Rollback must never silently discard stories already reviewed by a family.
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM story_draft WHERE call_session_id IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'Direct-call stories exist; preserve them before rolling back migration 75';
|
||||
END IF;
|
||||
END $$;
|
||||
DROP TRIGGER call_story_consent_changed ON consent_record;
|
||||
DROP FUNCTION call_story_consent_changed();
|
||||
DROP FUNCTION call_story_available(uuid);
|
||||
DROP FUNCTION call_story_authorized(uuid);
|
||||
DROP TABLE call_story_extraction;
|
||||
ALTER TABLE story_source DROP CONSTRAINT story_call_utterance_fk, DROP CONSTRAINT story_source_origin_check, DROP CONSTRAINT story_call_source_key;
|
||||
DROP INDEX story_source_call_idx;
|
||||
ALTER TABLE story_source DROP COLUMN call_session_id, DROP COLUMN call_utterance_id, DROP COLUMN call_source_revision;
|
||||
ALTER TABLE story_source ALTER COLUMN conversation_turn_id SET NOT NULL;
|
||||
ALTER TABLE story_draft DROP CONSTRAINT story_call_scope_fk, DROP CONSTRAINT story_origin_check, DROP CONSTRAINT story_call_generation_key;
|
||||
ALTER TABLE story_draft DROP COLUMN call_session_id, DROP COLUMN call_privacy_epoch;
|
||||
ALTER TABLE story_draft ALTER COLUMN conversation_id SET NOT NULL;
|
||||
ALTER TABLE call_session DROP CONSTRAINT call_session_story_scope;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,81 @@
|
||||
BEGIN;
|
||||
|
||||
-- Keep device calls in their native tables; a story has exactly one origin.
|
||||
ALTER TABLE call_session ADD CONSTRAINT call_session_story_scope UNIQUE(id,tenant_id,subject_id);
|
||||
ALTER TABLE story_draft ALTER COLUMN conversation_id DROP NOT NULL;
|
||||
ALTER TABLE story_draft ADD COLUMN call_session_id uuid;
|
||||
ALTER TABLE story_draft ADD COLUMN call_privacy_epoch bigint;
|
||||
ALTER TABLE story_draft ADD CONSTRAINT story_call_scope_fk
|
||||
FOREIGN KEY(call_session_id,tenant_id,subject_id) REFERENCES call_session(id,tenant_id,subject_id);
|
||||
ALTER TABLE story_draft ADD CONSTRAINT story_origin_check CHECK (
|
||||
(conversation_id IS NOT NULL AND call_session_id IS NULL AND call_privacy_epoch IS NULL) OR
|
||||
(conversation_id IS NULL AND call_session_id IS NOT NULL AND call_privacy_epoch IS NOT NULL AND call_privacy_epoch>=0));
|
||||
ALTER TABLE story_draft ADD CONSTRAINT story_call_generation_key UNIQUE(call_session_id,generation_version);
|
||||
|
||||
ALTER TABLE story_source ALTER COLUMN conversation_turn_id DROP NOT NULL;
|
||||
ALTER TABLE story_source ADD COLUMN call_session_id uuid;
|
||||
ALTER TABLE story_source ADD COLUMN call_utterance_id text;
|
||||
ALTER TABLE story_source ADD COLUMN call_source_revision bigint;
|
||||
ALTER TABLE story_source ADD CONSTRAINT story_call_utterance_fk
|
||||
FOREIGN KEY(call_session_id,call_utterance_id) REFERENCES call_utterance(session_id,utterance_id);
|
||||
ALTER TABLE story_source ADD CONSTRAINT story_source_origin_check CHECK (
|
||||
(conversation_turn_id IS NOT NULL AND call_session_id IS NULL AND call_utterance_id IS NULL AND call_source_revision IS NULL) OR
|
||||
(conversation_turn_id IS NULL AND call_session_id IS NOT NULL AND call_utterance_id IS NOT NULL AND call_source_revision IS NOT NULL AND call_source_revision>=0 AND source_role='subject'));
|
||||
ALTER TABLE story_source ADD CONSTRAINT story_call_source_key
|
||||
UNIQUE(story_revision_id,call_session_id,call_utterance_id,source_start_offset,source_end_offset);
|
||||
CREATE INDEX story_source_call_idx ON story_source(call_session_id,call_utterance_id) WHERE call_session_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE call_story_extraction (
|
||||
session_id uuid NOT NULL REFERENCES call_session(id),
|
||||
transcript_revision bigint NOT NULL CHECK(transcript_revision>0),
|
||||
privacy_epoch bigint NOT NULL CHECK(privacy_epoch>=0),
|
||||
outcome text NOT NULL CHECK(outcome IN ('STORY','NO_STORY','EMPTY','REVIEWED')),
|
||||
model_ref text NOT NULL DEFAULT '', prompt_version text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(session_id,transcript_revision,privacy_epoch)
|
||||
);
|
||||
|
||||
-- Authorization is frozen at call start and must still be valid at processing
|
||||
-- and review time. A later grant must not authorize old calls retroactively.
|
||||
CREATE FUNCTION call_story_authorized(sid uuid) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
SELECT EXISTS(SELECT 1 FROM call_session c JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id
|
||||
JOIN memory_user_state us ON us.subject_id=c.subject_id
|
||||
WHERE c.id=sid AND sp.status='active' AND c.privacy_epoch=us.privacy_epoch
|
||||
AND c.budget_snapshot->>'protocol'='DIRECT-CHAT-1'
|
||||
AND c.budget_snapshot->>'story_extraction_allowed'='true'
|
||||
AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.subject_id=c.subject_id AND pr.request_type='DELETE')
|
||||
AND NOT EXISTS(SELECT 1 FROM (VALUES ('transcript_storage'),('story_extraction')) required(kind)
|
||||
WHERE NOT EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.tenant_id=c.tenant_id
|
||||
AND cr.consent_type=required.kind AND cr.granted_at<=c.created_at AND cr.withdrawn_at IS NULL
|
||||
AND (cr.expires_at IS NULL OR cr.expires_at>now()))))
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION call_story_available(story uuid) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
SELECT EXISTS(SELECT 1 FROM story_draft sd WHERE sd.id=story AND (
|
||||
sd.call_session_id IS NULL OR (
|
||||
call_story_authorized(sd.call_session_id)
|
||||
AND sd.call_privacy_epoch=(SELECT privacy_epoch FROM call_session WHERE id=sd.call_session_id)
|
||||
AND EXISTS(SELECT 1 FROM story_source ss WHERE ss.story_revision_id=sd.current_revision_id)
|
||||
AND NOT EXISTS(SELECT 1 FROM story_source ss LEFT JOIN call_utterance u
|
||||
ON u.session_id=ss.call_session_id AND u.utterance_id=ss.call_utterance_id
|
||||
WHERE ss.story_revision_id=sd.current_revision_id AND (
|
||||
ss.call_session_id IS DISTINCT FROM sd.call_session_id OR u.session_id IS NULL
|
||||
OR u.source_revision IS DISTINCT FROM ss.call_source_revision OR u.speaker<>'USER'
|
||||
OR u.text_content='[deleted]' OR length(trim(u.text_content))=0
|
||||
OR EXISTS(SELECT 1 FROM memory_source_suppression ms CROSS JOIN LATERAL jsonb_array_elements(ms.source_refs) r
|
||||
WHERE ms.subject_id=sd.subject_id AND (r->>'story_id'=sd.public_id::text OR
|
||||
(r->>'session_id'=ss.call_session_id::text AND (r->>'utterance_id' IS NULL OR r->>'utterance_id'=ss.call_utterance_id)))))))))
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION call_story_consent_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.consent_type='story_extraction' AND (NEW.withdrawn_at IS NOT NULL OR NEW.expires_at<=now())
|
||||
AND (OLD.withdrawn_at IS DISTINCT FROM NEW.withdrawn_at OR OLD.expires_at IS DISTINCT FROM NEW.expires_at) THEN
|
||||
PERFORM call_privacy_fence(NEW.subject_id,'STORY_CONSENT_REVOKED');
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER call_story_consent_changed AFTER UPDATE ON consent_record
|
||||
FOR EACH ROW EXECUTE FUNCTION call_story_consent_changed();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP TRIGGER elder_persona_config_changed ON subject_voice_agent;
|
||||
DROP TRIGGER elder_persona_consent_changed ON consent_record;
|
||||
DROP TRIGGER elder_persona_profile_changed ON subject_profile;
|
||||
DROP FUNCTION elder_persona_config_changed();
|
||||
DROP FUNCTION elder_persona_consent_changed();
|
||||
DROP FUNCTION elder_persona_profile_changed();
|
||||
DROP FUNCTION queue_elder_persona_sync(uuid);
|
||||
DROP INDEX elder_persona_retry_idx;
|
||||
ALTER TABLE subject_voice_agent DROP COLUMN persona_base_role,DROP COLUMN persona_revision,DROP COLUMN persona_synced_revision,DROP COLUMN persona_sync_state,DROP COLUMN persona_sync_attempts,DROP COLUMN persona_next_retry_at,DROP COLUMN persona_error_code,DROP COLUMN persona_synced_at,DROP COLUMN persona_digest,DROP COLUMN persona_memory_allowed,DROP COLUMN persona_voice_allowed,DROP COLUMN persona_session_dirty;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Durable, coalescing persona synchronization. No personal facts in job payloads.
|
||||
ALTER TABLE subject_voice_agent
|
||||
ADD COLUMN persona_base_role text NOT NULL DEFAULT '',
|
||||
ADD COLUMN persona_revision bigint NOT NULL DEFAULT 1 CHECK(persona_revision>0),
|
||||
ADD COLUMN persona_synced_revision bigint NOT NULL DEFAULT 0,
|
||||
ADD COLUMN persona_sync_state text NOT NULL DEFAULT 'PENDING' CHECK(persona_sync_state IN ('PENDING','SYNCED','DEFERRED','FAILED')),
|
||||
ADD COLUMN persona_sync_attempts integer NOT NULL DEFAULT 0,
|
||||
ADD COLUMN persona_next_retry_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN persona_error_code text NOT NULL DEFAULT '',
|
||||
ADD COLUMN persona_synced_at timestamptz,
|
||||
ADD COLUMN persona_digest text NOT NULL DEFAULT '',
|
||||
ADD COLUMN persona_memory_allowed boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN persona_voice_allowed boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN persona_session_dirty boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Remove only the old compiler's trailing name sentence, retaining custom personas.
|
||||
UPDATE subject_voice_agent SET persona_base_role=regexp_replace(COALESCE(config#>>'{S2SConfig,ProviderParams,dialog,system_role}',''),E'\n你正在陪伴的长者称呼是[^\n]*。$','');
|
||||
|
||||
CREATE FUNCTION queue_elder_persona_sync(sid uuid) RETURNS void LANGUAGE sql AS $$
|
||||
UPDATE subject_voice_agent SET persona_revision=persona_revision+1,persona_sync_state='PENDING',persona_sync_attempts=0,persona_next_retry_at=now(),persona_error_code='' WHERE subject_id=sid
|
||||
$$;
|
||||
|
||||
-- Use the same elder lock as chat preparation and the synchronizer. Take it before
|
||||
-- the change so a provider request cannot race a newly committed profile version.
|
||||
CREATE FUNCTION elder_persona_profile_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF ROW(OLD.preferred_name,OLD.language,OLD.profile_data->'background',OLD.profile_data->'interests',
|
||||
OLD.conversation_preferences->'conversation_goal',OLD.conversation_preferences->'pace',
|
||||
OLD.conversation_preferences->'preferred_topics',OLD.conversation_preferences->'avoided_topics',OLD.status)
|
||||
IS DISTINCT FROM
|
||||
ROW(NEW.preferred_name,NEW.language,NEW.profile_data->'background',NEW.profile_data->'interests',
|
||||
NEW.conversation_preferences->'conversation_goal',NEW.conversation_preferences->'pace',
|
||||
NEW.conversation_preferences->'preferred_topics',NEW.conversation_preferences->'avoided_topics',NEW.status) THEN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.id::text,64));
|
||||
PERFORM queue_elder_persona_sync(NEW.id);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER elder_persona_profile_changed BEFORE UPDATE ON subject_profile FOR EACH ROW EXECUTE FUNCTION elder_persona_profile_changed();
|
||||
|
||||
CREATE FUNCTION elder_persona_consent_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- Do not take the elder lock here: privacy fencing has a separate lock order.
|
||||
IF NEW.consent_type IN ('personalized_memory','voice_chat') THEN PERFORM queue_elder_persona_sync(NEW.subject_id); END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER elder_persona_consent_changed AFTER INSERT OR UPDATE ON consent_record FOR EACH ROW EXECUTE FUNCTION elder_persona_consent_changed();
|
||||
|
||||
CREATE FUNCTION elder_persona_config_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF ROW(OLD.config,OLD.agent_config,OLD.persona_base_role) IS DISTINCT FROM ROW(NEW.config,NEW.agent_config,NEW.persona_base_role) THEN
|
||||
IF OLD.config#>>'{S2SConfig,ProviderParams,dialog,system_role}' IS DISTINCT FROM NEW.config#>>'{S2SConfig,ProviderParams,dialog,system_role}' THEN
|
||||
NEW.persona_base_role=regexp_replace(COALESCE(NEW.config#>>'{S2SConfig,ProviderParams,dialog,system_role}',''),E'\n你正在陪伴的长者称呼是[^\n]*。$','');
|
||||
END IF;
|
||||
NEW.persona_revision=OLD.persona_revision+1;
|
||||
NEW.persona_sync_state='PENDING'; NEW.persona_sync_attempts=0; NEW.persona_next_retry_at=now(); NEW.persona_error_code='';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER elder_persona_config_changed BEFORE UPDATE ON subject_voice_agent FOR EACH ROW EXECUTE FUNCTION elder_persona_config_changed();
|
||||
CREATE INDEX elder_persona_retry_idx ON subject_voice_agent(persona_next_retry_at) WHERE status='READY';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- A rollback cannot safely restore the one-story-per-call uniqueness after
|
||||
-- real multi-event stories exist. Roll back the release using a DB snapshot.
|
||||
DO $$ BEGIN RAISE EXCEPTION 'Memoir migration requires a pre-migration snapshot to roll back'; END $$;
|
||||
@@ -0,0 +1,120 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE memoir_chat_import (
|
||||
id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenant(id),
|
||||
subject_id uuid NOT NULL REFERENCES subject_profile(id), created_by uuid NOT NULL REFERENCES user_account(id),
|
||||
title text NOT NULL, transcript_digest text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','READY','FAILED'))
|
||||
);
|
||||
CREATE TABLE memoir_import_turn (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), import_id uuid NOT NULL REFERENCES memoir_chat_import(id),
|
||||
sequence_no integer NOT NULL, speaker text NOT NULL CHECK(speaker IN ('subject','agent')),
|
||||
content text NOT NULL, UNIQUE(import_id,sequence_no)
|
||||
);
|
||||
|
||||
-- One call may contain several independent events. Keep the original story
|
||||
-- origin for navigation, and carry every contributing source on revisions.
|
||||
ALTER TABLE story_draft ADD COLUMN event_index integer NOT NULL DEFAULT 0 CHECK(event_index>=0);
|
||||
ALTER TABLE story_draft ADD COLUMN event_time text NOT NULL DEFAULT '';
|
||||
ALTER TABLE story_draft ADD COLUMN import_id uuid REFERENCES memoir_chat_import(id);
|
||||
ALTER TABLE story_draft DROP CONSTRAINT story_origin_check;
|
||||
ALTER TABLE story_draft ADD CONSTRAINT story_origin_check CHECK(
|
||||
num_nonnulls(conversation_id,call_session_id,import_id)=1 AND
|
||||
((call_session_id IS NOT NULL AND call_privacy_epoch IS NOT NULL) OR (call_session_id IS NULL AND call_privacy_epoch IS NULL)));
|
||||
ALTER TABLE story_source ADD COLUMN import_turn_id uuid REFERENCES memoir_import_turn(id);
|
||||
ALTER TABLE story_source DROP CONSTRAINT story_source_origin_check;
|
||||
ALTER TABLE story_source ADD CONSTRAINT story_source_origin_check CHECK(
|
||||
num_nonnulls(conversation_turn_id,call_session_id,import_turn_id)=1 AND
|
||||
((call_session_id IS NOT NULL AND call_utterance_id IS NOT NULL AND call_source_revision IS NOT NULL) OR
|
||||
(call_session_id IS NULL AND call_utterance_id IS NULL AND call_source_revision IS NULL)));
|
||||
ALTER TABLE story_source ADD CONSTRAINT story_import_source_key UNIQUE(story_revision_id,import_turn_id);
|
||||
ALTER TABLE story_draft DROP CONSTRAINT story_draft_generation_key;
|
||||
ALTER TABLE story_draft DROP CONSTRAINT story_call_generation_key;
|
||||
ALTER TABLE story_draft ADD CONSTRAINT story_draft_generation_key UNIQUE(conversation_id,generation_version,event_index);
|
||||
ALTER TABLE story_draft ADD CONSTRAINT story_call_generation_key UNIQUE(call_session_id,generation_version,event_index);
|
||||
ALTER TABLE story_draft ADD CONSTRAINT story_import_generation_key UNIQUE(import_id,generation_version,event_index);
|
||||
|
||||
CREATE TABLE chat_daily_event (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenant(id),
|
||||
subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
conversation_id uuid REFERENCES conversation(id),
|
||||
call_session_id uuid REFERENCES call_session(id),
|
||||
import_id uuid REFERENCES memoir_chat_import(id),
|
||||
generation_version bigint NOT NULL,
|
||||
event_index integer NOT NULL,
|
||||
event_date date NOT NULL,
|
||||
title text NOT NULL,
|
||||
content text NOT NULL,
|
||||
source_refs jsonb NOT NULL,
|
||||
model_ref text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK(num_nonnulls(conversation_id,call_session_id,import_id)=1),
|
||||
UNIQUE(conversation_id,generation_version,event_index),
|
||||
UNIQUE(call_session_id,generation_version,event_index),
|
||||
UNIQUE(import_id,generation_version,event_index)
|
||||
);
|
||||
CREATE TABLE story_generation_event (
|
||||
origin_id uuid NOT NULL, generation_version bigint NOT NULL,
|
||||
event_index integer NOT NULL, story_id uuid NOT NULL REFERENCES story_draft(id),
|
||||
PRIMARY KEY(origin_id,generation_version,event_index)
|
||||
);
|
||||
CREATE TABLE memoir_document (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenant(id),
|
||||
subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
version bigint NOT NULL DEFAULT 1,
|
||||
source_digest text NOT NULL,
|
||||
document jsonb NOT NULL,
|
||||
model_ref text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(subject_id,version)
|
||||
);
|
||||
CREATE INDEX memoir_document_subject_idx ON memoir_document(subject_id,version DESC);
|
||||
CREATE INDEX chat_daily_event_subject_idx ON chat_daily_event(subject_id,event_date DESC,created_at DESC);
|
||||
CREATE TABLE memoir_generation_job (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), subject_id uuid NOT NULL REFERENCES subject_profile(id),
|
||||
tenant_id uuid NOT NULL REFERENCES tenant(id), actor_id uuid NOT NULL REFERENCES user_account(id),
|
||||
source_digest text NOT NULL, state text NOT NULL DEFAULT 'PENDING' CHECK(state IN ('PENDING','RUNNING','READY','FAILED')),
|
||||
error_code text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX memoir_generation_active ON memoir_generation_job(subject_id) WHERE state IN ('PENDING','RUNNING');
|
||||
CREATE TABLE memoir_illustration (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), document_id uuid NOT NULL REFERENCES memoir_document(id) ON DELETE CASCADE,
|
||||
chapter_index integer NOT NULL, content bytea NOT NULL, content_type text NOT NULL DEFAULT 'image/jpeg',
|
||||
UNIQUE(document_id,chapter_index)
|
||||
);
|
||||
|
||||
-- Every source in a merged story must remain valid, including other calls.
|
||||
CREATE OR REPLACE FUNCTION call_story_available(story uuid) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
SELECT EXISTS(SELECT 1 FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id WHERE sd.id=story AND sp.status='active'
|
||||
AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.subject_id=sd.subject_id AND pr.request_type='DELETE')
|
||||
AND EXISTS(SELECT 1 FROM story_source ss WHERE ss.story_revision_id=sd.current_revision_id)
|
||||
AND NOT EXISTS(SELECT 1 FROM story_source ss
|
||||
LEFT JOIN call_utterance u ON u.session_id=ss.call_session_id AND u.utterance_id=ss.call_utterance_id
|
||||
LEFT JOIN call_session c ON c.id=ss.call_session_id
|
||||
LEFT JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id
|
||||
LEFT JOIN conversation cv ON cv.id=ct.conversation_id
|
||||
LEFT JOIN memoir_import_turn it ON it.id=ss.import_turn_id
|
||||
LEFT JOIN memoir_chat_import ci ON ci.id=it.import_id
|
||||
WHERE ss.story_revision_id=sd.current_revision_id AND (
|
||||
(ss.call_session_id IS NOT NULL AND (c.tenant_id IS DISTINCT FROM sd.tenant_id OR c.subject_id IS DISTINCT FROM sd.subject_id
|
||||
OR NOT call_story_authorized(c.id) OR u.source_revision IS DISTINCT FROM ss.call_source_revision
|
||||
OR u.speaker<>'USER' OR u.text_content='[deleted]' OR length(trim(u.text_content))=0))
|
||||
OR (ss.conversation_turn_id IS NOT NULL AND (cv.tenant_id IS DISTINCT FROM sd.tenant_id OR cv.subject_id IS DISTINCT FROM sd.subject_id
|
||||
OR ct.speaker<>'subject' OR ct.final_text='[deleted]' OR length(trim(ct.final_text))=0
|
||||
OR NOT EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=sd.subject_id AND cr.consent_type='story_extraction'
|
||||
AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))))
|
||||
OR (ss.import_turn_id IS NOT NULL AND (ci.tenant_id IS DISTINCT FROM sd.tenant_id OR ci.subject_id IS DISTINCT FROM sd.subject_id
|
||||
OR it.speaker<>'subject' OR it.content='[deleted]' OR length(trim(it.content))=0
|
||||
OR NOT EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=sd.subject_id AND cr.consent_type='story_extraction'
|
||||
AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))))
|
||||
OR EXISTS(SELECT 1 FROM memory_source_suppression ms CROSS JOIN LATERAL jsonb_array_elements(ms.source_refs) r
|
||||
WHERE ms.subject_id=sd.subject_id AND (r->>'story_id'=sd.public_id::text OR
|
||||
(ss.call_session_id IS NOT NULL AND r->>'session_id'=ss.call_session_id::text
|
||||
AND (r->>'utterance_id' IS NULL OR r->>'utterance_id'=ss.call_utterance_id)) OR
|
||||
(ss.conversation_turn_id IS NOT NULL AND r->>'turn_id'=ct.public_id::text) OR
|
||||
(ss.import_turn_id IS NOT NULL AND r->>'import_id'=ci.id::text AND (r->>'turn_id' IS NULL OR r->>'turn_id'=it.id::text))))))
|
||||
)
|
||||
$$;
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user