Changelog
ubep.azure 0.17.0
-
A bench that is switched off no longer keeps the observer’s alarm on unreachable instances red. Benches are instances kept for trials, off by default and switched on when needed. Listed among the instances with the module, they are unreachable on most runs, and the alarm on unreachable instances fires on
irraggiungibili > 0: it would stay red for good, and an alarm that is always red is an alarm nobody reads.An entry of the inventory’s
con_modulomay now carry"banco": true, and the observer’s run record gains two fields.irraggiungibili_produzionecounts the unreachable instances not marked as benches, and is the field the alarm reads;irraggiungibili_nominames every unreachable instance, benches included, as an array even when it holds one.irraggiungibiliis unchanged. A missing flag, or any value other thantrue, means production: an instance leaves the alarm only when somebody marked it.The mark takes a bench out of that alarm and out of nothing else. When every instance with the module is a bench and all are off, the run reads nothing:
letture_riusciteis zero andtutti_collaudatiis false, so the alarms on absence and on the gate fire and stay red, as measured on 2026-08-20. That is the observer reporting that it sees no instance, and the steady red goes only when a production instance with the module is listed and answers.The order of the release is the one every new column asks for, because the ingestion API drops in silence a column the collection rule does not know: the two columns go into the observer’s table and its collection rule first, then the package and the runner copy under
/opt, and last, in the same window and once a record has broughtirraggiungibili_produzionewith a value, the alarm rule and the rule on incomplete records. The latter names the columns it requires, and must gain this one: moved onto it, the alarm reads a field no other rule guards, and should it arrive empty after a rollback or a collection rule restored from its earlier definition, a production instance switched off would fire nothing. No dictionary change and no module change.
ubep.azure 0.16.0
-
The scope gate no longer takes the requester’s name from the row. The gate decides whether whoever filed a request may manage the project they filed it for, and it built that question out of
requested_by— a field filled in by the@USERNAMEaction tag. An action tag governs the form REDCap draws, and an import draws no form: measured on 2026-09-02 on a live instance, the Data Import Tool writes that field verbatim. Anyone holding the import right could therefore name a colleague who manages the project and have the gate check that colleague’s rights.The round now asks REDCap’s event log who was authenticated when each row was created, and the gate judges that name. It is a fact REDCap establishes rather than a value anybody types, so no import can move it. The notifications still address the register’s own
requested_by: the two agree on every row filed from the form, and the import right that would make them disagree is not granted to anybody. The creation events are asked for withlogtype, which is REDCap’s own classification of the event, and the prose ofactionis read only as a guard on top of it. REDCap ignores a parameter it does not know rather than refusing it, so a filter REDCap silently dropped would answer with a complete log that looks valid — and the newest event for a row is whoever last edited it, not whoever opened it. The guard fails closed: no match, no creator, and the row is held rather than judged against the wrong person.One call for the whole round,
logtype = "record_add"and no time window: a record is created once, so the answer holds one row per record that ever existed rather than one per event, and it is asked only when a row is still standing to be judged.A row whose author the log cannot name is held, not refused, under the new
TRASPORTO_AUTORE_NON_LEGGIBILE. Telling the referent they are not authorized would send the one person who cannot fix it to go and argue — the same reasonTRASPORTO_PERMESSO_NON_LEGGIBILEexists.No dictionary change: this release is an install and not an import. It does require the register’s service account to hold REDCap’s Logging privilege, which the row-protection release already needed.
ubep.azure 0.15.0
-
An approved row now says so, and keeps saying so.
seal_statehad three values in its vocabulary and the round wrote two: a row cleared by an approval was applied and sealedintact, indistinguishable from one nobody had ever contested. The register forgot, one round after the protection fired, and REDCap’s event log was left as the only witness.The round now writes
approvedon the row it applies because an approval cleared it, and carries that value forward while the seal holds. It gives way tomodifiedif somebody edits the row again, and toapprovedagain once that edit is cleared in its turn.intacttherefore keeps its own meaning: nobody has ever contested this row.No dictionary change —
approvedwas already among the field’s choices — so this release is an install and not an import.
ubep.azure 0.14.0
-
A row edited after it was applied is no longer applied again. REDCap has no per-row ownership — rights are per instrument, and the only thing that partitions rows are data access groups, which this register does not have on purpose — so any referent can edit any other referent’s row. Until now the round would carry that edit out, on the authority of a pass that ran before the edit existed.
The round now seals what it applied, compares the seal on the next pass, and stops the row where a withdrawn one stops: before the desired state is built, so no instance is ever asked about it. The outcome vocabulary grows a sixth word,
held, which is neither a state nor a fault — nothing went wrong, and the row is waiting for a person. -
A change is cleared by somebody other than whoever made it. The approval carries the seal it approves, so it covers that change and no later one, and the round reads REDCap’s event log to check the two are different people. The log names whoever was authenticated, which is not a value anybody can type — the same reason
requested_bycannot be trusted on its own once the Data Import Tool is granted.A log that cannot be read leaves the row held. “I could not ask who did this” is not “somebody else approved it”, and of the two ways to be wrong that is the one that does not grant.
-
Three fields join the register’s dictionary:
applied_seal,seal_state,approved_seal. They stay hidden until a row has been applied, since a fresh request has nothing to protect.The collection rule has to learn
esiti_heldbefore this release lands, and the dictionary import and the package installation are a single act: this version also addsheldto the choices ofoutcome, andDIZIONARIO_SCELTE_DIVERSEstops the round in both directions — unlike a field, which stops it only when missing. The round is meant to sit onDIZIONARIO_DERIVATObetween the two gestures.
ubep.azure 0.13.0
-
The round’s record says whether the post was switched on. Every mail counter is zero both when there was nothing to send and when sending was off, and
posta_dirottatais false in both cases too — so the two rounds produced the same record and the telemetry could not tell a quiet night from a mute channel. The newpostafield is the twin ofscrittura, and it matters more than that one: under “send first, write after” the switch it reports gates every write into the register.Neither field has a fallback, and for the same reason: a caller that forgot to pass one would report the switch as off, which is the single reading the field exists to make impossible.
ubep.azure 0.12.1
-
The outcome message names the identity the round settled, instead of saying it is not established yet. A referent files a row with the UPN blank, as the work instruction asks; the round resolves it and writes it through the identity door before the message leaves. The message, though, was composed from the register as read — which is deliberately frozen, since it is what the round measures change against — so the first notice about any new request told the referent less than the register beside it already carried.
Repaired by telling
mail_round()what the round settled, rather than by widening the frame that gets imported: that frame is the other write door, and the two stay separate.
ubep.azure 0.12.0
The round sends the post itself, in Italian and in English. Three messages: the outcome of a request to whoever filed it, first-access instructions to the person the request is about, and the way into a newly created account to the referent who asked for it. It replaces the REDCap alerts, which deliver once per record and cannot be made to do otherwise — the trigger that sees writes arriving through the API is the one that has no “every time” to offer. A row whose outcome changed twice therefore warned once, and the last word a referent was left with could be the wrong one.
-
A row enters the register only once its message has left. The round sends first and writes after, so a message that did not go out leaves the row as the register has it — the next round finds it changed again and retries. The queue is the register itself: no second state, no file, nothing to keep aligned. What this buys is that a notification cannot be lost; what it costs is that one can arrive twice, and that trade was chosen deliberately.
The message about a new account’s access is the exception, and it says so. The account is already created and the round does not create it twice, so a failure there is not retried: it is reported under its own code, because nobody but a person can repair it.
UBEP_POSTAandUBEP_POSTA_Agovern the post. Without the first the round behaves exactly as it did in 0.11.0 — it computes, it writes, it sends nothing — so installing this version changes no behavior on its own. The second replaces every recipient with one address so a field test can run on real data, and the round record reports that it is on.A refused message says why, on stderr. The outcome keeps the closed vocabulary the telemetry counts —
TRASPORTO_POSTA_RIFIUTATA— and the service’s own answer now goes to the journal beside the HTTP status. Neither half carries the diagnosis alone: on 2026-08-27 a single condition returned 403 from one endpoint and 401 from another, both saying the requesting address was not on the allowlist, and the status by itself would have sent the reader looking for a network fault.The round record carries five new columns:
posta_partite,posta_fallite,credenziali_recapitate,credenziali_perseandposta_dirottata. The collection rule has to learn them before this version ships, or they arrive empty and any alarm watching them is watching nothing.-
A duplicated pair no longer takes the older row’s access out of reach.
register_to_desired()marked every row of a repeated pair, and a marked row never reaches the plan — so a revocation written on the row that had granted the access was not carried out. The access stayed on the instance, its mandate readdata_error, and nothing anybody could write in the register would remove it. That is a right standing with its mandate marked invalid, which is the failure this register exists to prevent, arriving from the side nobody was watching.The oldest row keeps its mandate, and only the rows filed after it carry
DATO_COPPIA_DUPLICATA. The two were never equals: the older one may already have been served, and the newer is the one that did what the work instruction says not to do, which is to file a second request instead of changing the first. What looked like refusing to guess was picking the outcome that leaves no way out — the referent could not delete the row, could not revoke it, and could not change the three fields the key is made of, because the round rewrites the username at every pass.Age is the
record_id, which REDCap auto-numbers, and not the order the rows arrive in. The API promises no order, and a register read newest-first would otherwise reverse who keeps the mandate. Non-numeric ids still get a definite order rather than an accidental one, and"10"sorts after"9".Putting
request_statusin the key would have been the smaller change and is unsafe, which is worth recording because it is the obvious one to reach for. It would stop anactiveand arevokedrow for one pair from being duplicates, and then both enter the plan:provisioning_reconcile()builds its apply batches before its revoke batches and reads the real state before either, so the round would grant the pair and remove it in the same pass. And it would be silent —round_changed()compares outcome, detail andapplied_as, all three identical from one round to the next — so the instance would be written to twice every four hours with nothing in the register to show for it. A test now states the invariant that no pair lands in both lists.What it does not fix, stated because the next person will ask. Two
activerows disagreeing about the role still resolve to the older one, and whoever filed the newer must change the older instead. The alternative is a thirdrequest_statusthat retires a row without revoking the access it stands for, and that costs a dictionary import on the register project — worth it only if the case turns up. -
A request revoked before it was ever served no longer makes the person exist.
request_statusreached onlyregister_to_desired(), so the creation branch hung off the identity verdict alone and revoking a row that had not yet been served left the account to be born from it anyway. The two are not the same question — a revocation speaks about the access and the verdict about who somebody is — but nobody who revokes a request that has not been served expects an identity to come of it, and the only answer left was to tell people to delete the record instead, which contradicts what the form itself promises: absence is not a request.The row closes rather than waiting, under the word this round already uses for the same shape: a revocation of a right that is not there is settled as
applied, with the read-back sayingabsent. Asking for absence and finding it is a success, andpendingwould have left open for ever a row nobody is going to touch again. -
The round erases the username it wrote itself and keeps the one it did not. This is the writing half of the rule that stopped a resolved row from being read as a declaration of its own handwriting, and it hangs on the same discriminator, the previous verdict. Read in both directions it is a single sentence: an empty
identitymeans theusernamebeside it belongs to whoever filed the row, and a filled one means it belongs to the round.Why it must not empty a declaration. A row that an error of data stops carries an empty
identityby construction, so the earlier rule never spoke for it. The round emptied the username; the next pass read that emptiness back as a field the referent had left blank, took the other branch of the rule on internal contact addresses, filled the username in from the contact and found the account’s surname was another person’s. One row collected two different diagnoses without anybody touching it — and the field it was being asked to correct had been blanked, so the mistake was no longer in front of the person who made it.Why it must still empty its own. Emptying nothing at all is the wrong fix and the tests now say so: a row that was
existingcarries the canonical UPN, in the tenant’s domain, beside a contact address that ordinarily is not. Kept past the verdict that backed it, that value is read as a declaration on the next pass and the row closes against the referent for a word the round wrote. Both branches settle in one step. -
The round makes the accounts that are missing, under a permission that can do nothing else.
directory_create_user()is the second and last thing this package does on Microsoft Graph, andUser.Createis what it runs under: it cannot modify an account that already exists, reset a credential, disable or delete one. So the worst a wrong creation can do is leave an account nobody has used, with the request that caused it written in the register beside the name of whoever filed it.It does not compose the name it creates. The UPN is an argument, and the round takes it from the verdict that found it free. Composing it a second time on the way to Graph would be two paths to one answer: the day somebody hands a domain to the resolution and not to the creation, the round would check that one name is free and create another — a UPN nobody has checked for a collision, which is the failure this whole sub-project is about.
givenNameandsurnameare in the body because the criterion is the surname. An account created without a surname is one the next sweep cannot find, while the UPN it would compose is now taken — so the row comes backcollision, and the round would have created the person and then told them for ever that their name is another person’s.Neither
jobTitlenorofficeLocation. The authorization has one channel and it is the channel; the contact address goes in the field that means contact address. The old carrier is read for as long as accounts carry it — the sweep counts them, and the day the count is zero the fallback is dead — and written never again. A guard arms the not-inheriting, because the serialized authorization is a live mechanism and not a fossil: 2.828 accounts carry it, 254 of them created in 2026.The row it creates stays
absentfor that pass and becomescreatedat the next one. That is not a delay being tolerated:createdis defined as the row whose previous verdict wasabsentand that now matches, and the memory lives in the register. A round that wrotecreatedon the strength of having just made the account would keep a second source of truth beside the sweep.Making somebody exist is bounded by the same gate as granting them a right, and by a guard. An
absentrow is not a pair, so before this it stopped at the identity gate and never reached the instance that answers the scope question — the round was rearranged so that it does. An instance that did not answer has neither allowed nor refused, and the row waits. A simulated round creates nobody: a creation is not a write on an instance, so nothing else in the round would have stopped it.One credential per person, drawn from
openssland not fromsample(). There is no batch to be the password of, which is the property the historical flow lacked — one password at the top of a generated.ps1, shared by fifteen people, in a file that outlived the act. The generator matters because the account is created enabled and with no second factor enrolled: what a guess buys is the enrollment of another person’s, which is the tenant’s whole defense. R seeds its own generator from the clock and the process id and this round runs on a timer at six known times a day, which is a search space rather than a secret. The credential leaves by the return value and by no other road — not the register, not the telemetry record, not standard output — and a guard forbids the runner from naming it at all. -
The round resolves who a row is talking about before deciding what to do with it.
provisioning_reconcile()gains a step between reading the register and building the desired state: it sweeps the tenant whole, resolves every row against it, writesusernameandidentityback through the door that takes nothing else, and hands on to the desired state only what the gate admits. The order of the questions is the design, and the function’s own documentation carries the new one.The gate has no window. It asks after the verdict the round has just computed, never after the one the register carries: a round that read the stored value would be looking at something up to four hours old, and after a failed sweep at something nothing had confirmed this pass at all.
A sweep that failed is a transport error on every row, and no instance is interrogated. “I could not ask” is not “you are not the one”, so the rows go back in the queue instead of closing against whoever filed them — and nothing is acted on, because acting would mean granting under a verdict nobody produced this round.
What the gate stops, and what the register then says.
ambiguousandcollisionclose the row as data errors, soA-13mails the referent, under codes that name what they can correct rather than what the round observed:DATO_RECAPITO_NON_IDENTIFICA— give a contact address that picks out this person and nobody else — andDATO_IDENTITA_IN_COLLISIONE, whose proposal travels beside it inusername.absentstayspending, because the round will create the account itself and mailing somebody about a row nobody has to touch is how an alert stops being read.Guard A ships in the same commit: no function that can write on an instance may do so without naming the gate on the identity, the twin of the rule that has guarded the gate on scope since 0.10.0. Both now read one list of what counts as a write, so a fourth write path cannot be added to one rule and missed by the other.
round_changed()takes afieldsargument so the same “only what changed” filter serves both families of field rather than being copied, and the Graph token and endpoint are arguments of the round with no defaults — this repository is public, and an endpoint written in is an endpoint published. -
The resolution no longer mistakes its own handwriting for a declaration.
resolve_identity()treated the register’susernameas a claim by whoever filed the row, every pass. That is right until the round has answered, and wrong from then on: the two fields are never written apart, so a row carrying a verdict carries a username this package wrote. Read as a declaration it is the very shape decision 12 refuses — a UPN in the tenant’s domain beside a contact address that ordinarily is not — so a resolved row oscillated with period two:existing, then closed against the referent withDATO_RECAPITO_INTERNO_DIVERGENTE, thenexistingagain, mailing them about it every other round.identityis what tells the two apart, and it can, because nothing writes a username without writing it. Nothing is lost: the declared UPN is confirmed on the first resolution, which is the only pass that has a declaration to confirm, and re-deriving the account every pass afterwards is also what makes a renamed login surface as a changed verdict instead of asDATO_UTENTE_DICHIARATO_DIVERSOblamed on the person who filed the row.Invisible to the pure layer’s own tests, which hand the resolution rows written by hand: the loop only closes when the round writes back and reads again.
-
The resolved identity has a body of its own, and a door that takes nothing else.
identity_payload()fixes the three columns —record_id,username,identity— andregister_identity_import()refuses at the threshold anything that is not exactly those, the wayregister_import()already refuses anything that is not the five outcome fields. The register holds three families of field: what a person asked for, what the round resolved about who they mean, and what happened. No body may carry two of them, and neither door accepts the other’s, so the separation is structural rather than a promise kept by whoever assembles the body.Neither field is written without the other. A username written without the verdict that authorizes it is the state this sub-project exists to close: the channel decides by looking at whether
usernameis filled, and nothing has ever put a verdict beside it. The invariant that follows is conditional, and it is the condition the gate will check — a username is authoritative if and only ifidentityisexistingorcreated. It is conditional rather than absolute because a collision carries its proposal: there is a determined value to show, and it is the one a person has to act on, so keeping it out of the register would leave it living only inside an e-mail.overwriteBehaviorisoverwrite, and the reason is not the one it is right for the outcomes. The body carries the totality of what the round owns on this axis, so overwriting can only blank the round’s own two fields — and the blanking is the point: a row that wasexistingand becomesambiguous, which is the renamed-login case, has to lose the username that became false rather than keep it beside a verdict that no longer supports it. The empty verdict is writable for the same reason. It is not a missing value: it is “not resolved”, which is what a row stopped by a data error carries, and a row that stops has to shed the username it earned back when it still resolved.The transport the two writers share now has one copy rather than two.
register_field_import()holds the mechanics — the JSON body, the overwrite, the check that REDCap took every row it was sent — while each writer fixes its own column list and its own refusal. What keeps the families apart is the door, not the call. -
The package can say who a register row is talking about.
resolve_identity()is pure — a function of the row and of the swept directory, in the same shape asprovisioning_diff()andscope_errors()— and it asks two questions, not one.ambiguousis read off the number of matches on the identity criterion;collisionoff the composed UPN being held by somebody else. A resolver asking one question could not tellcollisionfromabsent, and would either create a duplicate or take a uniqueness refusal from Entra and report it as a transport error, that is, as something that will pass by itself next round. It will not. Said in one line, because that is how the two stop being confused:ambiguousis too many people with one identity,collisionis two people with one name.The surname is the criterion and the contact address confirms it. An account carrying the surname — and the given name too, when the account carries one — is a candidate for being this person; whether it is the one is settled by the contact address. Read the other way round, the resolution would call an ambiguity what is merely a shared address: 99 addresses in the tenant sit on more than one account, 221 accounts involved, and a mailbox shared by a lab says nothing about who anybody is. It would also miss the failure that matters most — a person who exists under a UPN this package would not have composed, whose contact address has since changed, read as “nobody matched” and given a second account.
So one homonym is enough to stop the row. An account with this surname whose contact address is not the one given is
ambiguous: nothing here tells “the same person, with an address we did not know” from “somebody else with the same name”, and the second reading is the one that creates a duplicate. The addresses are read fromofficeLocation, where the historical flow put them and where 6.494 accounts still carry them, fromotherMails, and from the UPN, because an address in the tenant’s domain is a UPN. Both sides are normalized: 74 accounts carry the address with spaces at the edges, and an exact comparison would reportabsentfor people who exist. Guests are excluded by invariant, not by measurement.collisiontherefore means something narrower than it looks. A homonym is caught by the surname rule and never reaches it, so reaching it means the UPN this package would compose is held by an account that does not carry that surname — odd data in the tenant rather than two people with one name. The numeric-suffix proposal stands, because a free UPN is still needed.Two things stop a row that matched, and the prefix on each is a delivery address rather than a label. An account whose
userTypecannot be read, and a contact address sitting on an account with no surname at all, are ours: neither is something the filer could fix, and telling the one person who cannot reach a directory attribute that they are not authorized sends them to go and argue. The unclassified account still matches, on purpose — dropped instead, it would become “nobody matched” and earn its person a second account.The declared username is confirmed as an identity and not as a string. An alias, an
@unipd.itaddress that is the same institution but not the tenant’s domain, a shared mailbox: divergence between the UPN and an address the same person writes from is the ordinary case, and a string comparison would refuse it. When the account carries the declared value among its own identifiers the canonical UPN replaces it; when it does not, the row holds two incompatible claims and goes back to whoever filed it.The internal-address rule holds in both directions, and its third branch is the counter-intuitive one: an address in the domain that matches nobody is a typo rather than an absence, so the creation branch does not open — creating it would fabricate the account the typo describes. It is the one place
absentis suppressed, and it is asked before the collision so that the diagnosis names the typo instead of a homonym who has nothing to do with it. The symmetric case stays ordinary: an external address matching nobody isabsent, and somebody from outside is a row to create rather than one to refuse.On a collision the round proposes rather than decides:
nome.cognome.2,.3, the first free one. Without a proposal the row hands a person two questions and no material; with it exactly one is left, and it is the one only the referent can close. -
The package can read the tenant’s directory, and it reads all of it.
directory_users()is the only function that speaks to Microsoft Graph, and it does one thing: it sweeps. It does not filter, and that is a measurement rather than a preference —officeLocation, where the historical flow put the contact address and where 6.494 accounts still carry it, is not filterable server-side at all, andmail, which would be, isnullon 6.412 accounts out of 7.664. The obvious attribute is empty on exactly the population to be found. Reading in full costs 8 pages and 3,5 seconds measured on 2026-08-15, less than the round that will consume it, and it yields decision 4 of the channel’s design by construction rather than by discipline: no local copy of the state, the real one re-read every round. It also buys a property a server-side filter could not have given — the comparison is ours, so it can normalize, and it has to, because 74 accounts carry the address with spaces at the edges and aneqwould have missed them in silence.It answers in the shape the other adapters answer in —
ok,errors,payload— plususers, the frame, and the shape is what keeps “I could not ask” apart from “nobody is there”: the first isok = FALSEwith no frame at all, the second is a clean read of zero rows. A failure at any page returns no rows whatsoever. A half-read directory is not a small directory: every account on a page that did not arrive is missing, a missing account resolves toabsent, andabsentis the verdict that opens the creation branch — so partial rows escaping would turn a transport failure into duplicate people.Two bounds beyond what the plan asked for, both on the continuation Graph hands back. The sweep stops after 100 pages rather than following a continuation that never ends: the measured shape is 8 pages, and the failure avoided is the one this project can least afford, a round that never returns and so leaves no record, with the severity-1 alarm reporting that the channel is not running while it runs very hard. And a continuation is followed only when it is https on the host the caller named, compared on the parsed host rather than on a prefix, because
https://graph.example.org@elsewhere.test/starts with the right text and resolves to the wrong machine. Following it blindly would hand an application credential to whatever host the other end names.The token travels as a redacted bearer header, so it is a weak reference inside the request object and cannot be spilled by printing it while diagnosing a round; it never reaches a URL, on the first page or on any continuation; and it is scrubbed out of a refusal’s message before that message is kept. Graph does not echo it today, and relying on that would be trusting the other end to keep our credential. The refusal keeps Graph’s
codeas well as its prose, becauseAuthorization_RequestDeniedandRequest_UnsupportedQuerysend whoever reads them to different places — the consent and the query — and a 403 on every row is precisely the shape a missing consent takes. -
The
identityfield gains a fifth choice,absent, and the register’s packaged dictionary changes with it. The four approved on 2026-08-07 are not exhaustive over what the identity resolution can observe: they assume resolving a person and creating them are a single act, so “nobody matches and the composed UPN is free” becomescreatedat once. It does not — a creation can fail, and a person can be looking at the row in between — and a state that lasts needs a name.absentis a verdict rather than an instruction, the wayoutcomereports what happened rather than what to do.identity_vocabulary()holds the five words, next tooutcome_vocabulary()and for the same reason: more than one reader needs them, and a second copy drifts. A test binds every field whose vocabulary this package owns to the choices the packaged dictionary offers, in order — order included, becausecompare_dictionary()matches the whole choices string, so a reordering reads as drift against the live project. Without that binding, a word added on one side only is silent in both directions: one the package never emits, the other one the project refuses to store.This changes a schema, so it stops the channel until the live project is re-imported, in either order:
DIZIONARIO_SCELTE_DIVERSEblocks a round, and the comparison is symmetric. The halt is safe — it happens before the register is read, writes nothing, and raises a severity-1 alarm — but it is not silent, and the two acts belong to one maintenance window. The channel’s round leaves a record, and it goes in a table of its own. A round left no trace but the outcomes it wrote into the register, and those say nothing about the round itself.
round_record()builds that trace anddev/runner-canale.Remits it. It carriesregistro_letto— the channel’s counterpart of the observer’sletture_riuscite— because an alarm firing on the absence of a record would be satisfied by a round that started, stopped on a drifted dictionary and exited; and because an empty register and an unreachable one both giverighe = 0, while only one of them needs somebody. One counter per word of the closed outcome vocabulary rather than a single total, since a night of data errors and a night of transport errors go to different people. A second table rather than a column in the observer’s: the observer’s alarm rules read its table without asking who wrote the record, and five of the seven read whichever record is the most recent — so a channel record in there would have made one of them a standing red and three of them silently green, masking the observer’s findings instead of reporting them.The module resolves the names a write would need on every run, simulated or not. A role or a DAG that does not exist was refused only by a real write, because the resolutions lived inside the branch a dry run skips — so a simulation answered
creatofor a role that cannot exist and echoed it back inafter. Measured on the field on 2026-08-14. Both resolutions are reads, so a dry run can afford them, and a rehearsal that cannot refuse what the performance refuses cannot warn about anything — which is the whole purpose of the phase in which the channel only simulates. The existence of a user remains unchecked, there and everywhere: REDCap accepts rights for a login that does not exist, which is how orphan rows are born.
ubep.azure 0.11.0
-
The channel reads the request register with the token API and writes back only the outcome.
provisioning_reconcile()is the one function that holds the register and the instances together, and the only place a write can start from. Aproject_idthe register accepted but that is not a number is reportedDATO_PROGETTO_INESISTENTEbefore the scope gate ever sees the row — such a row may not be a pair yet, so nothing upstream validated it, and left to the gate it would come back “you may not ask for that project” when the truth is “that is not a project number”. -
The gate on scope is wired in. A request is applicable only if whoever filed it holds REDCap’s
user_rightspermission on the project it names, and a guard refuses any package function that can write on an instance without naming the gate — with one declared exemption, the conformance tool, which does not read the register and has no requester to gate on. An instance that did not answer returns its own transport code — whatevermodule_state()reports, orTRASPORTO_ISTANZA_SENZA_MODULO/TRASPORTO_SEGRETO_NON_LEGGIBILEwhen it was never even asked; one that answered but whose module is too old to carry the permission on any row returnsTRASPORTO_AMBITO_NON_LEGGIBILEinstead. Either way only the rows already resolved into a pair go back to the queue, never a declaration of out of scope. The same rule holds one granularity down: a single row whose own permission cannot be resolved — a role deleted underneath it, on an instance that otherwise answers — returnsTRASPORTO_PERMESSO_NON_LEGGIBILEand queues. It is refused either way, because the alternative would be to widen access exactly when the channel has stopped being able to see it; what the code decides is who is told. Whoever filed that request filed it correctly and may well hold the permission — what is broken sits in REDCap’s rights table, which IT can reach and they cannot. -
appliedis a claim about evidence, not about the absence of an error. A real write is followed by a read-back, and the outcome isappliedonly if the re-read confirms it — the pair present after an apply, absent after a revoke; otherwise the row is reportedTRASPORTO_SCRITTURA_NON_CONFERMATAand returns to the queue. Confirmation is by presence and not by field-by-field equality, deliberately: REDCap normalizes what it stores, and a rule demanding an exact match could make a row impossible to confirm and retried for ever. What the instance actually shows travels inapplied_as, and the next round’s diff is where a real disagreement gets acted on. - Writes on the instances are grouped by
(server, project_id): the module refuses a write that touches more than one project, and the refusal arrives before touching anything. - The dictionary comparison finally has a caller: the round stops before writing if the register’s schema has drifted in a way that changes what it reads.
ubep.azure 0.10.0
-
A request is bounded by what the person who filed it could already do by hand. The channel applies without anyone reading, so the gate on who may ask — rights on the request register — said nothing about what they may ask for: any authorized requester could name any project on any instance.
scope_errors()refuses a row whose requester does not hold REDCap’suser_rightspermission on the project the row names, withDATO_AMBITO_NON_AUTORIZZATO, andscope_pairs()says which pairs the instances have to be asked about. The rule automates a power instead of adding one: holding that permission is exactly “could have granted this access themselves”. - The gate refuses whenever it could not read, and never grants. A module too old to report the permission, a role deleted underneath a row, a value that read as empty, a row nobody can be attributed with: none of these is a permission. The one that decides the shape is the first — a missing column refuses every row loudly, rather than granting them all quietly.
-
StateReaderreports the permission that governs, role first. A user with a role inherits the role’s permissions and the columns on their own row do not govern; a user without a role carries their own. Reading one table only returns the wrong answer for everybody in the other half, silently — the same trap the DAG read already documents. The resolution is a pure method so it is tested without a REDCap instance, and it reports the raw REDCap value rather than a verdict: what counts as “may manage users” is policy, and policy lives with the caller. - Fixture project identifiers moved to a reserved block, and a guard keeps them there. Two of the values in the fixtures were projects that exist. The guard states the rule on the class rather than on a list of forbidden values, because a guard naming them would have to write them into this public repository; it covers the three syntaxes the repository writes an identifier in, refuses to pass when it read nothing, and names the offending file rather than the value it found.
ubep.azure 0.9.1
-
The endpoint imports the class it calls.
api.phpdeclares no namespace, so theAllowlistadded in 0.9.0 resolved to the global one and every authorized request raised anErrorthat REDCap turned into its generic API reply — the channel answered 400 to a valid secret and nothing was logged. A new test readsapi.phpas tokens and refuses any class it names unqualified that does not resolve once the module is loaded, which is the failurephp -lcannot see. Anyone running 0.9.0 must move to this release: the module directory of 0.9.0 serves no request that authenticates. -
The module’s
VERSIONtracks the package again. It had stayed at 0.7.0 through two releases, somodule_versionin every reply named a version the code was not.
ubep.azure 0.9.0
-
The job runs from a dedicated machine, and observes without comparing.
observe_instance()turns one instance’sstatereply into a row — version, major, gate, surface fingerprint, reachability, and the pairs whose expiration has already passed — andrun_record()builds the record a run leaves behind. Neither touches the diff: with no register every real pair would be classified as no longer wanted, so the danger is not the write call but the comparison feeding it, and the comparison is not computed at all. -
A record states its own scope.
flotta_a_una_majoris true only when every instance was attempted and answered. The clause that retires a compatibility branch fires on that flag, so a value computed over whichever instances happened to answer would retire a branch still in use: a flag that authorizes a destructive decision is false when it cannot know. - The module reports the fingerprint of its own IP allow list, never its contents. The caller keeps no expected value and alarms on the change between two runs, which makes a hand on any instance’s perimeter an observed event and avoids storing a digest small enough to enumerate. Parsing lives in one place and the address check uses it, so the reported fingerprint and the enforced list cannot drift apart.
-
contract_versionbecomes 3. Reads tolerate every version; writes tolerate 2 and everything after it, rather than 2 alone — pinning to one version would refuse an instance the moment its module is updated.
ubep.azure 0.8.0
-
The request register gains a schema.
inst/extdata/request-register-dictionary.csvis the data dictionary of the REDCap project that carries the desired state, and the pure layer can now turn that register into the lists the diff already consumes.register_to_desired()splits requested from revoked, refuses a pair that appears twice rather than guessing which row wins, and leaves a row whose identity is unresolved out of the desired state without calling it an error. -
A pair that exists in REDCap and appears nowhere in the register means nothing. Removing an access takes an explicit request, never an absence. The diff has always classified an unrequested pair as
revocato, which with an empty register would name every right in the fleet: the register declares requests, so it never produces a revocation nobody asked for. - The register’s field names are the ones the pure layer already speaks, so no map exists between the form and the request, and no key can fall into a default without raising. Every coded field uses its label as its own code, and a test on the dictionary keeps it that way.
-
outcome_payload()builds the body that writes an outcome back, with the columns fixed in the function rather than assembled by the caller. What a person asked for is intent and what happened is observation; a transport error records itself without taking the row out of the desired state.
ubep.azure 0.7.0
-
Writing is no longer confined to designated test projects. The
test-project-idssetting is gone from the module manifest. What removed it is not a decision to trust the channel but a recorded fact: a conformance run passed against the surface the instance actually runs, re-reading every field the channel writes and comparing it to what was asserted. A test in this package fails if that date is ever present while the manifest still declares the confinement, so the two cannot drift apart. - What still restrains a write: the module refuses anything below its version floor, simulates above its tested ceiling, simulates when the caller’s declared surfaces do not include its own, refuses a write spanning more than one project, and writes at all only on an explicit
dry_run: false. Nothing restrains it per project, by design — a permanent project allow list would cost one configuration per server, growing with the projects, which is the friction this channel exists to stop paying.
ubep.azure 0.6.0
- Writing now requires the caller to declare which REDCap surfaces it has been tested against. The module compares its own runtime fingerprint against that declaration and simulates the write when it is not among them, on the same branch that already forces a simulation above the tested ceiling. The previous release computed the fingerprint, reported it, and let every write through regardless — the check ran client side, on the answer, after the module had written.
- The endpoint contract moves to version 2. A write is refused against a module still on contract 1, which cannot enforce the declaration; reads accept either, so a partial deployment does not blind the fleet audit on the instances still to be upgraded.
- A passed conformance run is now recorded against the pair (major, fingerprint) rather than the major alone. A major can hold more than one surface — an upgrade inside it changes the fingerprint and leaves the major alone — and the previous behavior stamped every row sharing the major.
- Writing is still confined to the projects listed in the module’s
test-project-idssetting. That confinement is removed in the next release, and only once a conformance run has been recorded against the surface the instance actually runs.
ubep.azure 0.5.0
- The REDCap authorization channel gained a write path: it can now assert project rights (role, DAG, expiration) on a real instance, and revoke them. Every operation simulates by default; the instance is touched only on an explicit
dry_run: false. - Added a conformance check that certifies a channel against an instance rather than trusting a write’s own report of success: it applies, re-reads what was written, and compares it field by field against what was asserted, before revoking and re-reading again to confirm nothing was left behind. A passed run is recorded by REDCap major.
-
Writing is confined to the projects listed in the module’s
test-project-idssetting, and an unconfigured or empty list denies every write. No wildcard exists to lift this from the REDCap side. The confinement is removed only once a conformance run has been recorded against the instance’s REDCap major — which has not happened yet for any instance. Anyone installing this version expecting to provision production projects will find writes refused, not simulated: this is deliberate, not a bug.
ubep.azure 0.4.0
- Added the REDCap authorization channel, read only in this release. A REDCap External Module lives in
inst/redcap-module/and answers with the real state of project rights, the instance version, the version gate and a fingerprint of the REDCap surface it depends on. No write path exists yet. - Added
build_redcap_module(), the only exported addition: it packs the module into the archive REDCap expects. The module and this package share a single version number, so the two ends of the endpoint contract cannot drift. - The client reads a response by its shape rather than by its status code. A module that is installed but disabled answers HTTP 200 with plain text, so inferring success from the status fails inside the JSON parser with an error that does not name the cause.
- The surface fingerprint downgrades an instance to untested when it does not match a measured one, even if the version says otherwise. An instance can be in the right major and have a changed surface, which no version comparison can see.
- Requires R (>= 4.4).
ubep.azure 0.3.0
- Fixed
logImportUsers.csv: each row now records one user with an intact UPN-to-person mapping (previously every row repeated the whole batch and the column count varied with batch size). -
build_ps1_from_xlsx()now stops on rows with a missingNome/Cognomeinstead of silently emitting aNA.NA@...account. - Input is now restricted to Excel files; the (broken) CSV input path was removed.
- The generation no longer leaves a dangling
sink()if an error occurs mid-run (which used to mute the R session). - The initial password is no longer the hardcoded
P@ssw0rd: a strong random password is generated per run by default, and can be set via the newpwdargument. Accounts are still forced to change it at first sign-in. - Added tests covering the generated Microsoft Graph scripts (previously the
New-MgUser/Remove-MgUseroutput had no automated coverage) and removed real-looking student e-mail addresses from the test fixtures.
ubep.azure 0.2.1
- Fix verbosity of
read_cvs - Considered
ubep.unipd.itthe default domain, pass it to functions explicitly is no more necessary. - Removed
here()from project detecting path systems; now the files are created in the correct folder depending on their location and not on the r script project running the procedures.