Skip to content

Release: merge development into beta - #3980

Open
github-actions[bot] wants to merge 338 commits into
betafrom
development
Open

github-actions[bot] wants to merge 338 commits into
betafrom
development

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

…ink exposes (#3937)

Rows 2.48 and 13.36. Row 5.16, the party relationship, is named as not
started rather than half-built.

The affected set is not a second walk: it filters the bounded graph's answer,
because two walkers would drift and the second would be the one nobody
bounded. The prune is an input AND an output, and it recomputes reachability
— dropping the edges while keeping the nodes would be a prune that reports a
cut and changes no result. Truncation is passed through, never recomputed.

Exposure narrows and never widens. A link is not a grant: handing over
whatever a schema author listed would turn every relation into an access
decision made by whoever wrote the schema. The design's two sentences only
look contradictory, and the reading is now written down — the intersection is
with the PROPERTY rules, so a link can carry a reader to a record they could
not otherwise open and can never show a field their own rules withhold.

A property outside the set reads as WITHHELD, not absent. An undeclared
exposes narrows nothing; a present-but-empty one exposes nothing, and those
are different statements.
…ule (#3938)

openregister#3934 found that MagicFacetHandler returned the distinct values
of governed columns to everybody who could list the register. That closed
one path. It did not answer the question the path raised: how many other
places turn a column into a summary, and do any of them ask.

Derived from source rather than from a remembered list, 18 paths match the
shape. Three live ones leaked and are fixed here.

AggregationRunner gates on list permission for the schema, which is a
different question from whether the caller may read the property being
summed. A SUM over a salary nobody may read is the salary total, and a
groupBy's keys are the distinct values of the column.

ViewPresentationService draws one kanban column per distinct value of
groupByField, so a governed grouping property becomes a row of headings
naming every value. The cards inside are stripped correctly by the render
path, which is what made it hard to notice: the board looks empty and
correct while its headings are the leak.

FacetHandler advertised facetable fields without asking. That is the first
half of the leak and the easier half to miss, because naming the field
invites a caller to ask for its buckets.

A withheld summary is absent rather than zero. Zero is an answer and a
wrong one: it says the value does not occur, and a reader cannot tell it
from a real zero.

Four facet handlers matching the shape are never instantiated. They are
asserted to stay dead rather than guarded, because guarding them would
raise the count of paths fixed while protecting nothing.
…ved it (#3939)

A calendarDays term that ended on a Sunday ended on a Sunday. rollToWorkingDay
is none, next or previous on the SLA shape, and an unknown value is refused
rather than read as none: on a deadline with legal effect a silent default is
the worst kind.

The roll answers what it did, not just where it landed. A handler looking at a
term that ends on Tuesday has to be able to read that Monday was Tweede
Paasdag; a date that moved with no explanation is one somebody will challenge
and nobody can defend. The name comes from the calendar's own rule. This code
knows one name, weekend, because it is the one rule it decides itself.

It is applied in recompute(), the single place fire_at is set, so arm, extend,
supersede, suspend and resume all get it and none of them can forget, and the
escalation ladder is measured against the rolled moment because that is the
deadline the term actually has.

TermDiagnostic stops refusing a roll. It refused deliberately: the engine had
none, and a diagnostic that applied one would have printed a moment the arm
path never produces, believed precisely because it is the diagnostic. It now
calls the engine's own roll rather than walking the calendar a second time.

Nothing is switched on. The default is none, no shipped calendar changed, and
the migration leaves every armed timer with the deadline it has: rolling
existing terms would move deadlines with legal effect, retroactively, without
anybody deciding to.
…eads (#3941)

flow_register.json declares scope: private on the flow schema. Flows live in
the native openregister_flows table, and MigrateRegisterFlowsToTable drained
that register into it because every subsystem reads the table and nothing
reads the register. So the control was declared on the store that had been
deliberately emptied, and reported as done.

A reader of that declaration would have concluded a colleague cannot run a
flow they do not own. What actually protected a run was flow.run, seeded
@authenticated, plus an organisation check — on a single-organisation
instance, any signed-in user running any flow. A fourth path the original
task did not name, FlowController::run(), asked for nothing else.

Reading through the drained store is refused: that store was emptied on
purpose, with measurements. So one resolver answers for all four paths, over
the store they actually read, and the declaration says what it governs.

Administrator, owner, or the narrowable flow.update right. An unowned flow is
refused to everyone, the administrator included, because the engine will not
dispatch one either and test() executes synchronously.

This does not change the default posture: flow.update is seeded
@authenticated too. What changes is that narrowing it now governs every run
path instead of one.

Task 9.2's untrue sentence is quoted and replaced rather than ticked.
…it (#3942)

The note written in #3941 referenced openregister#3936. A task file that
names the wrong PR is the same failure the note is about: somebody following
the reference lands somewhere that does not explain what they were told.
…e the save path enforces (#3943)

Builds the endpoint task 3.2 was blocked on, then closes 3.2.

GET /api/objects/{register}/{schema}/{id}/reference-options?property=<name>,
declared before objects#show because {id} matches [^/]+ and the generic
route would otherwise swallow the longer path and answer 404 for an
endpoint that exists. That ordering is asserted, because the symptom is a
404, which is exactly what a wrong URL looks like: an e2e written against
it would report endpoint missing and send somebody to the wrong file.

It calls the same resolve() the save path calls. A picker that offers one
set while the save path accepts another is two evaluators of one rule: the
user picks what the form offered and the server refuses it, or the form
offers something the server then accepts and should not have.

No options is not every option. An unresolved operand answers an empty list
and names the property it waits for, with a 200, because the request was
fine and the answer is not yet. Returning the unfiltered set would show
every contact in the register to somebody who had not yet chosen an
organisation.

_draft merges over the stored record, because the case a picker exists for
is a form being filled in. A limit of zero means the default rather than
LIMIT 0, which is an empty page with a 200 and no explanation, and a page
is capped so a picker cannot become a bulk export. The search runs with
RBAC on: a picker is not a way to see objects you may not see.

The e2e is written and tagged, not run: no Playwright runner on this host.
It asserts the status of every call, because an earlier spec in this change
guessed a URL and the 404 would have been skipped by the suite's own
old-build guard, reporting green while asserting nothing.
A citizen who edits the subject line loses the [ZAAK-...] tag the inbound
job matches on, and the reply lands nowhere. In-Reply-To and References
survive an edited subject, so those are read: the direct parent first, then
the ancestry walked from its last entry, which is the nearest ancestor.

Nothing is ever guessed. No fuzzy match, no prefix match, and the subject
tag is deliberately not read, because a wrong guess files one citizen's
reply on another citizen's case, where that case's handler reads it and the
only sign is a letter that makes no sense there.

A chain naming two objects resolves NEITHER and names both, since somebody
replying about one case while quoting a mail about another hands us both
and picking either is a coin flip with a disclosure on one side.

A reply with no usable reference is unthreaded, which is a named answer
rather than an absence: it is real, it arrived, and a person has to see
it. Only a threaded result may be filed without one.
…not stay silent (#3944)

Taken after checking both candidates: end-date-roll-on-the-calendar is not
free, build-orseams merged it as #3939.

This builds the vocabulary and the report and nothing that touches XML. They
are the two pieces everything else rests on and the two that can be got wrong
invisibly: a mapping each direction keeps its own copy of drifts until a file
stops round-tripping through its own product, and a report that loses
something quietly is the failure the import requirement names in its own
words.

The import table is NOT the export table flipped. switch and route both
export to an exclusive gateway, so a flip resolves the collision by array
order and turns every imported route into a switch. A test asserts the flip
and the declaration disagree, so nobody simplifies it later.

Three verdicts and no fourth. An entry with no element id is refused by the
report itself. An approximation counts as a loss, because it is the verdict
most likely to read as fine. strict fails on a refusal only. And no type is
ever guessed from a task's name: a flow that runs something because a box was
labelled "send email" is a flow nobody authorised.

The OMG XSD set is deliberately not vendored: it is a licence-checked
artefact that wants a person who can say yes to the licence.
…nto the spec (#3917)

The notification engine spec described preferences, templating and queueing,
but not the three rules the recent work landed: a channel an administrator
forces, a message that carries a send-at, and a reply threaded by its headers.

Each is stated with the decision a reviewer would otherwise have to
reconstruct from the code:

- forcing ADDS to the resolved preference rather than replacing it, sits as a
  layer above the user's own value in the existing resolution, and is not a
  second dispatcher
- a force with no reason is refused at save, and an internal kind aimed at an
  outside recipient returns a named refusal, because an empty channel list is
  what an unconfigured kind already produces
- a scheduled message is claimed by comparing state AND attempt count, so two
  sweeps cannot both send one letter bearing one reference number
- a reply threads only on an exact Message-ID match; a chain naming two
  objects threads onto neither, and a reply matching nothing is named
  unthreaded rather than guessed onto a case

The requirements sit inside the ## Requirements section: appended after it
they parse as invisible to validate, list and archive.
…ther than intentions (#3940)

A satisfaction survey is a thing, not a rating field on a closed case. Four
schemas: the survey and its questions, the invitation that asks it, and the
answer set that comes back. The invitation is separate so that "who was asked
and never answered" is a question with an answer.

Every rule here is a promise made to somebody who cannot read the code, so each
one is a refusal that carries a sentence:

Anonymity is decided at creation and cannot be changed in either direction.
Switching it on hides answers somebody has already acted on by name, which does
not unread them. Switching it off names respondents who answered on a promise.

An anonymous survey withholds its answers below a minimum and shows the count
with the reason. An empty result reads as "nobody answered", which is a
different and false statement, and one somebody would report on.

The anonymous export has no respondent column AT ALL, not an empty one. An
empty column is an invitation to a join: the shape of the file says that is
what goes there, and somebody fills it from the invitation list in the next
sheet. The answer set drops the key rather than emptying it, for the same
reason: absent says the promise was kept, empty says nobody has filled it in
yet.

An expiry that will not parse is treated as expired, not as no expiry. Reading
it the other way turns one malformed timestamp into a link that works forever.

A missing required answer names the question. "Submission failed" sends
somebody back to a form of twenty questions to find the one.

A fatigue block is recorded with its reason and counted per respondent across
every survey. Somebody asked four times in a month does not care that it was
four different surveys, and a gap in the response data with nothing beside it
reads as nobody having been asked.

Tasks 1.1, 2.3, 3.2, 4.2, 4.3, 5.1, 5.2, 5.3, 6.1 and 6.2 of survey-object.
…3945)

hoursPerWorkingDay exists so hours and business days are commensurable, and it
stays for that. It cannot say WHEN the clock runs, so an hours term was computed
from a single opening minute and a day length. That is right only for an
organisation whose day is one unbroken block, and wrong by the length of the
lunch break for a counter that closes at midday.

A calendar may now declare serviceHours per weekday in its own zone, and an
hours term advances only inside them. A calendar declaring none behaves exactly
as it did, which is what lets this land without recomputing live terms.

Three refusals at write time, each naming the weekday. The one that matters is
the overlap: it double-counts its overlap, so every hours term on that calendar
fires early, for everybody, every time, and the fired term looks exactly like a
correct one. A backwards window and a window on a day the calendar does not work
are refused rather than ignored, because dropping the latter silently leaves
somebody believing the counter is open on Saturday.

The walk is bounded and running out throws rather than returning the cap's date.
A term that silently lands a year out is indistinguishable from a correct one,
and a statutory deadline is computed from it.

hoursPerWorkingDay is derived from the windows so the two cannot disagree.

The spec's own scenario for this change is off by an hour: a 4-hour term armed
on Friday at 16:00 against a 09:00-17:00 calendar lands on Monday at 12:00, not
11:00. The test follows the arithmetic and says so in its docblock rather than
pinning the wrong behaviour and calling it coverage. Flagged for confirmation in
the PR body.

Tasks 1.1, 1.3, 2.1, 2.2, 2.3 and 4.1 of service-hours-and-repeating-reminders.
…may read it (#3947)

openregister#3934 and #3938 left this named and undecided: the OpenAPI
description and the GraphQL type mapper describe a schema's shape,
including properties the caller may not read.

A field name is information. onderzoek_integriteit, schuldhulpverlening,
bijzondere_bijstand: the name alone says what category of fact is held, and
on a record about one person it says the fact is held about them. A
property carries an authorization block or a scope precisely because it is
sensitive, so the set of governed names is by construction the set most
worth not printing.

The objection is that a schema is a contract and a contract should be
stable. That is what settled it, and it settled the other way: the API
never returns a property this caller may not read, so describing it
promises a field that will never arrive. Leaving it out makes the document
more truthful, because it describes the API this caller actually has.

Its example and enum leave with it, since an example is a sample answer and
an enum is the set of permitted answers. Required drops names the document
can no longer mention, because a required list naming an absent property is
not a contract anyone can satisfy.

The omission is a count, never names. Naming them would be the leak with an
audit trail attached; saying nothing would leave an integrator unable to
tell a complete schema from the part they are allowed to see.

An administrator receives the complete description, because they already
bypass property-level reads everywhere else and making this the one place
they cannot see the schema would be a second answer that drifts.

Also narrows the fail-closed path to the property rather than the schema.
One governed property made a whole schema governed, so an unresolvable rule
withheld every property including ones nobody restricted, which protects
nothing and removes the document.
)

Everything that does not need the vendored OMG schema set. Two lines of the
spec do need it — "every exported file validates against the BPMN 2.0 XSD"
and the importer's validation before mapping — and neither is met. The
weaker checks that stand in their place are stated in the docblocks and in
the tests rather than implied, and the mapping report is NOT offered as a
substitute: a test asserts a non-XML file produces no report at all, because
a report over a malformed document attributes XML problems to process
constructs.

switch and route both export to an exclusiveGateway, so every node carries
its own type and config in extensionElements and the importer prefers that
over the element kind. Mutation-checked: letting the kind win brings route
back as switch, and the flow still looks right.

The importer never guesses a type from a name, lays out a file with no
diagram rather than piling it at the origin, refuses two processes rather
than taking the first, and keeps external entities off.

One defect caught before it shipped: I wrote FlowService::create(), which
does not exist. php -l cannot see it and a mock invents whichever method it
is asked for, so a controller test would have passed over a fatal. Fixed and
pinned by a test that reads the real class.
…3950)

Two things the serialisers landed without.

The dependency-direction check was the one unticked task in the change's own
list. Interchange is a boundary, not an execution semantic: if a run path ever
asked the BPMN code a question, the standard's vocabulary would start deciding
behaviour, and the next interchange change would be a change to the engine.
The test holds it both ways — the engine may not name the Bpmn namespace, and
Bpmn may not queue, advance or fire.

A sequenceFlow whose sourceRef or targetRef names nothing in the process is
not a slightly wrong diagram: every modeller refuses the whole file, so one
edge left behind by a deleted node turns the export into something nobody can
open. The engine refuses a dangling edge at build time, but a document
assembled from a stored node list can still carry one, and the export is where
it becomes fatal.
#3951)

* feat(archival): the anonymisation act refuses rather than guesses, and can be finished

Tasks 3.4 and 3.5, and the shape 3.2 and 3.3 plug into.

Four refusals, because this act is irreversible and reaches several stores, so
a best effort is the wrong shape:

A record under a legal hold is not anonymised, and the refusal names the hold. A
hold says somebody may still need the record as it is, and as it is includes the
name.

A record already anonymised is refused, naming when and under which profile.
Running again changes nothing it has not changed, and under a rotated salt it
would give the pseudonyms new values, so rows that used to join would stop
joining with nothing on screen to say so.

A record left half anonymised is resumed rather than restarted, and only under
the same salt. The marker carries a fingerprint of the salt, not the salt: a
resume under a different one refuses rather than leaving one record carrying two
families of pseudonym.

A plan that would change nothing is refused. Marking a record anonymised while
it holds every value it held before is the instrument lying about the thing it
measures.

All or nothing is achieved by preparing every target before applying any, so a
search index that cannot be reached stops the act while the record is whole. A
failure BETWEEN writes is the honest remaining case: the stores are separate and
no transaction spans them. The message says exactly that rather than implying a
rollback that did not happen, names what was written, and says the record must
not be treated as anonymised until a resume finishes it.

A mutation interleaving prepare and apply reddens the "nothing is written"
assertion, which is the test that makes the two-phase split load-bearing.

* feat(archival): the trail keeps the fact of an anonymisation and loses the values

Task 3.3's redaction half.

Every write leaves a diff on the chained trail holding the value before and the
value after. An anonymisation that changes the payload and leaves those diffs
alone has removed the citizen's name from one place and left it in every
historical row of the same record, where the trail shows it beside the record it
was removed from. That is a rename with a receipt.

Both sides of a redacted property go. Keeping old because only new matched the
payload is exactly how the name survives: the value a record used to hold is the
value being removed.

The shape stays. The property name, the untouched properties, the timestamps and
the actor remain, and a new entry records the profile and the scope. An auditor
can prove the act took place and prove which fields it covered, and cannot
recover the person from it.

A redacted value is marked rather than emptied, because "removed by an
anonymisation" and "was empty at the time" are different facts and only one is
about the citizen.

The act checks itself: leftovers() reads the redacted diff back and looks for the
removed values, because a redaction that missed a nested copy would report
success while the name sits one level down, and the trail is the last place
anybody would look for it.

* feat(archival): the sweep refuses one record at a time and counts the refusals apart

Built last, on purpose. Every refusal in AnonymisationRun exists for this class:
a person running one record can read an error and decide, a sweep cannot, so a
sweep that resolves an ambiguity by guessing resolves it the same wrong way
across every record on the instance before anybody notices.

Three shapes were available and two are worse. Stopping the batch on the first
refusal lets one held record block a retention obligation for everything else.
Skipping silently means the reasons never reach anybody while the sweep reports
a clean run over records it did not touch. So it refuses per record, keeps every
reason, and carries on.

The two counts are separate. "412 anonymised" and "412 anonymised, 9 refused"
are different sentences, and a sweep that adds them together says neither. The
nine are the ones somebody has to do something about.

It never retries a refusal on its own. A refusal is a decision waiting for a
person: a hold to be lifted, a schema to be corrected, a salt rotation to be
reckoned with. Re-attempting it nightly turns a decision into noise and the
record still is not anonymised.

A candidate with no uuid is refused, because an anonymisation nobody can point
at afterwards is not auditable.
Integriq's registry-backed-field-source built the resolver and has been
waiting on this key.

A correction to what the waiting was. The integriq tasks file, and my own
first measurement, said the key would fail a schema save. It would not:
assertKeysAreInTheVocabulary skips every x- prefixed key, so
x-openregister-property-source has always saved. What it could not do is be
discovered, because vocabularyKeys never published it, and nothing checked
its shape, so a provider of 7 or a mode of livee saved just as cleanly as
the real thing. That is the same failure the concept-scheme binding hit.

The shape is integriq's, adopted rather than invented: provider, config,
mode, taken from the change where the meaning was defined.

The mode defaults to live and default is asked for by name, because they
are different promises to whoever reads the record later: live means the
value is looked up when it is used, default means the provider only
supplied a starting value a person may change.

A property carrying both this key and x-openregister-object-source is
refused rather than ranked. They differ by one word and by their entire
blast radius, and the wrong half of a guess serves an entire register from
somewhere unexpected.

An existing test asserted the opposite and was right at the time: it said
publishing the key would be this layer inventing semantics for a key it did
not own. That reasoning is honoured rather than overruled, because the
meaning is now defined elsewhere and adopted here. Its fixture was never a
shipped shape; no register file in either repo carries this key.
…hat nobody imports (#3949)

survey_register.json shipped with no repair step, and appinfo/info.xml
named none, so the surveys register and its four schemas existed only in
the source tree. occ upgrade reported success and occ
openregister:descriptors:list would have reported surveys ABSENT.

This is the second time: the comment beside ImportFlowRegister in
info.xml records the first, eight of fifteen registers missing, found
only because two unrelated e2e suites died on a register slug nobody
could find.

So the step comes with a gate. Every descriptor whose x-openregister.type
is not mock must be named by a repair step that info.xml runs. The
control holds: all eleven other core descriptors have one and only the
six mock ones do not, which is what makes the survey case an exception
rather than a convention nobody follows.

The gate reads the path a step IMPORTS, not the text of its file. A
substring search over the source passed on a step whose REGISTER_PATH had
been repointed at another descriptor, because the class comment still
named the old one: the sentence explaining what a step does outlives the
constant that does it. Found by mutation.

Not asserted, and reported instead: ImportCredentialBrokerRegister pins
1.0.0 against a 1.4.0 register and ImportFlowRegister pins 1.4.0 against
a 1.3.0 register. Both predate this change, and a gate that fails on
inherited debt teaches everybody to skip it.
A saved view answers a question; nothing said anything when the answer got
large. An alert declares an operator, a threshold, who hears it and how often
it is evaluated, and every refusal names its field, because a 422 that does not
say what to fix sends somebody back to a form with five inputs.

It fires on the crossing, not on the state. A count above the line says so once
and stays fired until a sweep sees it back, then re-arms silently: nobody asked
to hear that a backlog cleared, and firing on the state would page a team lead
every fifteen minutes for as long as the backlog stood, which is how an alert
becomes a thing people filter out of their inbox.

The count is taken as the view's owner. A shared view alerts on what its owner
may see; counting as the system would turn a threshold on a shared view into a
way to learn how many records sit behind a filter the reader is not entitled
to. A view whose owner no longer exists is skipped rather than counted as the
system, and a count that cannot be taken leaves the state alone rather than
re-arming a fired alert.

The pass is bounded at 200 views, oldest evaluation first, so a thousand due
views take five passes and none starves behind a busier neighbour.

The dispatch is an event, not a notification, and the difference is written
down: every sender here takes an ObjectEntity and builds a deeplink from it,
and a view alert is about a number. Inventing an object to satisfy that
signature would put a fabricated record in the link somebody is told to click.
A leaf has two halves: a descriptor the server registers and a bundle the
browser loads. Only the first was checked. So a render-surface descriptor
from an app shipping no leaf bundle reached capability discovery,
getLeaves() returned it, the gate went green on both halves, and the
surface rendered nothing on every consuming page. Nobody was told, because
nothing had failed.

Measured on the development instance across 35 installed apps: 5 declare a
render surface, 2 ship a bundle, so 3 are dark today. One of the three did
build a bundle and named it hermiq-agent-leaf.js, while the loader reads
hermiq-leaves.js, so the work was done and the filename made it invisible.
That is why the refusal names the exact file the app must produce.

LeafBundle becomes the one answer to whether an app's leaf can render, used
by both the registry and the script listener. Two copies would drift, and
the registry accepting a leaf the loader never puts on a page is the
failure being fixed.

Three cases are deliberately not refused: a leaf with no render-surface
kind has no client half; a built-in leaf rides OpenRegister's own bundle;
and a disabled app is already reported unusable by describeForCapabilities,
which is right because enabling the app fixes it while a missing bundle
never fixes itself. An existing test caught that last distinction.
)

Corrects openregister#3954, merged this evening, before it broke a working
feature.

#3954 skipped registration for a render-surface leaf whose app ships no
js/<app>-leaves.js. That was unsound, and hermiq is the proof: it ships no
hermiq-leaves.js and its leaf is not dark. It loads its own
render-registration bundle on every Nextcloud page with
Util::addInitScript, precisely so it runs wherever another app renders the
integration registry. The refusal would have taken that down.

The lesson is about what the registry can know. Whether a bundle reaches
the page is a fact about the page; the registry sees only the filesystem.
The absence of one conventional filename is not proof of absence, because
it is one convention out of at least three and the app chooses which. I had
measured two of the three and called the answer complete.

The loud, actionable error stays, because naming the exact expected file is
what turned hermiq's invisibly-named bundle into a one-line fix. Only the
skip goes.

A sound refusal needs the descriptor to declare that it relies on the
shared entry, so an app loading its own bundle is never refused. That
declaration does not exist yet and is named in the tasks rather than
guessed at here.
#3956)

openregister#3954 refused a render-surface leaf whose app shipped no
js/<app>-leaves.js, and was wrong twice in one measurement: hermiq and
decidiq ship no such file and are not dark, because each loads its own
registration bundle on every page with Util::addInitScript. #3955
downgraded that to a report before it broke them.

The rule that came out of it is that refusing on filesystem evidence is
unsound, because whether a bundle reaches the page is a fact about the page
and the registry sees only the filesystem.

So a descriptor now says which of three conventions it uses: the shared
leaves entry, its own script, or already present. Only the first is
verifiable, because it is the only one where the platform does the loading,
and only a leaf claiming it is refused when the file is absent.

Silence is not a claim. A descriptor that declares nothing registers, as
#3955 left it, because every descriptor written before this says nothing
and refusing silence would re-create the original failure wholesale.

A fourth convention gets its own name rather than being folded in:
own-script means the app guarantees it, and a mechanism the platform could
verify deserves naming, because the value of the list is that one entry is
checkable and the others are trusted.
…ds on (#3957)

The task asked for two indexes; the source needs one, and saying so is the
point. FlowTimerMapper has exactly two calendar reads,
findOpenByCalendarSlug and countOpenByCalendarSlug, and both filter on the
same pair, calendar_slug and state. One composite index serves both. The
second index was to be on organisation, and no query filters on it, so it
would have cost a write on every timer armed and been read by nobody.

calendar_slug leads because it is the selective half: one calendar out of
many, against a state that is two values. The existing or_flowtimer_due_idx
on (state, fire_at) does not serve these reads for exactly that reason.

It changes the cost, not the answer. Without it the job paged the open
timers by id, which is an index read with a resumable cursor over a small
set, not a scan of every timer ever armed. A speed-up on a correct job.

Also records that section 2 of relations-that-travel must not be built
here: pipelinq already ships a relationship schema carrying fromContact,
toContact, fromType, toType, type, inverseType, category, notes, startDate,
endDate and strength. A second schema for one concept is the costliest kind
of duplicate, because two schemas mean two sets of stored rows and nothing
reconciles them afterwards. The validation and the direction-aware read are
genuinely unbuilt and belong to pipelinq, which owns the schema.
Both are satisfied by pipelinq's components.schemas.relationship, which
carries fromContact, toContact, fromType, toType, type, inverseType and the
period. Provenance, the one part it lacked, was added there in
pipelinq#1981 as an enum defaulting to the weakest value.

Recorded here so nobody builds the duplicate later. If a reader is tempted
to add partyRelationship, the answer is in pipelinq's schema, and the
reason not to is that two schemas for one concept mean two sets of stored
rows with nothing reconciling them.
…tion is verified (#3958)

A consuming app cannot hard-depend on OpenRegister, so every consumer invented
the same guard: class_exists, then run(), else call the operation plainly. The
fallback is the bug. It does not decline to elevate, it runs the identical write
as whoever is signed in and returns the same value the elevated call would have.
Nothing throws and nothing logs, so the write either records the wrong principal
or fails a permission check somewhere far away for a reason nobody connects back
to a missing class.

It also makes the codebase unsweepable. A reviewer asking which writes run as
the system cannot answer statically, because a call site naming the context may
or may not have elevated, and a scan for the idiom counts the degraded path as
elevated. That ambiguity is what stopped integriq's permission sweep: the safe
subset could not be identified, so nothing could be restricted.

assertSystem() says the same thing and refuses to be ambiguous. Either the
operation runs elevated or it throws, naming the write.

The elevation is verified rather than assumed, on BOTH sides of the operation.
Before, because an elevation that never applied is the ordinary failure. After,
because one that stopped applying part-way is the dangerous one: the write has
already happened, and checking only up front would call it elevated.

An earlier draft checked class_exists(self::class) and threw when false, which
cannot happen: a class that does not exist cannot run its own static method.
That guard was dead the day it was written, and a dead guard is worse than none
because it reads as a check and a later edit deletes it with every test green.
This lane has now removed two of those, so the replacement is tested by breaking
the elevation rather than by breaking the guard.
…nge (#3960)

* feat(audit): a write made with a token names the token, its owner and its consumer

The question the candidate actually asked is "which koppeling wrote this
field", and the trail could not answer it. It records what changed and who was
logged in; when the caller is a service account shared by four integrations,
that is not an answer anybody can act on.

- TokenResolver reads the session's app password back to its token record. An
  interactive login has no app password, which is the distinction the whole
  field rests on: a row naming a token has to mean a machine wrote it.
- The consumer is matched on the Nextcloud user a consumer already declares,
  not on the token's NAME. An app password's name is free text its owner can
  retype, and an attribution a rename silently redirects is worse than none.
  Two consumers sharing one user resolve to neither, for the same reason.
- AuthorizationService claims the issuer for a JWT call. It is the only place
  that knows which consumer presented the credential, and a JWT leaves no app
  password for the resolver to find, so without the claim every koppeling
  authenticating that way writes rows that cannot say who wrote them.
- TokenAttribution writes the triple twice, the shape PurposeAttribution
  established: sealed inside resultSummary, which the hash chain covers, and
  projected onto the new consumer column, which is indexed so "everything this
  koppeling wrote last month" is a lookup rather than a scan of the largest
  table in the app. disagrees() makes the column's unsealed status checkable.

The token VALUE never enters any of it. The trail is shipped off the instance
by design and kept for years; a credential in it is a breach waiting for
somebody to grep for it. TokenResolverTest holds that as an assertion rather
than an intention.

No payload, per D-4: the before and after already on the entry answers the
question a stored body was being kept for, without a second copy of personal
data with its own retention argument. carriesPayload() makes the prohibition
provable, and the test for it carries a control that proves the checker can
find a payload nested two levels down, so the absence it asserts is a real one.

18 unit tests, phpmd and the diff check both clean on every file touched.

* feat(audit): reported content keeps a copy that removal does not destroy

The proving system is forgejo's shadow copy: content reported for review is
frozen, so deleting it does not delete the evidence.

- The copy is taken when the report is FILED, never when a removal runs (D-5).
  A copy made at deletion time races the deletion, and the content removed
  fastest is usually the content somebody most wanted the evidence of.
- openregister_content_reports holds the frozen fields, a SHA-256 over them and
  the copy's OWN expiry, three years by default. It cannot inherit the object's
  retention: the sweep that deletes the content would take its evidence along.
  object_uuid is a uuid, not a foreign key, because the row outlives the object.
- Reading a copy is the reviewer group's, content-reviewers by default and
  deliberately not admin. The group is pinned on the report at filing, so
  widening the configured group later does not widen access to copies already
  taken. A group lookup that fails refuses.
- ContentReport::jsonSerialize() leaves the copy out. That omission IS the
  access control: the copy has its own endpoint behind its own check, and a
  copy in the serializer would leak through every list of reports.
- ContentReportRemovalListener, on ObjectDeletedEvent, notes the removal on
  each report and writes an audit entry naming the copies, so somebody reading
  the trail can reach the evidence too. Unreported deletes write nothing, and a
  failed note never blocks a removal somebody may be required to make.
- POST /api/content-reports is open to any authenticated caller: reporting must
  not be a privilege. The list, the report, the copy and the review outcome are
  reviewer only.

24 unit tests. phpmd and phpcs clean on every file this commit touches; the diff
check's one NEW finding (a test property docblock) is fixed here.

* feat(audit): a security-relevant setting change is announced to the administrators

Redmine's security_notifications is the proving system: it tells somebody at
the moment a security setting changes. Announcing is not recording (D-6). The
record belongs to settings-change-audit; a small beheerteam does not read a
trail every morning, and a switched-off access check is the change they need to
hear about that day.

- SecuritySettingRegistry is the marker: sixteen settings, each with its label,
  its default and whether it holds a secret. A list rather than an attribute
  scattered through the settings code, because "which settings will page the
  beheerteam" needs one answer somebody can read.
- The default is why saving a settings page for the first time is silent. An
  unset value and its default are the same value, and treating them as
  different would announce a change nobody made.
- A secret never reaches the notification, not even its parameters. Nextcloud
  stores those in its database and can mail them, so masking at render time
  would leave the credential in a table and an inbox. isSecret() also catches
  any path naming a password, secret, token or key, because the two mistakes do
  not cost the same.
- The snapshot is taken before and after the save inside the handler, not
  derived from the request: a setting the request omits keeps its stored value,
  so comparing against the request would both invent changes and miss real ones.
- Notifier renders the subject. Without that case prepare() throws on an unknown
  subject and the announcement is dropped, which is the failure mode this whole
  requirement exists to prevent.

110 unit tests green across the notifier, the announcer and the settings
handler. The e2e spec is written and tagged, including a control that an
unmarked setting announces nothing, and left for the nightly run.

* fix(audit): the inherited migrations hand the schema back

Both predate the guard that says a changeSchema() must return $schema: a null
return drops the shared snapshot and makes the next migration re-introspect
the whole database. Fixed on the way in rather than landed as two new
violations of a test that was already failing, where they would have hidden
inside a red nobody reads twice.
AnnotationNotificationDispatcher had `if (count($recipients) === 0) { continue;
}`. No log, no counter, no complaint. And declared groups ship EMPTY on purpose
across this fleet, because an empty group denies everyone except admins and
object owners, which is the right default. So on a fresh install a correctly
written rule addressed to a declared group resolves to nobody and reports
exactly what it would report having reached everybody. Every annotation added on
top of that reaches nobody.

Two halves, because the two cases are genuinely different.

At declaration time, a recipient that can NEVER resolve is refused: `groups: []`
and `users: []` name nobody structurally, so no instance state makes them match
and they are stubs or typos. A declared group that happens to be empty today is
NOT refused, and there is a test asserting that, because refusing it would fail
the import of every correctly written annotation on a fresh install.

At dispatch time, a rule that reached nobody is recorded. Only when the parties
path reached nobody either: dispatchToParties now returns a count rather than
void, because a rule addressed to parties legitimately resolves to zero account
recipients while still reaching people by e-mail, and reporting those would be a
false alarm on every party-addressed rule.

It is said ONCE PER RULE per run. A sweep over four hundred objects with one
unstaffed group would otherwise write four hundred warnings, and a log nobody
can read is the same silence with noise in front of it. The rule count and the
occurrence count are kept apart: one rule failing four hundred times and four
hundred rules failing once are very different problems. The report is returned
as well as logged, because a finding that exists only in a log file is findable
by whoever already suspects it.

NO FALLBACK RECIPIENT, and that is an argument rather than an omission. Routing
every misconfigured rule to administrators would mail them until they stop
reading any of it, and some of these messages carry case content addressed to a
group chosen precisely because it may see that case. Making the silence visible
is a smaller change than deciding on somebody's behalf who may read a
notification.
…h are right (#3962)

The same declaration was read two ways: a schema cascade denies on an empty
action, a property block admits. Reported as an inconsistency with one side
failing open. Measured, it is not.

A schema cascade is the last word, so an empty list can only mean denied. A
property block is a narrowing on top of the object cascade, so an action it
does not name has no opinion at that layer and the object's own rules still
have to pass. getUnauthorizedProperties only consults properties carrying a
block, and one it does not refuse is still written through the ordinary
object permission check. So the property side is not a fail-open to anyone;
it is no extra restriction here. The word accessible in that comment was
wrong and is what made it read as a leak.

Harmonising would cost more than it buys, and that was measured before
deciding: across the installed fleet there are 8 property-level blocks and
all 8 are partial, not one naming all four actions. A fail-closed property
layer would make every action they do not name unwritable, breaking all
eight in decidiq and stackiq. A guard satisfiable only by breaking what it
guards is worse than no guard.

What is genuinely sharp is left as a schema author's decision and named
where they will meet it: a property restricting read and saying nothing
about update can be written by anyone who may write the object, including
somebody who may not read it. That is a blind write rather than a
disclosure, and which of the two a schema wants is that schema's judgement.
…what would give it one (#3963)

openregister#3961 shipped the code with no openspec change written down. This
writes it down, and the part worth writing down is what it does not do.

RuleReachRecorder::report() has no caller. Measured on parity/round2 at
1a895e0: ten ->report() call sites in lib, all on other classes; the class
appears in lib in three files, itself, the dispatcher's property and default,
and one comment in the validator; and it has no registration in lib/AppInfo/.
The dispatcher builds its own with new RuleReachRecorder() and keeps it
private. So the aggregate is not merely uncalled, it is unreachable: the part
that says how many rules reached nobody, and whether one rule failed four
hundred times or four hundred rules failed once, is discarded with the
dispatcher instance.

What is real today is the one warning line per rule per run. An administrator
finds it by searching for the marker, which means only if they already suspect
the problem, which is the wrong audience. The tests on this class should not be
read as evidence that an operator is being told, so the class now says that
above report() rather than leaving it to be inferred.

This is the dark-capability shape we have been closing all night: a method that
exists, is tested, reads as a feature in review, and is reachable by nothing.
Recording it rather than letting it sit unrecorded because a leaf app worked
around it.

What would give it a caller, in order of cost: register it as a shared service
and read it on the notification settings page, where an administrator
configuring notifications is already standing; a scheduled job raising a
notification when needsAPerson is true, with the storm question answered first;
persisting it so "has this rule been unstaffed for three weeks" is answerable.
The settings row is the honest first step, and it is what tasks 3.1 to 3.3
name. REQ-RRN-03 is declared and marked NOT MET on purpose.

filinq#1136 answers a different question in the meantime, at read time and on a
screen, for the rules that one app declared. That is not a substitute: report()
is the platform's answer across every rule on the instance.

No behaviour change. openspec validate --strict passes, php -l and phpcs clean
on the edited file at 0 errors, the eight RuleReachRecorder tests still green.
…an administration write asks for (#3965)

Part 2 of instance-hardening-controls, on top of part 1 (#3850). Two of the
five remaining sections: the accepted statement (REQ-IHC-001) and elevation
(REQ-IHC-002). Sections 3, 4 and 5 are not built, and tasks.md says so task by
task rather than leaving half-written code behind.

THE STATEMENT CARRIES A VERSION. Recording that somebody accepted "the privacy
statement" is worth nothing the day the statement changes: the record then says
a person agreed to a text nobody can produce. So the acceptance carries the
version it was given for, publishing a new version asks everybody again, and
accepting a version that is not in force is refused rather than trusted, so a
client cannot close the gate on a text the user was never shown.

ELEVATION IS THE CHECK A STOLEN SESSION DOES NOT PASS. A Nextcloud session
lives for a day and an administrator leaves it open. `POST
/api/hardening/elevation` confirms the password of the session's own account,
throttled through a new `ThrottledSurfaces::ELEVATION` because a correct guess
there buys the right to weaken every control on the report. The period is
`admin.elevationSeconds`, a control with an `atMost` floor, because a longer
window is a weaker instance. The four administration writes call
`requireElevated()` and answer 403 with the period, and the guard fails closed
on no moment, an unreadable one and a moment in the future.

The account is never read from the request, on either surface: an elevation or
an acceptance naming a user id would let one person act for another.

Verified: php -l on every changed file; 75 unit tests over the hardening
services and the controller, green; mutation-checked by removing the guard from
updateControls, which reddens the "setControl was not expected to be called"
assertion; openspec validate --strict. The e2e spec now elevates before each
write, which is the behaviour change a client sees.
…3964)

* wip: work in progress saved when the weekly limit stopped the lane

* feat(apphost): a page opens without a session only when the app declares it public

A citizen holding a live access link reached a login screen: the page that
would render their record is served by the SPA catch-all, and the catch-all
requires an account. Making it public would have opened every page of every
adopting app at once, so the public surface is a route of its own.

`PublicPageResolver` reads the leaf app's bundled manifest and answers one
question: did the app declare this path public, with `config.mode: "public"`
and a route under `/public/`? Both, or the page stays shut. A manifest that is
missing, unreadable or invalid JSON declares nothing.

`Routes::standard($extra, publicPages: true)` adds the route, opt-in, after the
app's own routes and before the catch-all. The shell carries no record data,
only an initial-state key telling the app it booted public.

Also openregister#3818: the object share token answered with the whole object,
`@self.authorization` included. It now projects through the access link reader,
so the two anonymous surfaces publish one allow-list, and the reader projects
timeline entries too, because a public entry's text is public and the account
that wrote it is not.

The leaf half, dossiq declaring its status page, is task 6.1 and its own PR.
rubenvdlinde and others added 30 commits September 28, 2026 20:12
… can pass (#4152)

* test(rbac): a create rule with a match is evaluated against the incoming object (red, #4094)

* fix(rbac): ask the create question about the incoming data so a match can pass (#4094)

checkSavePermissions() now builds a transient object from the request body
without its @self block, with the caller's active organisation and no owner,
and passes it to both create checks. A create is exempt from the private
scope gate, as it was when no object was passed.
…t what cannot be built (#4154)

* test(rbac): an unsupported match operator is refused at save and denies on list as on find (red, #4089)

* fix(rbac): refuse unknown match operators at save, apply every operator on the list and deny what cannot be built (#4089)

Schema save refuses a $ operator outside the ten the evaluators handle and an
$in/$nin operand that is neither a list nor a $token. The list query now ANDs
every operator of a property instead of the first, emits the impossible
predicate for an operator or operand it cannot build, and turns $in: [] into
a deny and $nin: [] into IS NOT NULL. OperatorEvaluator denies a non-list
$in/$nin operand; $nin over a scalar used to grant.
… from (#4157)

* test(export): a property filter narrows the export (red, #4088)

* fix(export): an export keeps the property filters of the list it came from (#4088)

fetchObjectsForExport() skipped every filter that was not an @self filter, so
CSV, Excel, JSON and PDF exports, the row count, export profiles and scheduled
reports all carried every readable row. Property filters now reach
searchObjects(); the route's own parameters and _-prefixed controls do not.
…nges (#4158)

* fix(schemas): the schema tool and the source merge classify their changes

After #4147 two definition update paths still wrote without the versioning
service: SchemaTool::updateSchema() (the agent tool) and the applied
update-from-source merge in SchemaImportController. Both now classify the
change against the stored definition before it is made, bump the version
by the classification, and write the changelog entry after the update.
Neither has anybody to answer a breaking-change prompt, so, as on the
configuration import, a breaking change is recorded rather than refused.

Fixes #4102

* docs(schemas): document the versioning parameter of SchemaImportController
…ithout listing, so their coverage is counted (#4155)
… retire the l10n generator

The secret is written before the metadata is saved, and the save is
where the schema validates. A rename past the schema's bounds with a
rotation therefore changed the secret and then answered 500.
CredentialUpdateRequest::exceedsBounds() checks the brokeredcredential
maxLengths (name 255, each allowedApps entry 64, in characters) first,
and update() answers 400 before anything is written.

The logger fake now records the context, and both failure tests assert
it is exactly the credential id, so logging the exception with its
secret-carrying trace turns them red. The save failure without a
rotation has its own test.

scripts/build-l10n-js.js rebuilt every l10n/*.js from l10n/*.json, but
this repo keeps them as separate catalogues, so a rebuild deleted every
frontend-only string. The generator, its l10n:build and check:l10n-js
scripts and the CI leg are removed; test:l10n:parity still gates the
.js set. check-schema-l10n.js now reads l10n/en.js, the catalogue t()
renders schema strings from; its count and baseline are unchanged.

CHANGELOG.md notes the PUT /api/credentials/{id} change.
…rt-status-codes

# Conflicts:
#	tests/Unit/Controller/CredentialControllerTest.php
#	tests/Unit/Controller/CredentialOauth2ControllerTest.php
#	tests/Unit/Service/Credential/OAuth2InstanceClientTest.php
Both allowedApps cases were ASCII, so byte and character counts agreed
and a strlen in place of mb_strlen on that bound passed every test. The
at-bounds case now uses 64 multibyte characters, and 65 of them is a new
out-of-bounds case.
…ationContext

Nextcloud 35.0.1 added registerSystemReportSection() to
IRegistrationContext, so on stable35 the test double no longer
implemented the interface and PHPUnit died with a fatal error before
running a test. It is a no-op, like the other registrations these tests
do not use.
fix(credentials): start an OAuth2 connection, and answer each refusal with its own status
…nswers 409 (#4174)

* wip(audit): findByObjectUntil rewrite for #4161, mid-edit when the lane was killed by the weekly limit; not verified

* fix(revert): a revert runs against the real audit table, by slug too, and a frozen PATCH answers 409 (#4161)

findByObjectUntil filtered on a column object_id the audit table never had, so
every revert answered 500. It now matches on object_uuid and resolves an audit
id or a version to the entries after it; revertChanges writes the old values
back into the object data. The red test runs the query against the table the
migrations build in SQLite (old query: 'no such column: object_id').

RevertHandler accepts the register and schema by id, UUID or slug. PUT, PATCH
and POST-patch map ObjectStateWriteException to its declared 409 instead of
403 or a bare 500.

* fix(audit): resolve the revert point without an else clause (phpmd)
…ot an update (#4173)

saveObject() refused any write carrying a uuid on an append-only schema,
and an id in the body becomes that uuid. So every insert with a chosen id
was refused, which is every xAPI statement learniq's LRS stores.

The guard now asks whether an object with that uuid is stored (unfiltered
by RBAC and tenant, soft-deleted rows included, scoped to the register and
schema). Stored: refused as an update, as before. Not stored: the write
goes down insert-only, so a concurrent insert of the same uuid loses at
the _uuid unique constraint with a 409 instead of becoming an update.

Assisted-by: Claude Code
…ng the operator (#4178)

* fix(schemas): a malformed authorization rule is refused with 400 naming the operator (#4162)

The schema validator threw plain InvalidArgumentExceptions, and the schema
controller guessed the status from words in the message. The match-operator
refusals ('Authorization match for ... uses the unsupported operator') carried
none of those words and answered a bare 500. The validator now throws
InvalidAuthorizationRuleException (a subclass, so existing catches still hold),
and create, update (PATCH) and upload-update answer it with 400 and the message.

* wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified

* fix(schemas): name the message argument of the new exception (phpcs), and stop tracking the node_modules symlink
#4180)

* fix(import): pass 2 of a configuration import keeps the version pass 1 bumped (#4163)

importFromJson() imports each schema twice. Pass 1 classified the change and
bumped the version; Pass 2 re-imported the same data with force, found nothing
to classify, and wrote the incoming (older) version back, so the schema kept
0.0.4 while its changelog named 1.0.0. An import no longer moves a schema's
version back: when the incoming version is not newer, the stored one stays, or
the classification bumps it. The red test runs both passes over a stateful
mapper with the real versioning and diff services.

* wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified

* chore: stop tracking the node_modules symlink
…ad (#4182)

* fix(rbac): a full save keeps a property the writer was not allowed to read (#4170)

A property whose authorization.read the caller fails is stripped from their
read, so a GET, edit, PUT round trip sent a body without it and the PUT
null-fill erased the stored value (humaniq's BSN, IBAN and salary for a
manager). PropertyRbacHandler::collectOmittedUnreadableProperties() names the
omitted properties the writer cannot read on the stored object, and SaveObject
carries them forward with the write-only restore (#463). A writer who can read
the property and leaves it out still clears it.

* wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified

* chore: stop tracking the node_modules symlink
)

* feat(export): another app can render the rows it fetched as a PDF (portaliq#765)

ExportService::renderRowsToPdf(title, columns, rows) renders caller-supplied
rows as a PDF table through the same Dompdf sandbox and under the same
MAX_PDF_EXPORT_ROWS cap as exportToPdf(), and reads nothing itself. Portaliq
needs it because a resident's scoped collection is not a Nextcloud user's
search, so exportToPdf() cannot fetch it. Columns are key => label, or a list
of keys that are their own labels; lists render comma-separated, other
structures as JSON, every cell escaped and truncated as the existing export does.

* wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified

* chore: stop tracking the node_modules symlink

* refactor(export): the rows section is its own class, so ExportService stays under the method limit (phpcs, phpmd)
… integriq (#4186)

* docs(openspec): change expression-value-sources, conditions read integriq's allowlisted value sources (#4169)

* feat(rules): a condition can read an allowlisted value source through integriq, and fails closed without it (#4169)

* chore(openspec): archive expression-value-sources and fold its requirement into flow-engine
…verdue notice (#4188)

* feat(flow): a party is told when their portal task is overdue (#4166)

A postBreach escalation rung falls after the deadline and is addressed to the
party; the portal reminder listener records it as an overdue delivery
(PortalTaskDelivery::KIND_OVERDUE), its message carrying the consequence the
case type words. slaBreached still escalates inward only. The rung vocabulary,
the fired event and the escalation-ladder register schema carry postBreach and
consequence; both new schema strings are in every locale.

Also fixes the preBreach reminder itself: the listener checked the timer with
method_exists(), which is false for FlowTimer's Entity magic getters, so no
reminder ever reached a party outside tests that faked the timer. The new
tests use the real FlowTimerFiredEvent and FlowTimer.

* wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified

* chore: stop tracking the node_modules symlink

* chore(l10n): add the two flow strings as appended entries, keep the bundles' order

* fix(flow): validate the postBreach ladder with the real schema validator, bump the ladder schema, keep each docblock on its method

* fix(flow): document the consequence parameter, capitalise the comment (phpcs)
…key (#4177)

* fix(rbac): accept a property's authorization.audit flag as a control key

`audit: true` on a property's authorization block is the
sensitive-field-reveal-audit flag RevealCollector reads, and
SchemaMapper::validateRevealAudit() checks its shape. It was not in
PermissionCatalogue::CONTROL_KEYS, so Schema::validateAuthorizationRules()
read it as a verb and refused every schema declaring it:

  Invalid authorization action 'audit' in property 'personalNumber'.

Seen live 2026-09-29 on a throwaway instance: learniq's LearnerProfile
(personalNumber, the BSN, D31) could not be imported, and the learniq
register came up without its learner schema.

The new test is red before the fix (1 failure) and green after.

* fix(tests): the migrated test database speaks Nextcloud 35's schema and result API

AuditTrailMapperRevertQueryTest (from #4174) errored on every test in the
stable35 CI cell, so every PR since has been red on PHPUnit.

On Nextcloud 35 ISchemaWrapper::getTable()/createTable() are typed
OCP\DB\Schema\ITable and the real wrapper hands out OC\DB\Schema\Table
around the Doctrine table. The fake wrapper returned the bare Doctrine
table and failed its own return type. dropTable() returned the Doctrine
schema where the interface says self. Past that, NC 35's QBMapper reads
rows with IResult::fetchAssociative(), which the result bridge refused.

The fake now returns what the running Nextcloud's wrapper returns, and the
result bridge answers whichever fetch methods the loaded IResult declares,
so the test runs against both the vendored OCP and NC 35.

Assisted-by: Claude Code
…4194)

* feat(archival): two matters can hold the same record without releasing each other (#4172)

retention.legalHold.holds is a list of holds, one per owner key (for example
filinq:legalHoldCase:<uuid>). Placing adds or updates that owner's hold;
releasing with an owner key lifts only that hold and moves it to history; a
release naming no owner lifts every hold, as before. legalHold.active is
derived (any hold active), so every reader of it is unchanged, and a stored
single slot reads as one hold owned by openregister:manual. LegalHoldService
and RetentionService now share LegalHoldLedger; both endpoints take ownerKey.
Change: legal-hold-per-matter.

* wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified

* chore: stop tracking the node_modules symlink

* chore(openspec): archive legal-hold-per-matter and fold its requirement into archival-destruction-workflow

* fix(archival): no inline if in the hold ledger write (phpcs)

* fix(retention): group the legal hold param tags (phpcs)

* fix(archival): return early instead of else when a matter restates its hold (phpmd)
… _multitenancy (#4192)

deleteObject() forwarded both flags to the delete handler, but the two
lookups it runs first did not: the owner lookup for the permission check
and the transferred-object guard both applied the session's RBAC and
tenant scope. A caller that passed _multitenancy: false (learniq's xAPI
document store, answering a cmi5 AU with no session) got "Object not
found in magic table" for an object that exists, and the handler never
ran. The same gap let the transferred guard miss an object outside the
session scope and wave its delete through.

Both lookups now use the caller's flags. deleteObjects() already did.

Assisted-by: Claude Code
…#4198)

* feat(approval): amount tiers can be cumulative, so an amount needs every tier at or below it

* chore(openspec): archive approval-cumulative-tiers and fold REQ-010 into approval-workflow

* docs(openspec): cumulative tiers is REQ-011, REQ-010 was taken
#4217)

* feat(extraction): another app can hand in the text it read from a scan (#2033)

TextExtractionService::extractFromProvidedText(fileId, text, entityTypes,
method='ocr') indexes text another app extracted, such as filinq's local OCR of
a scan: sanitised, chunked and stored as the file's chunks, then entity
recognition and the risk level when recognition is on. The file must exist and
its own content is not read. extractFile() and the new seam share one indexing
path, and the metadata chunk now records extraction_method (llphant or what
the caller named).

* wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified

* chore: stop tracking the node_modules symlink

* docs(text-extraction): specify text another app hands in for a file (#2033)
…n envelope reaches it (#4216)

* fix(encryption): an encrypted property gets a TEXT column, and only an envelope reaches it

Updating any object whose schema has an x-openregister-encrypted property
failed with "column personal_number ... does not exist" (#4197).

The table sync skipped encrypted properties, on the belief that the value
lives in an `object` JSON blob column. No such column exists. The write
path kept naming the property, so every single-object save failed, and
the bulk path, which drops unknown columns, discarded the value without a
word: learniq's 203 example learner profiles have no personalNumber.

An encrypted property now gets a nullable, unindexed TEXT column, since
its value is an opaque envelope whatever its declared type. Existing
tables pick the column up through the missing-column sync on first use.

The bulk path never ran SaveObject's encryption step, so with a column in
place it would have stored plaintext. MagicMapper now encrypts flagged
properties at the table boundary for single and bulk writes (idempotent,
an envelope passes through), and refuses the write if no encryption
handler is available rather than store it in the clear.

Fixes #4197

Assisted-by: Claude Code

* fix(quality): import RuntimeException in MagicMapper (phpmd MissingImport)

Assisted-by: Claude Code
…r its own prefix (#4220)

AuditTrailMapper::countByActionPrefix() answers the lifetime count per action
for one prefix in a grouped query, and the list filter action=<prefix>.* answers
every row of that prefix. portaliq's proof records (DECISIONS row 5) no longer
need a full load per verb on every metrics scrape.

Assisted-by: Claude Code
…s stored (#4222)

* feat(views): a saved view can be shared with a group, and the share is stored

View::setSharedWith() had no caller and ViewShareResolver::validateShares() no
call site: create, update and patch now pass sharedWith through the validator
(400 naming the finding for a group that does not exist) into ViewService, and
the edit screen sends sharedWith instead of the sharedGroups nothing read.
Change view-group-share archived.

Assisted-by: Claude Code

* style(views): prettier on the view share modal

* style(views): build the group shares before the update payload, so eslint and prettier agree

* style(views): name why ViewService::update takes ten parameters

Assisted-by: Claude Code
…e empty string (#4224)

A non-admin reading an object whose schema has a read rule matching on
false got HTTP 500 on PostgreSQL: invalid input syntax for type boolean:
"". hermiq's agent rule {"isPrivate": false} hit it on every
single-object read through findAcrossAllMagicTables.

The query-builder RBAC path bound the resolved match value with the
default PARAM_STR. PDO casts a PHP false to '' (true to '1', which pgsql
happens to accept), so only rules on false failed. The raw-SQL list path
already wrote TRUE/FALSE, so list and single-object reads disagreed.

buildPropertyCondition() and buildComparisonOperatorCondition() now bind
a bool with PARAM_BOOL: a real boolean on PostgreSQL, 0/1 on MySQL.

The operator test's query-builder double returned a string where the real
builder returns an IParameter; it now returns an IParameter.

Assisted-by: Claude Code
…4226)

* feat(views): a saved view can be shared with a group, and the share is stored

View::setSharedWith() had no caller and ViewShareResolver::validateShares() no
call site: create, update and patch now pass sharedWith through the validator
(400 naming the finding for a group that does not exist) into ViewService, and
the edit screen sends sharedWith instead of the sharedGroups nothing read.
Change view-group-share archived.

Assisted-by: Claude Code

* feat(views): a write member of a shared view saves its query, judged on what changed

The update and patch paths looked a view up as the caller's own, so a write
member got 404. They now resolve any view that reaches the caller (owned,
group share, public, administrator) and save it under its own owner. The field
guard judges only the fields whose value changes, by value and order-insensitive
for query keys and shares, so the edit screen's full body refuses nothing a
member did not touch. Change a-view-update-is-judged-by-what-changed archived;
row srch-saved-shared built.

Assisted-by: Claude Code

* fix(views): keep ViewsController under the phpmd class-length limit

Drops a comment that described a lookup refuseForbiddenViewFields() no longer
makes, and tightens three new comments. No code change. phpmd was the only NEW
finding of check:strict on 9d65af3 (1013 lines against 1000).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants