Paciente creation is unattributable: no createdBy column, no appEvents row, and the resolver takes no actor argument
Creating a paciente in the shared registry writes NO audit trail. Measured live 2026-09-11 on ref eprpywqwzsvrjeaforei: pacientes has no createdBy/createdByRole column, and appEvents holds ZERO rows for any paciente-creation action - the only paciente-related events on the whole table are 4 'pacienteLookup' failures from lookupTerraPaciente, all appKey=terra. resolveOrCreatePaciente and the new resolveOrCreatePacienteForApp are both SECURITY INVOKER with EXECUTE to service_role only, and NEITHER TAKES AN ACTOR ARGUMENT, so the function could not log who called it even if it wanted to. Every authenticated terra user reaches it as the same DB principal.\n\nTHE ASYMMETRY IS THE FINDING: lookupTerraPaciente, the READ path, takes actorUserId and writes an appEvents row. The WRITE path takes no actor and writes nothing. The lower-risk operation is the audited one.\n\nWHY IT HAS NOT BITTEN YET, and why that is luck rather than design: all 6 pacientes rows were created inside the same transaction as a terraPracticas row - gap 00:00:00 to the microsecond on every one - and terraPracticas HAS createdBy. That is the only reason attribution was possible for TERRA-150 (result: 3 of 6 created by one alumno, 3 by one client+docente user). Call the resolver from any path that does not also write a practica and attribution becomes structurally impossible, with no error and no gap in any log.\n\nSCOPE, cross-lane: the function signature must gain an actor so the DB can record it, which means this rides the ENAMEL-36 phase-2 signature work rather than opening a third signature in parallel - node-postgres sends untyped positional params and same-named candidates raise 42725. db-enamel-cc writes the function change and the appEvents insert; coder-enamel-cc GOes it as a shared object and terra's caller passes the actor. Ley 25.326 relevance is direct and belongs on ENAMEL-1: an unattributable write to a clinical identity registry is exactly what a data-residency and accountability review asks about.
Sub-tickets — 1/1 done
Activity
-
parent=#3653
-
CORRECTION TO MY OWN MEASUREMENT, and it makes this row worse rather than better. I filed this saying attribution had been possible only by accident, via terraPracticas.createdBy on the same-transaction write. coder-terra-cc corrected me: createdBy IS CALLER-SUPPLIED. createTerraPractica takes actorUserId as an argument and only checks that such a member exists, so the application names the actor and the database records the assertion (their TERRA-231, p1). No alumno has ever created a paciente through terra's form - the three rows my census attributed to an alumno were one person on a second account, written through the function with a supplied actorUserId, not through the role gate.\n\nSO THE CENSUS I GAVE TERRA-150 WAS RIGHT ABOUT THE STORED VALUE AND WRONG ABOUT WHAT IT MEANT. '3 of 6 created by an alumno' is what the column says; it is not what happened. The join was correct and both facts were true, which is exactly why it passed - what I did not check was the PROVENANCE of the column I was trusting. An actor column is evidence only if the value is authenticated rather than passed in, and I never asked which this was.\n\nWHAT IT CHANGES HERE: the fallback channel named in this row's description is not a fallback. There is no authenticated attribution for a paciente write anywhere in the system - not on pacientes, not in appEvents, and not in terraPracticas either. When the actor argument is added per the scope note, it must be derived from the authenticated session at the boundary and NOT accepted as a parameter, or this row ships the same defect one table over. That is the acceptance criterion.\n\nTERRA-150 itself is unaffected in outcome: terra keeps its five-role gate and the decision rests on the policy argument, which coder-terra-cc has recorded explicitly, since 141 of 169 terra grants are alumno and nothing has been created since 2026-08-30 - six rows cannot separate 'alumnos do not do this' from 'nobody has used the app yet'.
-
RE-DERIVED INDEPENDENTLY, not accepted on report, per my own co-tenancy rule. coder-terra-cc and db-terra-cc say three of the six rows in the shared pacientes registry are MOCKS. Confirmed by my own SELECT: the three paired practicas at 12:10:25/32/39 on 2026-08-23 carry pacienteHistoriaClinica HC-MOCK-001/002/003 with reciboNumber NULL, while all three app-written rows have reciboNumber SET. One refinement to their narrative that does not change the conclusion: the 2026-08-21 17:23 row is ALSO NULL on pacienteNombre/pacienteApellido, because it predates terra's ce6612a - so the name-snapshot fields are a BUILD-dated secondary signal and reciboNumber is the discriminator that actually separates the two writers.\n\nALL SIX CARRY isSynthetic=false, AND THAT IS CORRECT. The flag means synthetic-session data by terra's definition and these are not that; they are mocks written through createTerraPractica, which terra_42:30 describes as the edge-callable function used for mock/API creation and which has zero call sites in terra's src/. So a mock practica created a real, unflagged row in a shared clinical registry, three times in fourteen seconds. NOBODY'S CODE MISBEHAVED - both functions are SECURITY INVOKER, owner postgres, ACL postgres=X plus service_role=X, no anon, no authenticated. Everything worked as written. The registry simply has no way to say which of its rows are real.\n\nWHAT IT DOES TO THIS ROW: the scope grows from 'no createdBy' to 'no PROVENANCE AT ALL'. An actor column alone would have recorded these three as created by a real userId and still not told anyone they were mocks. pacientes needs both - who, and through what path - and the path value must be hardcoded per writer, never an argument, for the same reason the actor must be derived at the boundary. terra is adding exactly that to terraPracticas as createdVia (TERRA-231). IF WE PICK A DIFFERENT VOCABULARY FOR THE SAME WRITE PATH WE GET TWO INCOMPATIBLE PROVENANCE SCHEMES ON ONE CHAIN, so the enamel-side marker should reuse terra's value set rather than invent one.\n\nSECOND-ORDER FINDING, and this one is a decision nobody has taken: the three mock rows are NOT DISPOSABLE under enamel's existing dev-mode rule, which allows removing only INDIVIDUALLY FLAGGED synthetic or test rows. These carry no flag, so the rule that exists to protect real data also protects mocks, and the registry has no mechanism to ever clean them. Whether they stay, get flagged retroactively, or get removed is a data decision on a clinical table - db-enamel-cc's lane to execute, not mine, and above my level to take unilaterally. Surfacing it rather than acting on it.\n\nCORPUS CORRECTION for anyone who reads my earlier events: real usage of this registry is THREE rows, not six, all one person across two accounts. Everything I inferred from the six-row corpus about role distribution is withdrawn.
-
Caller-inventory half delegated to db-terra-cc 2026-09-11 (they offered capacity; terra's deployed src/ and edge functions are out of my reach). Scope asked: does ANY reachable caller invoke resolveOrCreatePaciente / resolveOrCreatePacienteForApp WITHOUT writing a terraPracticas row in the same transaction - that assumption, derived by me from n=6 same-transaction rows, is the only thing holding attribution together today and I cannot test it from the enamel tree. Told them a repo sweep is not exhaustive here: both resolvers are EXECUTE-to-service_role, so an ad-hoc key holder is a caller no grep sees, and the verdict must say so rather than read as a clean negative. Read-only, written verdict back to me, no apply. Row stays mine; nothing else about it changes.
-
1
-
RAISED p2->p1 on db-terra-cc's caller inventory (2026-09-11, their evidence file 2026-09-11-enamel38-resolver-caller-inventory.result.md). MY ASSUMPTION IS NOW FALSE AND IT BECAME FALSE TODAY. I derived 'every resolver call also writes a terraPracticas row' from n=6; they confirmed the pattern holds for all 6 (pacientes.createdAt equals terraPracticas.createdAt to the microsecond on every one) and then found it held because exactly ONE path could create a paciente, not because anything enforces it. terra shipped a second path in 7741b76 TODAY: fulfilPacienteRegistrationRequest, src/lib/paciente-request-actions.ts:109 at deployed 72b8305, calls resolveOrCreatePacienteForApp and then writes only an UPDATE to terraPacienteRegistrationRequests before COMMIT. No practica, that transaction or any other.\n\nTHREE CREATION ROUTES NOW, THREE DIFFERENT ATTRIBUTION STORIES, and nothing in the database can tell them apart after the fact: (a) practica path, recoverable from terraPracticas.createdBy; (b) fulfil path, recoverable from an appEvents row (category authz, action pacienteRegistrationRequestFulfilled, actorUserId, resourceType paciente, resourceId the new pacienteId) - but that row is written by APPLICATION CODE after the resolver returns, not by the resolver and not by a trigger, so it is a convention of one file rather than a property of the schema, and its count is ZERO today because the mechanism shipped today and nobody has exercised it; (c) an ad-hoc service_role or postgres credential holder, NOT RECOVERABLE AT ALL. (c) is why this is structural and not a coverage hole: no application change touches it.\n\nINSTRUMENT COVERAGE, stated because I asked them to state it: the pg_proc sweep was whole-catalog on prosrc and found exactly one function in the family (createTerraPractica, resolve-then-insert; its 9-arg mention is inside a COMMENT, which a bare name grep cannot distinguish). terra's src/ was swept at the ESTABLISHED deployed commit (health reports 0.31.73 + dpl_4ddDXUb1DEiUrkZ5JRST1j5eyFkm; 0.31.73 appears in package.json at exactly one commit, 72b8305), not at HEAD. Edge functions: list_edge_functions returned []. NOT covered: the enamel tree, and any caller holding a service_role or postgres credential - a code inventory is not a caller inventory. Enamel's deployed version measured by me just now is 0.10.8 / dpl_C1hfAAYp9WZmxB4wwRppkBo7rRmD; sent to them so they can close the enamel half against the right commit.
-
RE-DERIVED LIVE 2026-09-28 on ref eprpywqwzsvrjeaforei (SELECT-only, pg_proc + information_schema + counts). Three corrections and one new finding. CORRECTION 1: resolveOrCreatePaciente (the original 9-arg) NO LONGER EXISTS. pg_proc holds exactly one resolver in the family: resolveOrCreatePacienteForApp(appKeyArg text, documentoTypeArg text, documentoNumberArg text, nombreArg text, apellidoArg text, emailArg text, telefonoArg text, fechaNacimientoArg date, isSyntheticArg boolean, syntheticSessionIdArg text). SECURITY INVOKER, owner postgres, proconfig search_path=public,pg_temp, EXECUTE to postgres+service_role only. The WI body says 'both' - only one survives, so there is no ungated predecessor overload left to chase. CORRECTION 2: pacientes still has NO attribution column of any kind. Full column list: id, documentoType, documentoNumber, nombre, apellido, email, emailCanonical, telefono, fechaNacimiento, isSynthetic, syntheticSessionId, createdAt, updatedAt, deletedAt. No createdBy, no createdByRole, no createdByAppKey, no appKey. CORRECTION 3: appEvents rows with resourceType='paciente' = ZERO, still, 17 days after terra shipped fulfilPacienteRegistrationRequest. The convention-of-one-file mechanism from route (b) has never once fired in production. Its zero is not evidence of no creations - pacientes total is 6, of which 0 are synthetic. NEW FINDING, and it changes the cheapest fix: THE RESOLVER ALREADY RECEIVES AN ATTRIBUTION AXIS AND THROWS IT AWAY. appKeyArg is mandatory (blank raises), and it is load-bearing - it gates the fail-closed vocabularyScopes check that decides which identity namespace the documento number lives in. Then neither INSERT arm stores it. Both the synthetic arm and the real arm list only documentoType/documentoNumber/nombre/apellido/email/telefono/fechaNacimiento (+isSynthetic/syntheticSessionId). So the app-of-origin is known to the function, validated by the function, and discarded by the function. vocabularyScopes currently scopes documentoType for exactly two appKeys: enamel, terra. That splits the fix into two legs of very different cost: LEG A - persist createdByAppKey from the appKeyArg already in hand. NO signature change, NO caller change, NO terra code change, no co-tenancy ack needed on the calling contract. Every future row gets an app-of-origin that an ad-hoc service_role holder cannot omit, because a caller with no vocabularyScopes row is already refused. This closes route (c) to the level of 'which app', not 'which person'. LEG B - createdByUserId + an actorUserIdArg parameter. This is a SIGNATURE CHANGE, therefore an OVERLOAD, therefore the old ungated signature survives its own fix unless explicitly dropped, and it requires every terra caller to be updated in the same window. Full co-tenancy announcement to terra's PM seat, ack-required. BACKFILL: 6 existing rows, attribution NOT uniformly derivable (3 are mocks per the 2026-09-11 re-derivation; the practica path recovers terraPracticas.createdBy for some, the fulfil path recovers nothing because its appEvents count is zero). So NOT NULL is unreachable without writing a sentinel, and a sentinel would be a lie in the audit column. Correct shape: nullable column, backfill only what is genuinely derivable, and enforce going-forward with a CHECK constraint carrying a cutover timestamp - check ("createdByAppKey" is not null or "createdAt" < <cutover>) - so the guard is a constraint enforced on every later write rather than a convention in one function body. NEXT: Leg A specced to db-enamel-cc this turn. Leg B stays open pending the terra announcement.
-
Terra cleared leg A 2026-09-28 (coder-terra-cc): compatible, no objection, no terra code change. Leg B is blocked on terra's own side, not on silence - terra's actor-bearing path (TERRA-36 internal support-admin) is itself blocked on TERRA-242 (bind by supabaseAuthId), so an actorUserIdArg overload would have no caller on terra today. Leg A is explicitly NOT held for it. ORDERING DEPENDENCY, now binding on leg A's schedule: terra is seeding demo/sample pacientes for teacher-facing demos (Elazar order 2026-09-28), hard-deleted pre-go-live by an ordered DELETE script owned by db-terra-cc. Those inserts go through resolveOrCreatePacienteForApp, so they carry createdByAppKey='terra' and satisfy the forward CHECK - BUT ONLY IF THEY LAND AFTER LEG A. Seeded before, they fall under the cutover exemption and are permanently unattributable, which is the exact hole this WI exists to close. Terra has agreed to seed after leg A lands. Leg A is therefore on terra's critical path, not just enamel's. Separate correction I raised and terra took: the demo pacientes will be marked with isSyntheticArg=true, NOT an @demo.local email suffix. The resolver's synthetic arm already exists for this and is strictly stronger - the narrowed unique indexes EXCLUDE synthetic rows, so a demo paciente cannot collide with or block a real patient on the documento axis, whereas an unflagged row with a suffixed email sits inside the live uniqueness namespace. Terra split the markers correctly: isSynthetic for pacientes, @demo.local retained for the demo STUDENT ACCOUNT users rows (isSynthetic is a pacientes column and does not reach users/GoTrue). db-terra-cc's delete script now carries both entry predicates in one transaction with per-table counts.
-
LEG A CLOSED 2026-09-28. enamel_071 applied to eprpywqwzsvrjeaforei by db-enamel-cc at git 1996f3a; applied md5 4dd389d78d9ee133ace52c032b8426e2; supabase_migrations.schema_migrations version=20260928095206. I verified live and independently (SELECT-only): pacientes."createdByAppKey" exists, ck_pacientes_createdByAppKey_after_cutover convalidated=true, registered statements md5 matches the applied md5 and the git blob, 0 rows carry createdByAppKey (correct - the cutover exempts every pre-existing row), 0 ACC6 probe leftovers (both probe arms unwound). Audit trail: audit-general-ca is the single auditor for this artifact (Elazar order, ONE audit per artifact, relayed coder-terra-cc 2026-09-28). PASS on 1996f3a, apply GO from me naming that exact head, head re-verified by both of us at GO and at apply. A PRIOR HEAD FAILED AT APPLY AND THAT IS THE LESSON: f2b30ae passed two source-only Class-S reviews plus my own read and still died on 42P01 - the ACC6 probes called the resolver in a FROM clause without an AS r alias, so r."pacienteId" had no relation to resolve. Clean rollback, nothing landed. An acceptance block is executable text; reviewing it by reading is a category error. 1996f3a was dry-run in full against the live DB under an outer begin/rollback before the audit was requested. Recorded in feedback_hold_amended_migration_for_reaudit.md. CARRIED LIMITS, documented not closed: (1) pacientes."createdAt" defaults to now() = transaction start, so the cutover CHECK is evadable by a transaction begun before the commit, and by an explicitly backdated createdAt regardless. (2) ACC6 does not assert outcome=created - LOW, next time the file opens. LEG B REMAINS OPEN and unassigned: actorUserIdArg + createdByUserId, a signature change and therefore an overload, needs a terra announcement, blocked on TERRA-242 (bind by supabaseAuthId) because terra has no actor to pass until it lands. WI stays open on leg B. TERRA UNBLOCKED: terra agreed to seed demo pacientes only after leg A. Leg A has landed, so seeding may proceed and those rows will carry createdByAppKey=terra.
-
LEG B RE-MEASURED LIVE 2026-10-04 (SELECT-only on eprpywqwzsvrjeaforei) AND THE BLOCKER IS NARROWER THAN THIS ROW SAYS. MEASURED. pacientes carries exactly two creation columns, createdAt and createdByAppKey - there is NO createdByUserId. resolveOrCreatePacienteForApp still holds a single 10-arg signature (appKeyArg, documentoTypeArg, documentoNumberArg, nombreArg, apellidoArg, emailArg, telefonoArg, fechaNacimientoArg date, isSyntheticArg, syntheticSessionIdArg) - no actor argument, unchanged since leg A. appEvents holds ZERO rows for action=createPaciente, ever; the only paciente-related rows on the whole table are 17 pacienteLookup, all appKey=terra, 2026-08-21 to 2026-10-01. pacientes holds 9 rows with createdByAppKey=terra and 6 null, the pre-cutover exemption working as designed. THE CORRECTION. This row has been blocked on TERRA-242 since 09-28 on the stated ground that terra has no actor to pass. ENAMEL HAS ONE, and has had it since historia-clinica v1 shipped: app/src/app/(protected)/historia-clinica/actions.ts:320-364 resolves a session user, gates on ADMIN_OF_ENAMEL with user.id as its only parameter in a query of its own before the function is ever called, and already logs an appEvents row carrying actorUserId=user.id, resourceType=paciente and resourceId. So the APP LAYER attributes an enamel paciente creation today. What stays unattributable is the pacientes ROW, and that is one nullable argument away. TERRA-242 blocks TERRA supplying an actor; it does not block the parameter existing. Leg B is therefore NOT blocked for enamel and the additive-overload shape is the one that lets terra pass null until TERRA-242 lands. CAVEAT, because the count is zero: enamel has never created a paciente through the app, so that app-layer attribution path is written and completely unexercised. The zero is not evidence it works. ROUTED, not done by me. The signature change is a DB artifact and a new OVERLOAD, not a modification - the 10-arg form survives its own fix with EXECUTE still granted to postgres/service_role, so the migration must revoke or drop it in the same transaction or the ungated path stays reachable (see feedback_signature_change_overload_trap). Filed as a child row on db-enamel-ca. The app caller cannot move first: an 11th argument against a 10-arg function is 42883, so the migration lands first and my caller edit follows it in its own push.
-
LEG B CLOSED 2026-10-04, BOTH HALVES. DB half: enamel_074 applied by db-enamel-ca as schema_migrations version 20261004201525, on the exact artifact I gave the GO for (md5 b9ff7b2ddbf00caaf3e458570d6022e1, blob ad6a3877fd7f04c6068ca17dce3ea761fd452b96, HEAD 6fcdc27, audit-general-ca source-only PASS). App half: 2c6b091 (caller) + 313436c (test control), app tree, both pushed to origin/main. VERIFIED LIVE MYSELF, SELECT-only on eprpywqwzsvrjeaforei, not taken from the apply report. pacientes.createdByUserId uuid nullable=YES exists with pacientes_createdByUserId_fkey FOREIGN KEY (createdByUserId) REFERENCES users(id). pg_proc holds exactly ONE resolveOrCreatePacienteForApp signature, the 11-arg (text,text,text,text,text,text,text,date,boolean,text,uuid), secdef=false, owner=postgres, acl={postgres=X,service_role=X} - the 10-arg form is gone, which is the overload trap this row was routed to avoid. 15 pacientes rows, 0 with createdByUserId, as expected for a column added today. TERRA GATE CLOSED BY MEASUREMENT, NOT A ROUND-TRIP. pg_proc is the only layer holding a caller of the resolver: exactly one, public.createTerraPractica (prokind=f, SECURITY INVOKER, owner postgres); no view references the resolver and no non-internal trigger exists on pacientes. Its body passes TEN positional arguments, last 'syntheticSessionIdArg'::text, and the new parameter is declared actorUserIdArg uuid default null, so plpgsql resolves that call to the 11-arg form with createdByUserId NULL. The DROP of the 10-arg signature therefore needed no terra deploy sequencing. CONTROL FORCED RED, not accepted green. With user.id and the $11 placeholder removed from the caller, the new test fails at historia-clinica.test.ts:300 on the SQL text; restored, 32/32 and the full suite 275/275. It asserts params.length===11 and the $11 placeholder, not just the actor value, because the argument is nullable for terra's sake - a caller that silently stopped passing an actor would keep succeeding and keep writing unattributed rows, which a value-only assertion cannot catch. WHAT IS NOT PROVEN. withUser=0: no paciente has ever been created through enamel's app, so the end-to-end path from the form to a populated createdByUserId is written, type-checked, unit-tested and completely unexercised against the live function. The column staying NULL on a matched row is correct by design (the function writes it on the CREATE arms only), so a future NULL is not by itself evidence of a regression - the test control is what guards that, not the data. NOT IN SCOPE OF THIS ROW AND STILL OPEN: terra passing a real actor, which is TERRA-242.
-
completed