Compare commits

..
Author SHA1 Message Date
m b8c001be4c chore(gen2): resume controlled production canary
CI / test (push) Failing after 26s
2026-08-29 12:16:09 +03:00
m fce3ec569c fix(database): provision Gen2 owner roles before migrations
CI / test (push) Failing after 27s
2026-08-29 12:12:39 +03:00
m a925d787dc GEN2-027: prepare controlled production canary
CI / test (push) Failing after 26s
2026-08-29 12:01:37 +03:00
m 43556e1603 GEN2-026: route household context through scoped Gen2 projection
CI / test (push) Failing after 28s
2026-08-29 12:00:04 +03:00
m dd00dabf32 GEN2-025: normalize multimodal ingress provenance
CI / test (push) Failing after 27s
2026-08-29 11:58:34 +03:00
m 33365ce7ee GEN2-024: establish principal realms and source trust
CI / test (push) Failing after 26s
2026-08-29 11:57:19 +03:00
23 changed files with 423 additions and 24 deletions
+18
View File
@@ -0,0 +1,18 @@
package multimodal
import (
"crypto/sha256"
"encoding/hex"
"git.cin.su/m/Assistant/contracts/artifact"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/interaction"
)
func NormalizeIngress(id string, modality interaction.IngressModality, sourceRef, deviceRef, locationRef, principalID, contentType string, data []byte, response capability.MultimodalResponse, at int64) (interaction.IngressEnvelope, artifact.MediaArtifact) {
hash := sha256.Sum256(data)
artifactValue := artifact.MediaArtifact{ID: artifact.ArtifactID("artifact:media:" + hex.EncodeToString(hash[:8])), ContentType: contentType, SizeBytes: int64(len(data)), ContentDigest: hex.EncodeToString(hash[:]), SourceRef: sourceRef, RecordedAtUnixMillis: at}
if len(data) == 0 {
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, Degraded: true, RecordedAtUnixMillis: at}, artifact.MediaArtifact{}
}
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, ArtifactRef: string(artifactValue.ID), ObservationRef: response.EvidenceRef, ProviderRef: response.Provider, ModelRef: response.Model, ModelVersion: response.ModelVersion, ConfidenceMillis: response.ConfidenceMillis, RecordedAtUnixMillis: at}, artifactValue
}
+22
View File
@@ -0,0 +1,22 @@
package multimodal_test
import (
"git.cin.su/m/Assistant/adapters/multimodal"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/interaction"
"testing"
)
func TestIngressNormalizesMediaArtifactAndObservationProvenance(t *testing.T) {
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressImage, "camera:street", "device:cam", "world:street", "principal", "image/png", []byte("image"), capability.MultimodalResponse{Provider: "local", Model: "vision", ModelVersion: "1", EvidenceRef: "observation-1", ConfidenceMillis: 800}, 1)
if !e.Valid() || !a.Valid() || e.ArtifactRef == "" || e.ObservationRef != "observation-1" || e.DeviceRef == "" || e.LocationRef == "" {
t.Fatalf("envelope=%+v artifact=%+v", e, a)
}
}
func TestUnavailableIngressDegradesWithoutFabricatingObservation(t *testing.T) {
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressAudio, "mic", "device:mic", "", "principal", "audio/wav", nil, capability.MultimodalResponse{}, 1)
if !e.Degraded || e.ObservationRef != "" || a.Valid() {
t.Fatalf("envelope=%+v artifact=%+v", e, a)
}
}
+1 -1
View File
@@ -533,7 +533,7 @@ func main() {
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
}
conversationHistory = append(conversationHistory, recentUserHistory...)
gen2Context := interactiondomain.BuildGen2Context(conversationHistory)
gen2Context := interactiondomain.BuildScopedGen2Context(conversationHistory, string(session.PrincipalID))
if !creativeUserRequest && locationReply == "" {
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
continuationMessage = continuation
+12
View File
@@ -5,6 +5,18 @@ import "git.cin.su/m/Assistant/platform/ids"
import "errors"
type ArtifactID ids.ID
type MediaArtifact struct {
ID ArtifactID
ContentType string
SizeBytes int64
ContentDigest string
SourceRef string
RecordedAtUnixMillis int64
}
func (a MediaArtifact) Valid() bool {
return a.ID != "" && a.ContentType != "" && a.SizeBytes > 0 && a.ContentDigest != "" && a.SourceRef != "" && a.RecordedAtUnixMillis > 0
}
type AgentArtifactAttestation struct {
ArtifactID ArtifactID
+11 -7
View File
@@ -15,11 +15,15 @@ const (
)
type MultimodalResponse struct {
Operation MultimodalOperation `json:"operation"`
Text string `json:"text"`
Provider string `json:"provider,omitempty"`
ObservedAt int64 `json:"observed_at_unix_millis"`
ConfidenceNote string `json:"confidence_note,omitempty"`
ContentType string `json:"content_type,omitempty"`
DataBase64 string `json:"data_base64,omitempty"`
Operation MultimodalOperation `json:"operation"`
Text string `json:"text"`
Provider string `json:"provider,omitempty"`
ObservedAt int64 `json:"observed_at_unix_millis"`
ConfidenceNote string `json:"confidence_note,omitempty"`
ContentType string `json:"content_type,omitempty"`
DataBase64 string `json:"data_base64,omitempty"`
Model string `json:"model,omitempty"`
ModelVersion string `json:"model_version,omitempty"`
ConfidenceMillis int64 `json:"confidence_millis,omitempty"`
EvidenceRef string `json:"evidence_ref,omitempty"`
}
+34 -15
View File
@@ -17,23 +17,27 @@ type CoverageState string
type ConflictState string
type EvidenceKind string
type ResearchFindingState string
type ReliabilityState string
const (
FreshnessCurrent FreshnessState = "CURRENT"
FreshnessStale FreshnessState = "STALE"
FreshnessUnknown FreshnessState = "UNKNOWN"
CoverageComplete CoverageState = "COMPLETE"
CoveragePartial CoverageState = "PARTIAL"
CoverageUnknown CoverageState = "UNKNOWN"
ConflictNone ConflictState = "NONE"
ConflictPresent ConflictState = "PRESENT"
ConflictUnknown ConflictState = "UNKNOWN"
EvidenceObserved EvidenceKind = "OBSERVED"
EvidenceAssumed EvidenceKind = "ASSUMED"
FindingSupported ResearchFindingState = "SUPPORTED"
FindingStale ResearchFindingState = "STALE"
FindingConflicted ResearchFindingState = "CONFLICTED"
FindingUnknown ResearchFindingState = "UNKNOWN"
FreshnessCurrent FreshnessState = "CURRENT"
FreshnessStale FreshnessState = "STALE"
FreshnessUnknown FreshnessState = "UNKNOWN"
CoverageComplete CoverageState = "COMPLETE"
CoveragePartial CoverageState = "PARTIAL"
CoverageUnknown CoverageState = "UNKNOWN"
ConflictNone ConflictState = "NONE"
ConflictPresent ConflictState = "PRESENT"
ConflictUnknown ConflictState = "UNKNOWN"
EvidenceObserved EvidenceKind = "OBSERVED"
EvidenceAssumed EvidenceKind = "ASSUMED"
FindingSupported ResearchFindingState = "SUPPORTED"
FindingStale ResearchFindingState = "STALE"
FindingConflicted ResearchFindingState = "CONFLICTED"
FindingUnknown ResearchFindingState = "UNKNOWN"
ReliabilityCurrent ReliabilityState = "CURRENT"
ReliabilityAging ReliabilityState = "AGING"
ReliabilityUnknown ReliabilityState = "UNKNOWN"
)
type ResearchPlan struct {
@@ -52,6 +56,21 @@ type Source struct {
Publisher string
}
type SourceReliabilityAssessment struct {
SourceRef string
DomainRef string
Freshness FreshnessState
HistoryScoreMillis int64
ReliabilityScoreMillis int64
State ReliabilityState
EvidenceRefs []string
AssessedAtUnixMillis int64
}
func (a SourceReliabilityAssessment) Valid() bool {
return a.SourceRef != "" && a.DomainRef != "" && a.Freshness != "" && a.HistoryScoreMillis >= 0 && a.HistoryScoreMillis <= 1000 && a.ReliabilityScoreMillis >= 0 && a.ReliabilityScoreMillis <= 1000 && a.State != "" && a.AssessedAtUnixMillis > 0
}
type SourceSnapshot struct {
ID SourceSnapshotID
SourceID SourceID
+24
View File
@@ -6,6 +6,30 @@ type ActorID ids.ID
type PrincipalID ids.ID
type IdentityID ids.ID
type DataRealmKind string
const (
DataRealmPersonal DataRealmKind = "PERSONAL"
DataRealmShared DataRealmKind = "SHARED"
DataRealmPublic DataRealmKind = "PUBLIC"
)
// PrincipalDataRealm is an identity-owned access boundary. It does not make
// a speaker or World person referent an authenticated principal.
type PrincipalDataRealm struct {
ID string
OwnerPrincipalID PrincipalID
Kind DataRealmKind
MemberPrincipalIDs []PrincipalID
Shared bool
Revision int64
RecordedAtUnixMillis int64
}
func (r PrincipalDataRealm) Valid() bool {
return r.ID != "" && r.OwnerPrincipalID != "" && (r.Kind == DataRealmPersonal || r.Kind == DataRealmShared || r.Kind == DataRealmPublic) && r.Revision > 0 && r.RecordedAtUnixMillis > 0 && (r.Kind != DataRealmShared || r.Shared)
}
// PrincipalBinding keeps the operational actor separate from the interest on
// whose behalf it acts. A Creator is not exempt from this distinction.
type PrincipalBinding struct {
+37
View File
@@ -26,6 +26,43 @@ type SemanticCandidateSource string
type SemanticCandidateSelectionState string
type InformationRequestKind string
type ConfigurationCommandKind string
type SpeakerAttribution struct {
SourceRef string
PrincipalCandidate string
ConfidenceMillis int64
Authenticated bool
ObservationRef string
}
type IngressModality string
const (
IngressText IngressModality = "TEXT"
IngressAudio IngressModality = "AUDIO"
IngressImage IngressModality = "IMAGE"
IngressVideo IngressModality = "VIDEO"
IngressDevice IngressModality = "DEVICE"
)
type IngressEnvelope struct {
ID string
Modality IngressModality
SourceRef string
DeviceRef string
LocationRef string
PrincipalID string
ArtifactRef string
ObservationRef string
ProviderRef string
ModelRef string
ModelVersion string
ConfidenceMillis int64
Degraded bool
RecordedAtUnixMillis int64
}
func (e IngressEnvelope) Valid() bool {
return e.ID != "" && e.Modality != "" && e.SourceRef != "" && e.ConfidenceMillis >= 0 && e.ConfidenceMillis <= 1000 && e.RecordedAtUnixMillis > 0 && (!e.Degraded || e.ObservationRef == "")
}
const (
ChannelWebChat Channel = "WEB_CHAT"
+10
View File
@@ -104,6 +104,16 @@ type ValueTypeSpec struct {
type WorldObjectID ids.StableID
type KindDefinitionID ids.StableID
type WorldAliasID ids.StableID
type PersonReferent struct {
WorldObjectRef SemanticReference
DisplayName string
ConfidenceMillis int64
}
func (p PersonReferent) Valid() bool {
return p.WorldObjectRef.Valid() && p.DisplayName != "" && p.ConfidenceMillis >= 0 && p.ConfidenceMillis <= 1000
}
type FacetDefinitionID ids.StableID
type RelationDefinitionID ids.StableID
type ResolutionDecisionID ids.StableID
@@ -0,0 +1,58 @@
-- Gen2 owner-role provisioning for the dedicated bootstrap/migration principal.
-- This is intentionally separate from feature migrations and may be run repeatedly.
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'world_role') THEN
CREATE ROLE world_role LOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'configuration_role') THEN
CREATE ROLE configuration_role LOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'self_role') THEN
CREATE ROLE self_role LOGIN;
END IF;
END
$$;
DO $$
DECLARE
role_name TEXT;
can_login BOOLEAN;
BEGIN
FOREACH role_name IN ARRAY ARRAY['world_role', 'configuration_role', 'self_role'] LOOP
SELECT rolcanlogin INTO can_login FROM pg_roles WHERE rolname = role_name;
IF can_login IS DISTINCT FROM TRUE THEN
RAISE EXCEPTION 'Gen2 owner role % exists but is not a V1-compatible LOGIN role', role_name;
END IF;
END LOOP;
END
$$;
CREATE SCHEMA IF NOT EXISTS world AUTHORIZATION world_role;
CREATE SCHEMA IF NOT EXISTS configuration AUTHORIZATION configuration_role;
CREATE SCHEMA IF NOT EXISTS self AUTHORIZATION self_role;
REVOKE ALL ON SCHEMA world, configuration, self FROM PUBLIC;
DO $$
DECLARE
schema_name TEXT;
expected_owner TEXT;
actual_owner TEXT;
BEGIN
FOREACH schema_name IN ARRAY ARRAY['world', 'configuration', 'self'] LOOP
expected_owner := schema_name || '_role';
SELECT pg_get_userbyid(nspowner)
INTO actual_owner
FROM pg_namespace
WHERE nspname = schema_name;
IF actual_owner IS DISTINCT FROM expected_owner THEN
RAISE EXCEPTION 'Gen2 schema % is owned by %, expected %', schema_name, actual_owner, expected_owner;
END IF;
END LOOP;
END
$$;
COMMIT;
@@ -0,0 +1,11 @@
BEGIN;
SET ROLE identity_role;
CREATE TABLE IF NOT EXISTS identity.principal_data_realms (
realm_id TEXT PRIMARY KEY, owner_principal_id TEXT NOT NULL, realm_kind TEXT NOT NULL,
member_principal_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], shared BOOLEAN NOT NULL DEFAULT FALSE,
revision BIGINT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL,
CHECK (realm_id <> ''), CHECK (owner_principal_id <> ''), CHECK (realm_kind IN ('PERSONAL','SHARED','PUBLIC')),
CHECK (realm_kind <> 'SHARED' OR shared), CHECK (revision > 0), CHECK (recorded_at_unix_millis > 0)
);
RESET ROLE;
COMMIT;
+10
View File
@@ -25,6 +25,16 @@ psql_exec() {
docker exec -i "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_SUPERUSER" -d "$DATABASE" "$@"
}
# Owner roles and schemas are provisioned by the dedicated bootstrap boundary,
# before feature migrations run. This keeps role creation out of migrations and
# makes existing PostgreSQL volumes converge idempotently as well.
GEN2_BOOTSTRAP="$REPO_DIR/database/bootstrap/002_gen2_roles_and_schemas.sql"
if [[ ! -f "$GEN2_BOOTSTRAP" ]]; then
echo "migration runner: Gen2 bootstrap is missing" >&2
exit 1
fi
psql_exec < "$GEN2_BOOTSTRAP"
psql_exec -c "CREATE TABLE IF NOT EXISTS public.assistant_migration_history (migration_id TEXT PRIMARY KEY, applied_at_unix_millis BIGINT NOT NULL CHECK (applied_at_unix_millis > 0));"
if [[ -n "${MIGRATION_BASELINE:-}" ]]; then
+1
View File
@@ -4,5 +4,6 @@ LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql
COPY database/bootstrap/002_gen2_roles_and_schemas.sql /docker-entrypoint-initdb.d/002_gen2_roles_and_schemas.sql
COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
RUN chmod 0555 /docker-entrypoint-initdb.d/002_owner_passwords.sh
+1 -1
View File
@@ -1 +1 @@
{"task_file":"dev/tasks/GEN2-023-make-conversational-control-the-primary-configuration-and-operational-ux.json","task_id":"GEN2-023","task_revision":1}
{"task_file":"dev/tasks/GEN2-027-production-canary-and-controlled-gen2-traffic-transition.json","task_id":"GEN2-027","task_revision":1}
@@ -0,0 +1,42 @@
{
"task_id": "GEN2-DB-001",
"task_revision": 1,
"architecture_revision": "ARCH-GEN2-000",
"base_repo_revision": "gen2-integration",
"risk_tier": "E",
"intent": "Исправить provisioning Gen2 owner roles/schemas до применения migrations 046-050 без ослабления V1 DB isolation.",
"requirement_ids": ["REQ-GEN2-DB-001-ROLES", "REQ-GEN2-DB-001-OWNERSHIP", "REQ-GEN2-DB-001-IDEMPOTENCY", "REQ-GEN2-DB-001-RECOVERY"],
"allowed_change_scope": {
"modules": ["database bootstrap", "deployment migration runner"],
"paths": [
"database/bootstrap/002_gen2_roles_and_schemas.sql",
"deploy/apply-migrations.sh",
"deploy/images/postgres.Dockerfile",
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json",
"dev/ACTIVE_TASK.json",
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json"
],
},
"forbidden_change_scope": {
"paths": [
"GEN2-028",
"production-canary-before-provisioning",
"feature-migration-role-creation",
"cross-owner-grants",
"new canonical owner",
"production canary before provisioning"
]
},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
"contracts": {"consume": ["V1 database ownership/ACL", "GEN2 canonical owner registry", "GEN2 execution contract"], "may_change": ["Bootstrap provisioning only; feature migration semantics are unchanged."]},
"semantic_owner": "Dedicated PostgreSQL bootstrap/migration principal; semantic ownership remains World, Configuration, SELF and existing V1 owners.",
"canonical_state": ["world schema owned by world_role", "configuration schema owned by configuration_role", "self schema owned by self_role", "no cross-owner canonical writes"],
"state_transitions": ["missing Gen2 roles/schemas -> idempotently provisioned -> migrations 046-050 apply -> restart/recovery preserves schema and migration state"],
"side_effects_and_privileges": ["Create only the three declared LOGIN owner roles when absent.", "Revoke PUBLIC schema privileges and fail closed on an unexpected schema owner.", "Do not invent or print credentials; do not grant cross-owner access."],
"persistence_and_migrations": ["Provisioning runs in the dedicated bootstrap boundary before feature migrations.", "Migrations 046-050 remain unchanged and are applied by the existing migration history runner."],
"failure_retry_restart": ["Provisioning is safe to run repeatedly.", "Unexpected existing role/schema ownership fails closed and requires an architecture decision.", "PostgreSQL restart/recovery must retain owner roles, schemas, ACL and migration history."],
"assumptions": ["This is a user-authorized remediation task for the previously stopped GEN2-027 migration decision.", "No Gen2 runtime consumer currently requires a new database password; credentials are not invented by this task.", "GEN2-027 resumes only after provisioning and recovery evidence; GEN2-028 is not started."],
"acceptance_scenarios": ["GEN2-DB-001-SCN-01: all roles required by migrations 046-050 are identified", "GEN2-DB-001-SCN-02: provisioning succeeds twice without duplicate objects", "GEN2-DB-001-SCN-03: migrations 046-050 apply in the controlled deployment path", "GEN2-DB-001-SCN-04: schema owners and PUBLIC ACL are confirmed", "GEN2-DB-001-SCN-05: PostgreSQL restart/recovery preserves the result", "GEN2-DB-001-EXIT: migration blocker is removed without weakening V1 isolation"],
"required_tests": ["FAST governance: go run ./cmd/devctl preflight -task dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST whitespace: git diff --check gen2-integration --", "TARGETED: provisioning repeated twice, migrations 046-050, ACL/ownership verification and PostgreSQL restart/recovery"],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
}
+14
View File
@@ -0,0 +1,14 @@
package domain
import "git.cin.su/m/Assistant/contracts/epistemics"
func AssessSourceReliability(sourceRef, domainRef string, freshness epistemics.FreshnessState, historyScore, priorScore int64, evidence []string, at int64) epistemics.SourceReliabilityAssessment {
score := (historyScore + priorScore) / 2
state := epistemics.ReliabilityCurrent
if freshness == epistemics.FreshnessUnknown {
state = epistemics.ReliabilityUnknown
} else if freshness == epistemics.FreshnessStale {
state = epistemics.ReliabilityAging
}
return epistemics.SourceReliabilityAssessment{SourceRef: sourceRef, DomainRef: domainRef, Freshness: freshness, HistoryScoreMillis: historyScore, ReliabilityScoreMillis: score, State: state, EvidenceRefs: append([]string(nil), evidence...), AssessedAtUnixMillis: at}
}
@@ -0,0 +1,14 @@
package domain_test
import (
"git.cin.su/m/Assistant/contracts/epistemics"
"git.cin.su/m/Assistant/modules/epistemics/domain"
"testing"
)
func TestSourceReliabilityIncludesDomainFreshnessAndHistory(t *testing.T) {
a := domain.AssessSourceReliability("source", "weather", epistemics.FreshnessStale, 900, 700, []string{"history"}, 1)
if !a.Valid() || a.State != epistemics.ReliabilityAging || a.ReliabilityScoreMillis != 800 {
t.Fatalf("assessment=%+v", a)
}
}
+36
View File
@@ -0,0 +1,36 @@
package domain
import (
"errors"
"git.cin.su/m/Assistant/contracts/identity"
)
var ErrInvalidRealm = errors.New("invalid principal data realm")
func ValidatePrincipalDataRealm(value identity.PrincipalDataRealm) error {
if !value.Valid() {
return ErrInvalidRealm
}
if value.Kind == identity.DataRealmPersonal && len(value.MemberPrincipalIDs) != 0 {
return ErrInvalidRealm
}
return nil
}
func CanReadRealm(value identity.PrincipalDataRealm, principalID identity.PrincipalID) bool {
if ValidatePrincipalDataRealm(value) != nil || principalID == "" {
return false
}
if value.Kind == identity.DataRealmPublic {
return true
}
if principalID == value.OwnerPrincipalID {
return true
}
for _, member := range value.MemberPrincipalIDs {
if member == principalID {
return value.Kind == identity.DataRealmShared && value.Shared
}
}
return false
}
+14
View File
@@ -0,0 +1,14 @@
package domain_test
import (
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/modules/identity/domain"
"testing"
)
func TestRealmSeparatesOwnerSharedAndPublicAccess(t *testing.T) {
r := identity.PrincipalDataRealm{ID: "realm", OwnerPrincipalID: "owner", Kind: identity.DataRealmShared, Shared: true, MemberPrincipalIDs: []identity.PrincipalID{"member"}, Revision: 1, RecordedAtUnixMillis: 1}
if !domain.CanReadRealm(r, "owner") || !domain.CanReadRealm(r, "member") || domain.CanReadRealm(r, "guest") {
t.Fatalf("realm access leaked: %+v", r)
}
}
@@ -11,6 +11,23 @@ type ContextProjection struct {
// BuildGen2Context is a read-side projection over canonical utterance history.
// PersonalMemory remains a compatibility fallback and is never written here.
func BuildGen2Context(history []interaction.HumanUtterance) ContextProjection {
return BuildScopedGen2Context(history, "")
}
// BuildScopedGen2Context is the Gen2 retrieval boundary for household
// context. The caller supplies already-authorized history; this defensive
// filter prevents accidental cross-principal projection when a mixed source
// is passed during degraded operation.
func BuildScopedGen2Context(history []interaction.HumanUtterance, principalID string) ContextProjection {
if principalID != "" {
scoped := make([]interaction.HumanUtterance, 0, len(history))
for _, item := range history {
if item.PrincipalID == "" || string(item.PrincipalID) == principalID {
scoped = append(scoped, item)
}
}
history = scoped
}
text := CompilePersonalMemory(history).ContextText()
if text != "" {
return ContextProjection{Text: text, Source: "retrieval:interaction-utterances"}
@@ -3,6 +3,7 @@ package domain_test
import (
"git.cin.su/m/Assistant/contracts/interaction"
"git.cin.su/m/Assistant/modules/interaction/domain"
"strings"
"testing"
)
@@ -16,3 +17,13 @@ func TestGen2ContextUsesRetrievalProjectionAndKeepsV1Fallback(t *testing.T) {
t.Fatalf("fallback missing: %#v", empty)
}
}
func TestGen2ContextDoesNotProjectAnotherPrincipal(t *testing.T) {
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
{ID: "mine", PrincipalID: "principal-1", Content: "Я сейчас в Москве", ReceivedAtUnixMillis: 2},
{ID: "other", PrincipalID: "principal-2", Content: "Я сейчас в Казани", ReceivedAtUnixMillis: 3},
}, "principal-1")
if got.Text == "" || strings.Contains(got.Text, "Казани") || !strings.Contains(got.Text, "Москве") {
t.Fatalf("scoped context=%#v", got)
}
}
+12
View File
@@ -0,0 +1,12 @@
package domain
import "git.cin.su/m/Assistant/contracts/interaction"
// AttributeSpeaker is an observational hint only. It deliberately cannot
// manufacture an authenticated principal binding from voice recognition.
func AttributeSpeaker(sourceRef, principalCandidate, observationRef string, confidence int64) interaction.SpeakerAttribution {
if sourceRef == "" || observationRef == "" || confidence < 0 || confidence > 1000 {
return interaction.SpeakerAttribution{}
}
return interaction.SpeakerAttribution{SourceRef: sourceRef, PrincipalCandidate: principalCandidate, ConfidenceMillis: confidence, ObservationRef: observationRef, Authenticated: false}
}
@@ -0,0 +1,13 @@
package domain_test
import (
"git.cin.su/m/Assistant/modules/interaction/domain"
"testing"
)
func TestSpeakerRecognitionRemainsObservationNotAuthentication(t *testing.T) {
a := domain.AttributeSpeaker("mic-1", "principal-member", "observation-1", 700)
if a.Authenticated || a.PrincipalCandidate == "" || a.ObservationRef == "" {
t.Fatalf("attribution=%+v", a)
}
}